Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(inventory): introduce OmegaConf as optional inventory backend #995

Closed
wants to merge 42 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ea3131b
feat(inv): introduce OmegaConf as optional inventory backend
MatteoVoges Apr 26, 2023
9c57cae
remove print statement
MatteoVoges Apr 27, 2023
770404c
fix(lint): fix linting
MatteoVoges Apr 27, 2023
5de9d50
Merge branch 'master' into pluggable-inventory
MatteoVoges Apr 28, 2023
c42b1b1
Merge branch 'master' into pluggable-inventory
MatteoVoges May 3, 2023
083160f
add first version of migration script
MatteoVoges May 10, 2023
d9a1d23
add flag behavior for `--compose-node-name` and `-t`
MatteoVoges May 10, 2023
fc314f7
add resolving step for references in migration script
MatteoVoges May 11, 2023
0e76778
feat: finish migration script
MatteoVoges May 17, 2023
742b2bb
feat: enable migration support as query
MatteoVoges May 17, 2023
79bf1e9
add local module omegaconf to have new features
MatteoVoges Jun 1, 2023
6b54600
add migrated inventory to test the feature
MatteoVoges Jun 1, 2023
60c56ea
refactor: add error handling and correct merging flags
MatteoVoges Jun 1, 2023
b1f85e7
deps: add temporarily deps ruamel and regex for migrating
MatteoVoges Jun 1, 2023
3b5c8db
feat: prepare omegaconf for multiprocessing
MatteoVoges Jun 1, 2023
d20be19
feat: add generated grammar
MatteoVoges Jun 8, 2023
a7944a6
change route of module oc
MatteoVoges Jun 8, 2023
91a85e8
fix: change module import path
MatteoVoges Jun 14, 2023
3217401
Merge branch 'master' into pluggable-inventory
MatteoVoges Jun 14, 2023
3e94da2
fix: change import paths for omegaconf
MatteoVoges Jun 14, 2023
88175c6
feat: resolve relative class name
MatteoVoges Jun 14, 2023
6b53e02
refactor: adapt new merge interface
MatteoVoges Jun 14, 2023
6147f0d
refactor: remove examples inventory for omegaconf
MatteoVoges Jun 15, 2023
f38696f
feat: change migration via flag, not query
MatteoVoges Jun 15, 2023
45d44a3
refactor: remove unneccessary debug and comments
MatteoVoges Jun 15, 2023
c684376
feat: add option to pull omegaconf locally
MatteoVoges Jun 15, 2023
4a72a40
lint: fix type annotations
MatteoVoges Jun 15, 2023
22fb477
refactor: remove directory oc after pulling
MatteoVoges Jun 15, 2023
cfc85b7
feat: support init class
MatteoVoges Jun 21, 2023
5155f0e
perf: use faster function `unsafe_merge`
MatteoVoges Jun 21, 2023
be32b38
feat: add more custom resolvers
MatteoVoges Jun 22, 2023
641dc73
refactor: add more resolvers
MatteoVoges Jun 28, 2023
7dc7585
fix: namespace error with flag migrate
MatteoVoges Jun 28, 2023
47b2c54
feat: add ability to define user resolvers in inventory
MatteoVoges Jun 29, 2023
b605005
fix: user written resolvers replace system resolvers
MatteoVoges Jun 29, 2023
3c616d5
feat: restructure resolving and migrating
MatteoVoges Jul 5, 2023
08a2194
chore: remove ruamel-yaml and add omegaconf in poetry-file
MatteoVoges Jul 5, 2023
228ac23
Merge branch 'master' into pluggable-inventory
MatteoVoges Jul 5, 2023
ba04981
fix: resolver escape_tag was missing braces
MatteoVoges Jul 12, 2023
78e83ff
fix: correct wrong behavior of resolver `tag`
MatteoVoges Jul 13, 2023
1f7923a
feat: prepare support for lint
MatteoVoges Jul 13, 2023
8fd6d93
Merge branch 'dev' into pluggable-inventory
MatteoVoges Jul 19, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,10 @@ local_serve_documentation:
mkdocs_gh_deploy: # to run locally assuming git ssh access
docker build -f Dockerfile.docs --no-cache -t kapitan-docs .
docker run --rm -it -v $(PWD):/src -v ~/.ssh:/root/.ssh -w /src kapitan-docs gh-deploy -f ./mkdocs.yml

pull_oc:
rm -rf omegaconf
git clone --branch 1080-add-list-deep-merging https://github.com/nexenio/omegaconf.git oc
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MatteoVoges this is not needed anymore now that it has been merged right?

pip install -r oc/requirements/dev.txt -e oc/
mv oc/omegaconf .
rm -rf oc
32 changes: 32 additions & 0 deletions kapitan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,20 @@ def build_parser():
help="dumps all none-type entries as empty, default is dumping as 'null'",
)

compile_parser.add_argument(
"--omegaconf",
help="use omegaconf as inventory backend",
action="store_true",
default=from_dot_kapitan("compile", "omegaconf", False),
)

