Skip to content

Commit

Permalink
Pluggable inventory (#7)
Browse files Browse the repository at this point in the history
* feat(inv): introduce OmegaConf as optional inventory backend

* remove print statement

* fix(lint): fix linting

* add first version of migration script

* add flag behavior for `--compose-node-name` and `-t`

* add resolving step for references in migration script

* feat: finish migration script

* feat: enable migration support as query

* add local module omegaconf to have new features

* add migrated inventory to test the feature

* refactor: add error handling and correct merging flags

* deps: add temporarily deps ruamel and regex for migrating

* feat: prepare omegaconf for multiprocessing

* feat: add generated grammar

* change route of module oc

* fix: change module import path

* fix: change import paths for omegaconf

* feat: resolve relative class name

* refactor: adapt new merge interface

* refactor: remove examples inventory for omegaconf

* feat: change migration via flag, not query

* refactor: remove unneccessary debug and comments

* feat: add option to pull omegaconf locally

* lint: fix type annotations

* refactor: remove directory oc after pulling

* feat: support init class

* perf: use faster function `unsafe_merge`

* feat: add more custom resolvers

* refactor: add more resolvers

* fix: namespace error with flag migrate

* feat: add ability to define user resolvers in inventory

* fix: user written resolvers replace system resolvers

* feat: restructure resolving and migrating

* chore: remove ruamel-yaml and add omegaconf in poetry-file

* fix: resolver escape_tag was missing braces

* fix: correct wrong behavior of resolver  `tag`

* feat: prepare support for lint
  • Loading branch information
MatteoVoges authored Jul 19, 2023
1 parent af91028 commit 0eaa122
Show file tree
Hide file tree
Showing 10 changed files with 609 additions and 36 deletions.
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
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
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

0 comments on commit 0eaa122

Please sign in to comment.