Skip to content

Commit

Permalink
Parse postBuild references in the Kustomization (#625)
Browse files Browse the repository at this point in the history
  • Loading branch information
allenporter authored Apr 20, 2024
1 parent f17968d commit 4d83af7
Show file tree
Hide file tree
Showing 16 changed files with 1,010 additions and 546 deletions.
8 changes: 8 additions & 0 deletions flux_local/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ class HelmException(CommandException):

class KyvernoException(CommandException):
"""Raised when there is an error running kyverno policy command."""


class InvalidValuesReference(FluxException):
"""Exception raised for an unsupported ValuesReference."""


class InvalidSubstituteReference(FluxException):
"""Exception raised for an unsupported SubstituteReference."""
82 changes: 62 additions & 20 deletions flux_local/git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

import git

from . import kustomize, helm
from . import kustomize, values
from .exceptions import FluxException, KustomizePathException
from .manifest import (
CRD_KIND,
Expand Down Expand Up @@ -394,6 +394,14 @@ async def build(
self._cache[key] = cmd
return cmd

def remove(self, kustomization: Kustomization) -> None:
"""Remove the kustomization value from the cache."""
target_key = f"{kustomization.namespaced_name} @"
for key in list(self._cache.keys()):
if key.startswith(target_key):
_LOGGER.debug("Invalidated cache %s", key)
del self._cache[key]


async def visit_kustomization(
selector: PathSelector,
Expand Down Expand Up @@ -558,19 +566,20 @@ async def build_kustomization(
)

kinds = []
# Needed for expanding postbuild substitutions and value references
kinds.append(CONFIG_MAP_KIND)
if helm_repo_selector.enabled:
kinds.append(HELM_REPO_KIND)
if helm_release_selector.enabled:
kinds.append(HELM_RELEASE_KIND)
# Needed for expanding value references
kinds.append(CONFIG_MAP_KIND)
kinds.append(SECRET_KIND)
if cluster_policy_selector.enabled:
kinds.append(CLUSTER_POLICY_KIND)
if selector.doc_visitor:
kinds.extend(selector.doc_visitor.kinds)
if not kinds:
return None
return

regexp = f"kind=^({'|'.join(kinds)})$"
docs = await cmd.grep(regexp).objects(
Expand Down Expand Up @@ -624,6 +633,23 @@ async def build_kustomization(
]


def _ready_kustomizations(kustomizations: list[Kustomization], visited: set[str]) -> tuple[Kustomization, Kustomization]:
"""Split the kustomizations into those that are ready vs pending."""
ready = []
pending = []
for kustomization in kustomizations:
if not_ready := (set(kustomization.depends_on or {}) - visited):
_LOGGER.debug(
"Kustomization %s waiting for %s",
kustomization.namespaced_name,
not_ready,
)
pending.append(kustomization)
else:
ready.append(kustomization)
return (ready, pending)


async def build_manifest(
path: Path | None = None,
selector: ResourceSelector = ResourceSelector(),
Expand Down Expand Up @@ -658,23 +684,39 @@ async def build_manifest(
]

async def update_kustomization(cluster: Cluster) -> None:
build_tasks = []
for kustomization in cluster.kustomizations:
_LOGGER.debug(
"Processing kustomization '%s': %s",
kustomization.name,
kustomization.path,
)
build_tasks.append(
build_kustomization(
kustomization,
Path(cluster.path),
selector,
options.kustomize_flags,
builder,
queue = [*cluster.kustomizations]
visited: set[str] = set()
while queue:
build_tasks = []
(ready, pending) = _ready_kustomizations(queue, visited)
for kustomization in ready:
_LOGGER.debug("Processing kustomization '%s': %s", kustomization.name, kustomization.path)

if kustomization.postbuild_substitute_from:
values.expand_postbuild_substitute_reference(
kustomization,
values.ks_cluster_config(cluster.kustomizations),
)
# Clear the cache to remove any previous builds that are
# missing the postbuild substitutions.
builder.remove(kustomization)

build_tasks.append(
build_kustomization(
kustomization,
Path(cluster.path),
selector,
options.kustomize_flags,
builder,
)
)
)
await asyncio.gather(*build_tasks)
if not build_tasks:
raise FluxException(
"Internal error: Unexpected loop without build tasks"
)
await asyncio.gather(*build_tasks)
visited.update([ks.namespaced_name for ks in ready])
queue = pending

# Validate all Kustomizations have valid dependsOn attributes since later
# we'll be using them to order processing.
Expand All @@ -693,7 +735,7 @@ async def update_kustomization(cluster: Cluster) -> None:
for cluster in clusters:
for kustomization in cluster.kustomizations:
kustomization.helm_releases = [
helm.expand_value_references(helm_release, kustomization)
values.expand_value_references(helm_release, kustomization)
for helm_release in kustomization.helm_releases
]

Expand Down
134 changes: 1 addition & 133 deletions flux_local/helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,11 @@
```
"""

import base64
from collections.abc import Sequence
import datetime
from dataclasses import dataclass
import logging
from pathlib import Path
from typing import Any, TypeVar
from typing import Any

import aiofiles
import yaml
Expand All @@ -51,11 +49,6 @@
CRD_KIND,
SECRET_KIND,
REPO_TYPE_OCI,
Kustomization,
CONFIG_MAP_KIND,
ConfigMap,
Secret,
VALUE_PLACEHOLDER,
)
from .exceptions import HelmException

Expand Down Expand Up @@ -239,128 +232,3 @@ async def template(
if options.skip_resources:
cmd = cmd.skip_resources(options.skip_resources)
return cmd


_T = TypeVar("_T", bound=ConfigMap | Secret)


def _find_object(name: str, namespace: str, objects: Sequence[_T]) -> _T | None:
"""Find the object in the list of objects."""
for obj in objects:
if obj.name == name and obj.namespace == namespace:
return obj
return None


def _decode_config_or_secret_value(
name: str, string_data: dict[str, str] | None, binary_data: dict[str, str] | None
) -> dict[str, str] | None:
"""Return the config or secret data."""
if binary_data:
try:
return {
k: base64.b64decode(v).decode("utf-8") for k, v in binary_data.items()
}
except ValueError:
raise HelmException(f"Unable to decode binary data for configmap {name}")
return string_data


def _get_secret_data(
name: str, namespace: str, ks: Kustomization
) -> dict[str, str] | None:
"""Find the secret value in the kustomization."""
found: Secret | None = _find_object(name, namespace, ks.secrets)
if not found:
return None
return _decode_config_or_secret_value(
f"{namespace}/{name}", found.string_data, found.data
)


def _get_configmap_data(
name: str, namespace: str, ks: Kustomization
) -> dict[str, str] | None:
"""Find the configmap value in the kustomization."""
found: ConfigMap | None = _find_object(name, namespace, ks.config_maps)
if not found:
return None
return _decode_config_or_secret_value(
f"{namespace}/{name}", found.data, found.binary_data
)


def expand_value_references(
helm_release: HelmRelease, kustomization: Kustomization
) -> HelmRelease:
"""Expand value references in the HelmRelease."""
if not helm_release.values_from:
return helm_release

values = helm_release.values or {}
for ref in helm_release.values_from:
_LOGGER.debug("Expanding value reference %s", ref)
found_data: dict[str, str] | None = None
if ref.kind == SECRET_KIND:
found_data = _get_secret_data(
ref.name, helm_release.namespace, kustomization
)
elif ref.kind == CONFIG_MAP_KIND:
found_data = _get_configmap_data(
ref.name, helm_release.namespace, kustomization
)
else:
_LOGGER.warning(
"Unsupported valueFrom kind %s in HelmRelease %s",
ref.kind,
helm_release.namespaced_name,
)
continue

if found_data is None:
if not ref.optional:
_LOGGER.warning(
"Unable to find %s %s/%s referenced in HelmRelease %s",
ref.kind,
helm_release.namespace,
ref.name,
helm_release.namespaced_name,
)
if ref.target_path:
# When a target path is specified, the value is expected to be
# a simple value type. Create a synthetic placeholder value
found_value = VALUE_PLACEHOLDER
else:
continue
elif (found_value := found_data.get(ref.values_key)) is None:
_LOGGER.warning(
"Unable to find key %s in %s/%s referenced in HelmRelease %s",
ref.values_key,
helm_release.namespace,
ref.name,
helm_release.namespaced_name,
)
continue

if ref.target_path:
parts = ref.target_path.split(".")
inner_values = values
for part in parts[:-1]:
if part not in inner_values:
inner_values[part] = {}
elif not isinstance(inner_values[part], dict):
raise HelmException(
f"While building HelmRelease '{helm_release.namespaced_name}': Expected '{ref.name}' field '{ref.target_path}' values to be a dict, found {type(inner_values[part])}"
)
inner_values = inner_values[part]

inner_values[parts[-1]] = found_value
else:
obj = yaml.load(found_value, Loader=yaml.SafeLoader)
if not obj or not isinstance(obj, dict):
raise HelmException(
f"While building HelmRelease '{helm_release.namespaced_name}': Expected '{ref.name}' field '{ref.target_path}' values to be valid yaml, found {type(values)}"
)
values.update(obj)

return helm_release.model_copy(update={"values": values})
45 changes: 43 additions & 2 deletions flux_local/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"HelmRelease",
"HelmChart",
"ClusterPolicy",
"ConfigMap",
"Secret",
]

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -393,6 +395,19 @@ def compact_exclude_fields(cls) -> dict[str, Any]:
}


class SubstituteReference(BaseManifest):
"""SubstituteReference contains a reference to a resource containing the variables name and value."""

kind: str
"""The kind of resource."""

name: str
"""The name of the resource."""

optional: bool = False
"""Whether the reference is optional."""


class Kustomization(BaseManifest):
"""A Kustomization is a set of declared cluster artifacts.
Expand Down Expand Up @@ -446,6 +461,12 @@ class Kustomization(BaseManifest):
images: list[str] = Field(default_factory=list)
"""The list of images referenced in the kustomization."""

postbuild_substitute: Optional[dict[str, Any]] = None
"""A map of key/value pairs to substitute into the final YAML manifest, after building."""

postbuild_substitute_from: Optional[list[SubstituteReference]] = None
"""A list of substitutions to reference from an ConfigMap or Secret."""

depends_on: list[str] | None = None
"""A list of namespaced names that this Kustomization depends on."""

Expand All @@ -464,14 +485,18 @@ def parse_doc(cls, doc: dict[str, Any]) -> "Kustomization":
path = spec.get("path", "")
source_path = metadata.get("annotations", {}).get("config.kubernetes.io/path")
source_ref = spec.get("sourceRef", {})

postbuild = spec.get("postBuild", {})
substitute_from: list[SubstituteReference] | None = None
if substitute_from_dict := postbuild.get("substituteFrom"):
substitute_from = [
SubstituteReference(**subdoc) for subdoc in substitute_from_dict
]
depends_on = []
for dependency in spec.get("dependsOn", ()):
if not (dep_name := dependency.get("name")):
raise InputException(f"Invalid {cls} missing dependsOn.name: {doc}")
dep_namespace = dependency.get("namespace", namespace)
depends_on.append(f"{dep_namespace}/{dep_name}")

return Kustomization(
name=name,
namespace=namespace,
Expand All @@ -482,6 +507,8 @@ def parse_doc(cls, doc: dict[str, Any]) -> "Kustomization":
source_namespace=source_ref.get("namespace", namespace),
target_namespace=spec.get("targetNamespace"),
contents=doc,
postbuild_substitute=postbuild.get("substitute"),
postbuild_substitute_from=substitute_from,
depends_on=depends_on,
)

Expand Down Expand Up @@ -529,8 +556,22 @@ def compact_exclude_fields(cls) -> dict[str, Any]:
"source_kind": True,
"target_namespace": True,
"contents": True,
"postbuild_substitute": True,
"postbuild_substitute_from": True,
"depends_on": True,
}

def update_postbuild_substitutions(self, substitutions: dict[str, Any]) -> None:
"""Update the postBuild.substitutions in the extracted values and raw doc contents."""
if self.postbuild_substitute is None:
self.postbuild_substitute = {}
self.postbuild_substitute.update(substitutions)
if self.contents:
post_build = self.contents["spec"]["postBuild"]
if (substitute := post_build.get("substitute")) is None:
substitute = {}
post_build["substitute"] = substitute
substitute.update(substitutions)


class Cluster(BaseManifest):
Expand Down
Loading

0 comments on commit 4d83af7

Please sign in to comment.