compile_parser.add_argument(
"--migrate",
help="migrate inventory to omegaconf",
action="store_true",
default=from_dot_kapitan("compile", "migrate", False),
)

compile_selector_parser = compile_parser.add_mutually_exclusive_group()
compile_selector_parser.add_argument(
"--targets",
Expand Down Expand Up @@ -379,6 +393,12 @@ def build_parser():
default=from_dot_kapitan("inventory", "multiline-string-style", "double-quotes"),
help="set multiline string style to STYLE, default is 'double-quotes'",
)
inventory_parser.add_argument(
"--omegaconf",
help="use omegaconf as inventory backend",
action="store_true",
default=from_dot_kapitan("inventory", "omegaconf", False),
)

searchvar_parser = subparser.add_parser(
"searchvar", aliases=["sv"], help="show all inventory files where var is declared"
Expand Down Expand Up @@ -505,6 +525,12 @@ def build_parser():
action="store_true",
default=from_dot_kapitan("refs", "verbose", False),
)
refs_parser.add_argument(
"--omegaconf",
help="use omegaconf as inventory backend",
action="store_true",
default=from_dot_kapitan("inventory", "omegaconf", False),
)

lint_parser = subparser.add_parser("lint", aliases=["l"], help="linter for inventory and refs")
lint_parser.set_defaults(func=start_lint, name="lint")
Expand Down Expand Up @@ -548,6 +574,12 @@ def build_parser():
default=from_dot_kapitan("lint", "inventory-path", "./inventory"),
help='set inventory path, default is "./inventory"',
)
lint_parser.add_argument(
"--omegaconf",
help="use omegaconf as inventory backend",
action="store_true",
default=from_dot_kapitan("inventory", "omegaconf", False),
)

init_parser = subparser.add_parser(
"init", help="initialize a directory with the recommended kapitan project skeleton."
Expand Down
198 changes: 198 additions & 0 deletions kapitan/omegaconf_inv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#!/usr/bin/env python3

# Copyright 2023 nexenio
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same copyright msg here too

import logging
import os
import time

import regex

from kapitan.errors import InventoryError
from kapitan.resolvers import register_resolvers
from omegaconf import ListMergeMode, Node, OmegaConf, errors

logger = logging.getLogger(__name__)


def inventory_omegaconf(
inventory_path: str,
ignore_class_notfound: bool = False,
targets: list = [],
compose_node_name: bool = False,
) -> dict:
"""
generates inventory from yaml files using OmegaConf
"""

# add config option to specify paths
targets_searchpath = os.path.join(inventory_path, "targets")
classes_searchpath = os.path.join(inventory_path, "classes")

register_resolvers(inventory_path)

selected_targets = []

# loop through targets searchpath and load all targets
for root, dirs, files in os.walk(targets_searchpath):
for target_name in files:
target_path = os.path.join(root, target_name)

# split file extension and check if yml/yaml
target_name, ext = os.path.splitext(target_name)
if ext not in (".yml", ".yaml"):
logger.debug(f"{target_name}: targets have to be .yml or .yaml files.")
# RAISE ERROR
continue

# skip targets if they are not specified with -t flag
if targets and target_name not in targets:
continue

# compose node name
if compose_node_name:
target_name = str(os.path.splitext(target_path)[0]).replace(targets_searchpath + os.sep, "")
target_name = target_name.replace("/", ".")

selected_targets.append({"name": target_name, "path": target_path})

# using nodes for reclass legacy code
inv = {"nodes": {}}

# prepare logging
logger.info(f"Found {len(selected_targets)} targets")

# load targets
for target in selected_targets:
try:
# start = time.time()
name, config = load_target(target, classes_searchpath, ignore_class_notfound)
inv["nodes"][name] = config
# print(time.time() - start)
except Exception as e:
raise InventoryError(f"{target['name']}: {e}")

return inv


def load_target(target: dict, classes_searchpath: str, ignore_class_notfound: bool = False):
"""
load only one target with all its classes
"""

target_name = target["name"]
target_path = target["path"]

target_config = OmegaConf.load(target_path)
target_config_classes = target_config.get("classes", [])
target_config_parameters = OmegaConf.create(target_config.get("parameters", {}))
target_config = {}

classes_redundancy_check = set()

# load classes for targets
for class_name in target_config_classes:
# resolve class path
class_path = os.path.join(classes_searchpath, *class_name.split("."))

if class_path in classes_redundancy_check:
continue

classes_redundancy_check.add(class_path)

if os.path.isfile(class_path + ".yml"):
class_path += ".yml"
elif os.path.isdir(class_path):
# search for init file
init_path = os.path.join(classes_searchpath, *class_name.split("."), "init") + ".yml"
if os.path.isfile(init_path):
class_path = init_path
elif ignore_class_notfound:
logger.debug(f"Could not find {class_path}")
continue
else:
raise InventoryError(f"Class {class_name} not found.")

# load classes recursively
class_config = OmegaConf.load(class_path)

# resolve relative class names
new_classes = class_config.pop("classes", [])
for new in new_classes:
if new.startswith("."):
new = ".".join(class_name.split(".")[0:-1]) + new

target_config_classes.append(new)

class_config_parameters = OmegaConf.create(class_config.get("parameters", {}))

# merge target with loaded classes
if target_config_parameters:
target_config_parameters = OmegaConf.unsafe_merge(
class_config_parameters, target_config_parameters, list_merge_mode=ListMergeMode.EXTEND
)
else:
target_config_parameters = class_config_parameters

if not target_config_parameters:
raise InventoryError("empty target")

# append meta data (legacy: _reclass_)
target_config_parameters["_reclass_"] = {
"name": {
"full": target_name,
"parts": target_name.split("."),
"path": target_name.replace(".", "/"),
"short": target_name.split(".")[-1],
}
}

# resolve references / interpolate values
OmegaConf.resolve(target_config_parameters)
target_config["parameters"] = OmegaConf.to_object(target_config_parameters)

# obtain target name to insert in inv dict
try:
target_name = target_config["parameters"]["kapitan"]["vars"]["target"]
except KeyError:
logger.warning(f"Could not resolve target name on target {target_name}")

return target_name, target_config


def migrate(inventory_path: str) -> None:
"""migrates all .yml/.yaml files in the given path to omegaconfs syntax"""

for root, subdirs, files in os.walk(inventory_path):
for file in files:
file = os.path.join(root, file)
name, ext = os.path.splitext(file)

if ext not in (".yml", ".yaml"):
continue

try:
with open(file, "r+") as file:
content = file.read()
file.seek(0)

# replace colons in tags and replace _reclass_ with _meta_
updated_content = regex.sub(
r"(?<!\\)\${([^{}\\]+?)}",
lambda match: "${"
+ match.group(1).replace(":", ".").replace("_reclass_", "_meta_")
+ "}",
content,
)

# replace escaped tags with specific resolver
excluded_chars = "!"
invalid = any(c in updated_content for c in excluded_chars)
updated_content = regex.sub(
r"\\\${([^{}]+?)}",
lambda match: ("${tag:" if not invalid else "\\\\\\${") + match.group(1) + "}",
updated_content,
)

file.write(updated_content)
except Exception as e:
InventoryError(f"{file}: error with migration: {e}")
24 changes: 12 additions & 12 deletions kapitan/refs/cmd_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from kapitan.refs.secrets.gpg import GPGSecret, lookup_fingerprints
from kapitan.refs.secrets.vaultkv import VaultSecret
from kapitan.refs.secrets.vaulttransit import VaultTransit
from kapitan.resources import inventory_reclass
from kapitan.resources import get_inventory
from kapitan.utils import fatal_error, search_target_token_paths

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -65,7 +65,7 @@ def ref_write(args, ref_controller):
type_name, token_path = token_name.split(":")
recipients = [dict((("name", name),)) for name in args.recipients]
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
Expand Down Expand Up @@ -95,7 +95,7 @@ def ref_write(args, ref_controller):
type_name, token_path = token_name.split(":")
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
Expand Down Expand Up @@ -123,7 +123,7 @@ def ref_write(args, ref_controller):
type_name, token_path = token_name.split(":")
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
Expand Down Expand Up @@ -152,7 +152,7 @@ def ref_write(args, ref_controller):
type_name, token_path = token_name.split(":")
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
Expand Down Expand Up @@ -196,7 +196,7 @@ def ref_write(args, ref_controller):
vault_params = {}
encoding = "original"
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError(
Expand Down Expand Up @@ -230,7 +230,7 @@ def ref_write(args, ref_controller):
_data = data.encode()
vault_params = {}
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))
Expand Down Expand Up @@ -302,7 +302,7 @@ def secret_update(args, ref_controller):
for name in args.recipients
]
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))
Expand Down Expand Up @@ -330,7 +330,7 @@ def secret_update(args, ref_controller):
elif token_name.startswith("gkms:"):
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))
Expand All @@ -356,7 +356,7 @@ def secret_update(args, ref_controller):
elif token_name.startswith("azkms:"):
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))
Expand All @@ -382,7 +382,7 @@ def secret_update(args, ref_controller):
elif token_name.startswith("awskms:"):
key = args.key
if args.target_name:
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
kap_inv_params = inv["nodes"][args.target_name]["parameters"]["kapitan"]
if "secrets" not in kap_inv_params:
raise KapitanError("parameters.kapitan.secrets not defined in {}".format(args.target_name))
Expand Down Expand Up @@ -439,7 +439,7 @@ def secret_update_validate(args, ref_controller):
"Validate and/or update target secrets"
# update gpg recipients/gkms/awskms key for all secrets in secrets_path
# use --refs-path to set scanning path
inv = inventory_reclass(args.inventory_path)
inv = get_inventory(args.inventory_path)
targets = set(inv["nodes"].keys())
secrets_path = os.path.abspath(args.refs_path)
target_token_paths = search_target_token_paths(secrets_path, targets)
Expand Down
Loading
Loading