From 62719b843fd4877f5440efcebad21317d40cfc4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:03:30 +0000 Subject: [PATCH 01/11] Initial plan From 776a5cae8fbd01e69715b38e73e15d4792d2d0c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:11:17 +0000 Subject: [PATCH 02/11] Fix ApplySet contains-group-kinds to use plural resource names The applyset.kubernetes.io/contains-group-kinds annotation requires plural resource names (e.g., 'deployments.apps', 'cronworkflows.argoproj.io') instead of singular kind names. Changes: - Updated get_canonical_resource_kind_name to return plural names - Added DynamicClient support to query K8s API for correct plural names - Added _pluralize_kind heuristic fallback for when API is unavailable - Updated set_group_kinds to accept optional DynamicClient parameter - Updated template.py to pass DynamicClient to set_group_kinds Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 4 +- src/nyl/resources/applyset.py | 92 ++++++++++++++++++++++++++---- src/nyl/resources/applyset_test.py | 39 +++++++++++-- 3 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index e48ff980..32c96ace 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -30,6 +30,7 @@ from nyl.tools.kubernetes import drop_empty_metadata_labels, populate_namespace_to_resources from nyl.tools.logging import lazy_str from nyl.tools.types import Resource, ResourceList +from kubernetes.dynamic.client import DynamicClient DEFAULT_NAMESPACE_ANNOTATION = "nyl.io/is-default-namespace" @@ -321,7 +322,8 @@ def worker() -> ResourceList: ) if applyset is not None: - applyset.set_group_kinds(source.resources) + dynamic_client = DynamicClient(client) + applyset.set_group_kinds(source.resources, dynamic_client) # HACK: Kubectl 1.30 can't create the custom resource, so we need to create it. But it will also reject # using the custom resource unless it has the tooling label set appropriately. For more details, see # https://github.com/helsing-ai/nyl/issues/5. diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 5718c251..d324e1f8 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -1,13 +1,17 @@ import base64 import hashlib from dataclasses import dataclass -from typing import Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar from databind.core import SerializeDefaults +from loguru import logger from nyl.resources import API_VERSION_K8S, NylResource, ObjectMetadata from nyl.tools.types import ResourceList +if TYPE_CHECKING: + from kubernetes.dynamic.client import DynamicClient + APPLYSET_LABEL_PART_OF = "applyset.kubernetes.io/part-of" """ Label key to use to associate objects with an ApplySet resource. """ @@ -152,15 +156,20 @@ def contains_group_kinds(self, value: list[str]) -> None: self.metadata.annotations = {} self.metadata.annotations[APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS] = ",".join(sorted(value)) - def set_group_kinds(self, manifests: ResourceList) -> None: + def set_group_kinds(self, manifests: ResourceList, client: "DynamicClient | None" = None) -> None: """ Set the kinds of resources that are part of the ApplySet based on the specified manifests. + + Args: + manifests: The list of manifests to extract the resource kinds from. + client: An optional Kubernetes DynamicClient to use for discovering the plural resource names. + If not provided, the function will fall back to heuristic-based pluralization. """ kinds = set() for manifest in manifests: if "kind" in manifest: - kinds.add(get_canonical_resource_kind_name(manifest["apiVersion"], manifest["kind"])) + kinds.add(get_canonical_resource_kind_name(manifest["apiVersion"], manifest["kind"], client)) self.contains_group_kinds = list(kinds) def validate(self) -> None: @@ -220,20 +229,81 @@ def calculate_applyset_id(*, name: str, namespace: str = "", group: str) -> str: return f"applyset-{uid}-v1" -def get_canonical_resource_kind_name(api_version: str, kind: str) -> str: +def get_canonical_resource_kind_name( + api_version: str, kind: str, client: "DynamicClient | None" = None +) -> str: """ Given the apiVersion and kind of a Kubernetes resource, return the canonical name of the resource. This name can be used to identify the resource in an ApplySet's `applyset.kubernetes.io/contains-group-kinds` annotation. - Note that according to the [reference][1], the resource name should use the plural form, but it appears that the - resource kind name is also accepted. Deriving the plural form will be difficult without querying the Kubernetes - API. + The annotation requires the plural resource name (e.g., 'deployments.apps' not 'Deployment.apps'). + See: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-contains-group-kinds + + Args: + api_version: The apiVersion of the resource (e.g., 'v1', 'apps/v1', 'argoproj.io/v1alpha1'). + kind: The kind of the resource (e.g., 'Pod', 'Deployment', 'CronWorkflow'). + client: An optional Kubernetes DynamicClient to use for discovering the plural resource name. + If not provided or if the resource is not found, falls back to heuristic-based pluralization. + + Returns: + The canonical resource name in the format '.' (e.g., 'deployments.apps', 'pods'). + """ + + group = api_version.split("/")[0] if "/" in api_version else "" + + # Try to get the plural name from the Kubernetes API + plural_name = None + if client is not None: + try: + resource = client.resources.get(api_version=api_version, kind=kind) + plural_name = resource.name + except Exception as e: + logger.debug( + "Could not find plural name for {}/{} from Kubernetes API: {}. Using heuristic.", + api_version, + kind, + e, + ) + + # Fall back to heuristic-based pluralization if we couldn't get it from the API + if plural_name is None: + plural_name = _pluralize_kind(kind) - [1]: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-contains-group-kinds + return (f"{plural_name}.{group}").rstrip(".") + + +def _pluralize_kind(kind: str) -> str: + """ + Convert a Kubernetes kind name to its plural form using common English pluralization rules. + + This is a heuristic fallback when the Kubernetes API is not available. + The result is lowercased to match the format expected by kubectl. Args: - api_version: The apiVersion of the resource. - kind: The kind of the resource. + kind: The singular kind name (e.g., 'Pod', 'Deployment', 'CronWorkflow'). + + Returns: + The plural form of the kind, lowercased (e.g., 'pods', 'deployments', 'cronworkflows'). """ - return (f"{kind}." + (api_version.split("/")[0] if "/" in api_version else "")).rstrip(".") + kind_lower = kind.lower() + + # Handle special cases + if kind_lower.endswith("s"): + # Words ending in 's' typically add 'es' (e.g., 'address' -> 'addresses') + # But some already look plural (e.g., 'ingress' -> 'ingresses') + return f"{kind_lower}es" + elif kind_lower.endswith("y"): + # Words ending in consonant + 'y' change 'y' to 'ies' + # Check if the character before 'y' is a consonant + if len(kind_lower) > 1 and kind_lower[-2] not in "aeiou": + return f"{kind_lower[:-1]}ies" + else: + # Words ending in vowel + 'y' just add 's' + return f"{kind_lower}s" + elif kind_lower.endswith("x") or kind_lower.endswith("ch") or kind_lower.endswith("sh"): + # Words ending in 'x', 'ch', 'sh' add 'es' + return f"{kind_lower}es" + else: + # Default: just add 's' + return f"{kind_lower}s" diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index 7c07c1c1..b3e24e40 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -1,5 +1,5 @@ from nyl.resources import ObjectMetadata -from nyl.resources.applyset import ApplySet, calculate_applyset_id, get_canonical_resource_kind_name +from nyl.resources.applyset import ApplySet, _pluralize_kind, calculate_applyset_id, get_canonical_resource_kind_name def test__ApplySet__dump() -> None: @@ -10,7 +10,7 @@ def test__ApplySet__dump() -> None: ) ) resource.tooling = "kubectl/1.30" - resource.contains_group_kinds = ["Service", "Deployment.apps"] + resource.contains_group_kinds = ["services", "deployments.apps"] resource.validate() assert resource.dump() == { @@ -20,7 +20,7 @@ def test__ApplySet__dump() -> None: "name": "test-applyset", "annotations": { "applyset.kubernetes.io/tooling": "kubectl/1.30", - "applyset.kubernetes.io/contains-group-kinds": "Deployment.apps,Service", # sorted + "applyset.kubernetes.io/contains-group-kinds": "deployments.apps,services", # sorted }, "labels": { "applyset.kubernetes.io/id": calculate_applyset_id( @@ -34,6 +34,33 @@ def test__ApplySet__dump() -> None: def test__get_canonical_resource_kind_name() -> None: - assert get_canonical_resource_kind_name("v1", "Pod") == "Pod" - assert get_canonical_resource_kind_name("apps/v1", "Deployment") == "Deployment.apps" - assert get_canonical_resource_kind_name("nyl.io/v1", "ApplySet") == "ApplySet.nyl.io" + # Without a client, the function falls back to heuristic pluralization (lowercase) + assert get_canonical_resource_kind_name("v1", "Pod") == "pods" + assert get_canonical_resource_kind_name("apps/v1", "Deployment") == "deployments.apps" + assert get_canonical_resource_kind_name("nyl.io/v1", "ApplySet") == "applysets.nyl.io" + assert get_canonical_resource_kind_name("argoproj.io/v1alpha1", "CronWorkflow") == "cronworkflows.argoproj.io" + + +def test__pluralize_kind() -> None: + # Basic pluralization (add 's') + assert _pluralize_kind("Pod") == "pods" + assert _pluralize_kind("Deployment") == "deployments" + assert _pluralize_kind("Service") == "services" + assert _pluralize_kind("ConfigMap") == "configmaps" + assert _pluralize_kind("Secret") == "secrets" + assert _pluralize_kind("CronWorkflow") == "cronworkflows" + assert _pluralize_kind("Namespace") == "namespaces" + + # Words ending in 's' add 'es' + assert _pluralize_kind("Ingress") == "ingresses" + assert _pluralize_kind("Address") == "addresses" + + # Words ending in consonant + 'y' change to 'ies' + assert _pluralize_kind("NetworkPolicy") == "networkpolicies" + assert _pluralize_kind("PodSecurityPolicy") == "podsecuritypolicies" + + # Words ending in vowel + 'y' just add 's' + assert _pluralize_kind("Gateway") == "gateways" + + # Words ending in 'x', 'ch', 'sh' add 'es' + assert _pluralize_kind("Match") == "matches" From 0e599133ad4c55bd19f94ec9991c1b0577d94971 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:13:53 +0000 Subject: [PATCH 03/11] Use more specific exception types for API errors Address code review feedback by catching ResourceNotFoundError and ApiException instead of the broad Exception class. Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/resources/applyset.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index d324e1f8..1ec4d520 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from databind.core import SerializeDefaults +from kubernetes.client.exceptions import ApiException +from kubernetes.dynamic.exceptions import ResourceNotFoundError from loguru import logger from nyl.resources import API_VERSION_K8S, NylResource, ObjectMetadata @@ -257,7 +259,7 @@ def get_canonical_resource_kind_name( try: resource = client.resources.get(api_version=api_version, kind=kind) plural_name = resource.name - except Exception as e: + except (ResourceNotFoundError, ApiException) as e: logger.debug( "Could not find plural name for {}/{} from Kubernetes API: {}. Using heuristic.", api_version, From 802af32e430e651b5860d1a73ea3f936d0b4104e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:53:56 +0000 Subject: [PATCH 04/11] Fix ApplySet annotation format to use . instead of plural names The applyset.kubernetes.io/contains-group-kinds annotation requires the format . with the singular, capitalized Kind name (e.g., 'Pod', 'Deployment.apps'), not the plural lowercase resource names. This matches the format expected by kubectl's ParseGroupKind function which parses strings like 'Pod' or 'Deployment.apps' into GroupKind structs with Kind and Group fields. Fixes the error: error: parsing ApplySet annotation: could not find mapping for kind in "applyset.kubernetes.io/contains-group-kinds" annotation: no matches for kind "pods" in group "" Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 4 +- src/nyl/resources/applyset.py | 86 +++--------------------------- src/nyl/resources/applyset_test.py | 41 +++----------- 3 files changed, 17 insertions(+), 114 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index 32c96ace..e48ff980 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -30,7 +30,6 @@ from nyl.tools.kubernetes import drop_empty_metadata_labels, populate_namespace_to_resources from nyl.tools.logging import lazy_str from nyl.tools.types import Resource, ResourceList -from kubernetes.dynamic.client import DynamicClient DEFAULT_NAMESPACE_ANNOTATION = "nyl.io/is-default-namespace" @@ -322,8 +321,7 @@ def worker() -> ResourceList: ) if applyset is not None: - dynamic_client = DynamicClient(client) - applyset.set_group_kinds(source.resources, dynamic_client) + applyset.set_group_kinds(source.resources) # HACK: Kubectl 1.30 can't create the custom resource, so we need to create it. But it will also reject # using the custom resource unless it has the tooling label set appropriately. For more details, see # https://github.com/helsing-ai/nyl/issues/5. diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 1ec4d520..34460e1a 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -1,19 +1,13 @@ import base64 import hashlib from dataclasses import dataclass -from typing import TYPE_CHECKING, Annotated, ClassVar +from typing import Annotated, ClassVar from databind.core import SerializeDefaults -from kubernetes.client.exceptions import ApiException -from kubernetes.dynamic.exceptions import ResourceNotFoundError -from loguru import logger from nyl.resources import API_VERSION_K8S, NylResource, ObjectMetadata from nyl.tools.types import ResourceList -if TYPE_CHECKING: - from kubernetes.dynamic.client import DynamicClient - APPLYSET_LABEL_PART_OF = "applyset.kubernetes.io/part-of" """ Label key to use to associate objects with an ApplySet resource. """ @@ -158,20 +152,15 @@ def contains_group_kinds(self, value: list[str]) -> None: self.metadata.annotations = {} self.metadata.annotations[APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS] = ",".join(sorted(value)) - def set_group_kinds(self, manifests: ResourceList, client: "DynamicClient | None" = None) -> None: + def set_group_kinds(self, manifests: ResourceList) -> None: """ Set the kinds of resources that are part of the ApplySet based on the specified manifests. - - Args: - manifests: The list of manifests to extract the resource kinds from. - client: An optional Kubernetes DynamicClient to use for discovering the plural resource names. - If not provided, the function will fall back to heuristic-based pluralization. """ kinds = set() for manifest in manifests: if "kind" in manifest: - kinds.add(get_canonical_resource_kind_name(manifest["apiVersion"], manifest["kind"], client)) + kinds.add(get_canonical_resource_kind_name(manifest["apiVersion"], manifest["kind"])) self.contains_group_kinds = list(kinds) def validate(self) -> None: @@ -231,81 +220,22 @@ def calculate_applyset_id(*, name: str, namespace: str = "", group: str) -> str: return f"applyset-{uid}-v1" -def get_canonical_resource_kind_name( - api_version: str, kind: str, client: "DynamicClient | None" = None -) -> str: +def get_canonical_resource_kind_name(api_version: str, kind: str) -> str: """ Given the apiVersion and kind of a Kubernetes resource, return the canonical name of the resource. This name can be used to identify the resource in an ApplySet's `applyset.kubernetes.io/contains-group-kinds` annotation. - The annotation requires the plural resource name (e.g., 'deployments.apps' not 'Deployment.apps'). + The annotation format is `.` where Kind is the singular, capitalized kind name. + For core v1 resources (no group), the format is just ``. See: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-contains-group-kinds Args: api_version: The apiVersion of the resource (e.g., 'v1', 'apps/v1', 'argoproj.io/v1alpha1'). kind: The kind of the resource (e.g., 'Pod', 'Deployment', 'CronWorkflow'). - client: An optional Kubernetes DynamicClient to use for discovering the plural resource name. - If not provided or if the resource is not found, falls back to heuristic-based pluralization. Returns: - The canonical resource name in the format '.' (e.g., 'deployments.apps', 'pods'). + The canonical resource name in the format '.' (e.g., 'Deployment.apps', 'Pod'). """ group = api_version.split("/")[0] if "/" in api_version else "" - - # Try to get the plural name from the Kubernetes API - plural_name = None - if client is not None: - try: - resource = client.resources.get(api_version=api_version, kind=kind) - plural_name = resource.name - except (ResourceNotFoundError, ApiException) as e: - logger.debug( - "Could not find plural name for {}/{} from Kubernetes API: {}. Using heuristic.", - api_version, - kind, - e, - ) - - # Fall back to heuristic-based pluralization if we couldn't get it from the API - if plural_name is None: - plural_name = _pluralize_kind(kind) - - return (f"{plural_name}.{group}").rstrip(".") - - -def _pluralize_kind(kind: str) -> str: - """ - Convert a Kubernetes kind name to its plural form using common English pluralization rules. - - This is a heuristic fallback when the Kubernetes API is not available. - The result is lowercased to match the format expected by kubectl. - - Args: - kind: The singular kind name (e.g., 'Pod', 'Deployment', 'CronWorkflow'). - - Returns: - The plural form of the kind, lowercased (e.g., 'pods', 'deployments', 'cronworkflows'). - """ - - kind_lower = kind.lower() - - # Handle special cases - if kind_lower.endswith("s"): - # Words ending in 's' typically add 'es' (e.g., 'address' -> 'addresses') - # But some already look plural (e.g., 'ingress' -> 'ingresses') - return f"{kind_lower}es" - elif kind_lower.endswith("y"): - # Words ending in consonant + 'y' change 'y' to 'ies' - # Check if the character before 'y' is a consonant - if len(kind_lower) > 1 and kind_lower[-2] not in "aeiou": - return f"{kind_lower[:-1]}ies" - else: - # Words ending in vowel + 'y' just add 's' - return f"{kind_lower}s" - elif kind_lower.endswith("x") or kind_lower.endswith("ch") or kind_lower.endswith("sh"): - # Words ending in 'x', 'ch', 'sh' add 'es' - return f"{kind_lower}es" - else: - # Default: just add 's' - return f"{kind_lower}s" + return (f"{kind}.{group}").rstrip(".") diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index b3e24e40..b6048139 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -1,5 +1,5 @@ from nyl.resources import ObjectMetadata -from nyl.resources.applyset import ApplySet, _pluralize_kind, calculate_applyset_id, get_canonical_resource_kind_name +from nyl.resources.applyset import ApplySet, calculate_applyset_id, get_canonical_resource_kind_name def test__ApplySet__dump() -> None: @@ -10,7 +10,7 @@ def test__ApplySet__dump() -> None: ) ) resource.tooling = "kubectl/1.30" - resource.contains_group_kinds = ["services", "deployments.apps"] + resource.contains_group_kinds = ["Service", "Deployment.apps"] resource.validate() assert resource.dump() == { @@ -20,7 +20,7 @@ def test__ApplySet__dump() -> None: "name": "test-applyset", "annotations": { "applyset.kubernetes.io/tooling": "kubectl/1.30", - "applyset.kubernetes.io/contains-group-kinds": "deployments.apps,services", # sorted + "applyset.kubernetes.io/contains-group-kinds": "Deployment.apps,Service", # sorted }, "labels": { "applyset.kubernetes.io/id": calculate_applyset_id( @@ -34,33 +34,8 @@ def test__ApplySet__dump() -> None: def test__get_canonical_resource_kind_name() -> None: - # Without a client, the function falls back to heuristic pluralization (lowercase) - assert get_canonical_resource_kind_name("v1", "Pod") == "pods" - assert get_canonical_resource_kind_name("apps/v1", "Deployment") == "deployments.apps" - assert get_canonical_resource_kind_name("nyl.io/v1", "ApplySet") == "applysets.nyl.io" - assert get_canonical_resource_kind_name("argoproj.io/v1alpha1", "CronWorkflow") == "cronworkflows.argoproj.io" - - -def test__pluralize_kind() -> None: - # Basic pluralization (add 's') - assert _pluralize_kind("Pod") == "pods" - assert _pluralize_kind("Deployment") == "deployments" - assert _pluralize_kind("Service") == "services" - assert _pluralize_kind("ConfigMap") == "configmaps" - assert _pluralize_kind("Secret") == "secrets" - assert _pluralize_kind("CronWorkflow") == "cronworkflows" - assert _pluralize_kind("Namespace") == "namespaces" - - # Words ending in 's' add 'es' - assert _pluralize_kind("Ingress") == "ingresses" - assert _pluralize_kind("Address") == "addresses" - - # Words ending in consonant + 'y' change to 'ies' - assert _pluralize_kind("NetworkPolicy") == "networkpolicies" - assert _pluralize_kind("PodSecurityPolicy") == "podsecuritypolicies" - - # Words ending in vowel + 'y' just add 's' - assert _pluralize_kind("Gateway") == "gateways" - - # Words ending in 'x', 'ch', 'sh' add 'es' - assert _pluralize_kind("Match") == "matches" + # The format is . - singular, capitalized Kind name + assert get_canonical_resource_kind_name("v1", "Pod") == "Pod" + assert get_canonical_resource_kind_name("apps/v1", "Deployment") == "Deployment.apps" + assert get_canonical_resource_kind_name("nyl.io/v1", "ApplySet") == "ApplySet.nyl.io" + assert get_canonical_resource_kind_name("argoproj.io/v1alpha1", "CronWorkflow") == "CronWorkflow.argoproj.io" From 6999d7d503fbb767d4d9fe2b3996e5d107929b52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:07:23 +0000 Subject: [PATCH 05/11] Refactor ApplySet to use ConfigMap instead of custom nyl.io/v1 resource - Replace custom ApplySet.nyl.io/v1 resource with ConfigMap as the parent - ConfigMap is namespace-scoped, placed in the default namespace - Remove --applyset flag usage with kubectl (manually set annotations) - Simplify ApplySet class as a plain dataclass without NylResource inheritance - Update kubectl.apply() to remove applyset and prune parameters - Update tests for new ConfigMap-based implementation Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 41 +++--- src/nyl/resources/applyset.py | 216 ++++++++--------------------- src/nyl/resources/applyset_test.py | 33 ++--- src/nyl/tools/kubectl.py | 20 +-- 4 files changed, 101 insertions(+), 209 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index e48ff980..22248e32 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -288,51 +288,44 @@ def worker() -> ResourceList: source.resources, post_processors = PostProcessor.extract_from_list(source.resources) # Find the namespaces that are defined in the file. If we find any resources without a namespace, we will - # inject that namespace name into them. Also find the applyset defined in the file. + # inject that namespace name into them. namespaces: set[str] = set() - applyset: ApplySet | None = None for resource in list(source.resources): if is_namespace_resource(resource): namespaces.add(resource["metadata"]["name"]) - elif ApplySet.matches(resource): - if applyset is not None: - logger.opt(colors=True).error( - "Multiple ApplySet resources defined in {}, there can only be one per source.", - source.file, - ) - exit(1) - applyset = ApplySet.load(resource) - source.resources.remove(resource) - - if not applyset and project.config.settings.generate_applysets: + + # Create an ApplySet if configured to do so + applyset: ApplySet | None = None + if project.config.settings.generate_applysets: if not current_default_namespace: logger.opt(colors=True).error( "No default namespace defined for {}, but it is required for the automatically " - "generated nyl.io/v1/ApplySet resource (the ApplySet is named after the default namespace).", + "generated ApplySet ConfigMap (the ApplySet is named after the default namespace).", source.file, ) exit(1) applyset_name = current_default_namespace - applyset = ApplySet.new(applyset_name) + applyset = ApplySet.new(applyset_name, current_default_namespace) logger.opt(colors=True).info( - "Automatically creating ApplySet for {} (name: {}).", source.file, applyset_name + "Automatically creating ApplySet for {} (name: {}, namespace: {}).", + source.file, + applyset_name, + current_default_namespace, ) if applyset is not None: applyset.set_group_kinds(source.resources) - # HACK: Kubectl 1.30 can't create the custom resource, so we need to create it. But it will also reject - # using the custom resource unless it has the tooling label set appropriately. For more details, see - # https://github.com/helsing-ai/nyl/issues/5. applyset.tooling = f"kubectl/v{generator.kube_version}" applyset.validate() if apply: - # We need to ensure that ApplySet parent object exists before invoking `kubectl apply --applyset=...`. + # Apply the ConfigMap that serves as the ApplySet parent logger.opt(colors=True).info( - "Kubectl-apply ApplySet resource {} from {}.", - applyset.reference, + "Kubectl-apply ApplySet ConfigMap {}/{} from {}.", + applyset.namespace, + applyset.name, source.file, ) kubectl.apply(ResourceList([applyset.dump()]), force_conflicts=True) @@ -372,10 +365,10 @@ def worker() -> ResourceList: if apply: logger.info("Kubectl-apply {} resource(s) from '{}'", len(source.resources), source.file) + # Note: We don't use kubectl's --applyset flag because it has limitations with multi-namespace deployments. + # Instead, we manually set the applyset.kubernetes.io/part-of label on all resources. kubectl.apply( manifests=source.resources, - applyset=applyset.reference if applyset else None, - prune=True if applyset else False, force_conflicts=True, ) elif diff: diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 34460e1a..870d2117 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -1,12 +1,8 @@ import base64 import hashlib -from dataclasses import dataclass -from typing import Annotated, ClassVar +from dataclasses import dataclass, field -from databind.core import SerializeDefaults - -from nyl.resources import API_VERSION_K8S, NylResource, ObjectMetadata -from nyl.tools.types import ResourceList +from nyl.tools.types import Resource, ResourceList APPLYSET_LABEL_PART_OF = "applyset.kubernetes.io/part-of" """ Label key to use to associate objects with an ApplySet resource. """ @@ -21,136 +17,33 @@ """ Annotation key to use on ApplySet resources to specify the kinds of resources that are part of the ApplySet. """ -@dataclass(kw_only=True) -class ApplySet(NylResource, api_version=API_VERSION_K8S): +@dataclass +class ApplySet: """ - An ApplySet functions as a grouping mechanism for a set of objects that are applied together. This is a standard - Kubernetes mechanism that needs to be implemented as a custom resource. To read more about ApplySets, check out the - following article: + An ApplySet functions as a grouping mechanism for a set of objects that are applied together. This uses a + ConfigMap as the parent resource, which is the recommended approach for ApplySets. + To read more about ApplySets, check out the following article: https://kubernetes.io/blog/2023/05/09/introducing-kubectl-applyset-pruning/ - Nyl's ApplySet resource is not namespaces. + The ApplySet is namespace-scoped (using a ConfigMap) and should be placed in the "default" namespace + determined by Nyl's namespace resolution logic. - When loading manifests from a file, Nyl looks for an ApplySet resource to determine if the manifests are to be + When loading manifests from a file, Nyl looks for an ApplySet definition to determine if the manifests are to be associated with an ApplySet. """ - # HACK: Can't set it on the class level, see https://github.com/NiklasRosenstein/python-databind/issues/73. - metadata: Annotated[ObjectMetadata, SerializeDefaults(False)] - - # note: the only purpose of this CRD is to create resources that act as a parent for ApplySets. - # check out this GitHub issue, and specifically this comment for more information: - # https://github.com/kubernetes/enhancements/issues/3659#issuecomment-1753091733 - CRD: ClassVar = { - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": { - "name": f"applysets.{API_VERSION_K8S.split('/')[0]}", - "labels": { - "applyset.kubernetes.io/is-parent-type": "true", - }, - }, - "spec": { - "group": API_VERSION_K8S.split("/")[0], - "names": { - "kind": "ApplySet", - "plural": "applysets", - }, - "scope": "Cluster", - "versions": [ - { - "name": "v1", - "served": True, - "storage": True, - "schema": { - "openAPIV3Schema": { - "type": "object", - } - }, - } - ], - }, - } - - @property - def reference(self) -> str: - """ - Return the refernce to this ApplySet resource that can be given to the `--applyset` flag of `kubectl apply`. - """ - - return f"applysets.{self.API_VERSION.split('/')[0]}/{self.metadata.name}" - - @property - def id(self) -> str | None: - """ - Returns the ID of the ApplySet as it is configured in the `applyset.kubernetes.io/id` label. - """ - - if self.metadata.labels is not None: - return self.metadata.labels.get(APPLYSET_LABEL_ID) - return None - - @id.setter - def id(self, value: str) -> None: - """ - Set the ID of the ApplySet. - """ - - if self.metadata.labels is None: - self.metadata.labels = {} - self.metadata.labels[APPLYSET_LABEL_ID] = value - - def calculate_id(self) -> str: - """ - Calculate the ID of the ApplySet based on the name and namespace of the ApplySet. - """ - - return calculate_applyset_id( - name=self.metadata.name, namespace=self.metadata.namespace or "", group=self.API_VERSION.split("/")[0] - ) - - @property - def tooling(self) -> str | None: - """ - Returns the tooling that was used to apply the ApplySet. - """ - - if self.metadata.annotations is not None: - return self.metadata.annotations.get(APPLYSET_ANNOTATION_TOOLING) - return None - - @tooling.setter - def tooling(self, value: str) -> None: - """ - Set the tooling that was used to apply the ApplySet. - """ - - if self.metadata.annotations is None: - self.metadata.annotations = {} - self.metadata.annotations[APPLYSET_ANNOTATION_TOOLING] = value + name: str + namespace: str + tooling: str = "" + contains_group_kinds: list[str] = field(default_factory=list) @property - def contains_group_kinds(self) -> list[str] | None: + def id(self) -> str: """ - Returns the kinds of resources that are part of the ApplySet. + Returns the ID of the ApplySet. """ - - if self.metadata.annotations is not None: - value = self.metadata.annotations.get(APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS) - if value is not None: - return value.split(",") - return None - - @contains_group_kinds.setter - def contains_group_kinds(self, value: list[str]) -> None: - """ - Set the kinds of resources that are part of the ApplySet. - """ - - if self.metadata.annotations is None: - self.metadata.annotations = {} - self.metadata.annotations[APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS] = ",".join(sorted(value)) + return calculate_applyset_id(name=self.name, namespace=self.namespace) def set_group_kinds(self, manifests: ResourceList) -> None: """ @@ -161,62 +54,73 @@ def set_group_kinds(self, manifests: ResourceList) -> None: for manifest in manifests: if "kind" in manifest: kinds.add(get_canonical_resource_kind_name(manifest["apiVersion"], manifest["kind"])) - self.contains_group_kinds = list(kinds) + self.contains_group_kinds = sorted(kinds) def validate(self) -> None: """ Validate the ApplySet configuration. - Mutations: - - Sets the `applyset.kubernetes.io/id` label on the metadata of the ApplySet resource if it is not set. - Raises: ValueError: - - If the resource is namespaced. - - If the annotations has no `applyset.kubernetes.io/tooling` key. - - If the annotations has no `applyset.kubernetes.io/contains-group-kinds` key. - - If the `applyset.kubernetes.io/id` label has an invalid value. + - If the name is empty. + - If the namespace is empty. + - If the tooling is not set. + - If the contains_group_kinds is empty. """ - if self.metadata.namespace: - raise ValueError("ApplySet resources cannot be namespaced") + if not self.name: + raise ValueError("ApplySet name cannot be empty") - if self.metadata.labels is None: - self.metadata.labels = {} + if not self.namespace: + raise ValueError("ApplySet namespace cannot be empty") - if self.id is None: - self.id = self.calculate_id() - elif self.id != self.calculate_id(): - raise ValueError(f"Invalid {APPLYSET_LABEL_ID!r} label value: {self.id!r}") + if not self.tooling: + raise ValueError(f"ApplySet must have a {APPLYSET_ANNOTATION_TOOLING!r} annotation") - if self.tooling is None: - raise ValueError(f"ApplySet resource must have a {APPLYSET_ANNOTATION_TOOLING!r} annotation") + if not self.contains_group_kinds: + raise ValueError(f"ApplySet must have a {APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS!r} annotation") - if self.contains_group_kinds is None: - raise ValueError(f"ApplySet resource must have a {APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS!r} annotation") + def dump(self) -> Resource: + """ + Dump the ApplySet as a ConfigMap resource with the appropriate annotations and labels. + """ + + return Resource({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": self.name, + "namespace": self.namespace, + "annotations": { + APPLYSET_ANNOTATION_TOOLING: self.tooling, + APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS: ",".join(self.contains_group_kinds), + }, + "labels": { + APPLYSET_LABEL_ID: self.id, + }, + }, + }) @staticmethod - def new(name: str) -> "ApplySet": + def new(name: str, namespace: str) -> "ApplySet": """ - Create a new ApplySet resource with the specified name. + Create a new ApplySet with the specified name and namespace. """ - return ApplySet( - metadata=ObjectMetadata( - name=name, - namespace=None, - ) - ) + return ApplySet(name=name, namespace=namespace) -def calculate_applyset_id(*, name: str, namespace: str = "", group: str) -> str: +def calculate_applyset_id(*, name: str, namespace: str) -> str: """ - Calculate the ID of a Kubernetes ApplySet with the specified name. + Calculate the ID of a Kubernetes ApplySet with the specified name and namespace. + The ID is based on a ConfigMap parent resource. """ # reference: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-id - hash = hashlib.sha256(f"{name}.{namespace}.ApplySet.{group}".encode()).digest() - uid = base64.b64encode(hash).decode().rstrip("=").replace("/", "_").replace("+", "-") + # Format: applyset-..ConfigMap.))>-v1 + hash_input = f"{name}.{namespace}.ConfigMap." + hash_bytes = hashlib.sha256(hash_input.encode()).digest() + uid = base64.b64encode(hash_bytes).decode().rstrip("=").replace("/", "_").replace("+", "-") return f"applyset-{uid}-v1" diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index b6048139..0222f510 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -1,38 +1,39 @@ -from nyl.resources import ObjectMetadata from nyl.resources.applyset import ApplySet, calculate_applyset_id, get_canonical_resource_kind_name def test__ApplySet__dump() -> None: - resource = ApplySet( - metadata=ObjectMetadata( - name="test-applyset", - namespace=None, - ) - ) - resource.tooling = "kubectl/1.30" - resource.contains_group_kinds = ["Service", "Deployment.apps"] - resource.validate() + applyset = ApplySet.new("test-applyset", "default") + applyset.tooling = "kubectl/1.30" + applyset.contains_group_kinds = ["Deployment.apps", "Service"] + applyset.validate() - assert resource.dump() == { - "apiVersion": "nyl.io/v1", - "kind": "ApplySet", + assert applyset.dump() == { + "apiVersion": "v1", + "kind": "ConfigMap", "metadata": { "name": "test-applyset", + "namespace": "default", "annotations": { "applyset.kubernetes.io/tooling": "kubectl/1.30", - "applyset.kubernetes.io/contains-group-kinds": "Deployment.apps,Service", # sorted + "applyset.kubernetes.io/contains-group-kinds": "Deployment.apps,Service", }, "labels": { "applyset.kubernetes.io/id": calculate_applyset_id( name="test-applyset", - namespace="", - group="nyl.io", + namespace="default", ), }, }, } +def test__calculate_applyset_id() -> None: + # Verify the ID format is correct for ConfigMap-based ApplySets + applyset_id = calculate_applyset_id(name="test", namespace="default") + assert applyset_id.startswith("applyset-") + assert applyset_id.endswith("-v1") + + def test__get_canonical_resource_kind_name() -> None: # The format is . - singular, capitalized Kind name assert get_canonical_resource_kind_name("v1", "Pod") == "Pod" diff --git a/src/nyl/tools/kubectl.py b/src/nyl/tools/kubectl.py index 6147ed1e..d542b934 100644 --- a/src/nyl/tools/kubectl.py +++ b/src/nyl/tools/kubectl.py @@ -5,16 +5,19 @@ from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict import yaml from loguru import logger -from nyl.resources.applyset import APPLYSET_LABEL_PART_OF, ApplySet +from nyl.resources.applyset import APPLYSET_LABEL_PART_OF from nyl.tools.logging import lazy_str from nyl.tools.shell import pretty_cmd from nyl.tools.types import ResourceList +if TYPE_CHECKING: + from nyl.resources.applyset import ApplySet + @dataclass class KubectlError(Exception): @@ -90,35 +93,26 @@ def apply( manifests: ResourceList, force_conflicts: bool = False, server_side: bool = True, - applyset: str | None = None, - prune: bool = False, ) -> None: """ Apply the given manifests to the cluster. """ - env = self.env command = ["kubectl", "apply", "-f", "-"] if server_side: command.append("--server-side") - if applyset: - env = env.copy() - env["KUBECTL_APPLYSET"] = "true" - command.extend(["--applyset", applyset]) - if prune: - command.append("--prune") if force_conflicts: command.append("--force-conflicts") logger.debug("Applying manifests with command: $ {command}", command=lazy_str(pretty_cmd, command)) - status = subprocess.run(command, input=yaml.safe_dump_all(manifests), text=True, env={**os.environ, **env}) + status = subprocess.run(command, input=yaml.safe_dump_all(manifests), text=True, env={**os.environ, **self.env}) if status.returncode: raise KubectlError(status.returncode) def diff( self, manifests: ResourceList, - applyset: ApplySet | None = None, + applyset: "ApplySet | None" = None, on_error: Literal["raise", "return"] = "raise", ) -> Literal["no-diff", "diff", "error"]: """ From 92ce57691d3bf87c2d982da6ce52d9cec88a94e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:29:26 +0000 Subject: [PATCH 06/11] Add nyl.io/last-applied-context annotation for ApplySet tracking Introduces a Nyl-specific annotation that stores contextual information about when/how the ApplySet was last applied: - source: "cli" or "argocd" depending on the execution context - files: List of manifest file names used to generate resources - revision: Git commit hash (when available via ArgoCD) - app_name: ArgoCD application name (when running via ArgoCD) This helps track the origin and version of applied configurations. Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 22 +++++++++++- src/nyl/resources/applyset.py | 56 ++++++++++++++++++++++++++--- src/nyl/resources/applyset_test.py | 57 +++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index 22248e32..0ecd251f 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -21,7 +21,7 @@ from nyl.profiles import DEFAULT_PROFILE, ProfileManager from nyl.project.config import ProjectConfig from nyl.resources import API_VERSION_INLINE, NylResource -from nyl.resources.applyset import APPLYSET_LABEL_PART_OF, ApplySet +from nyl.resources.applyset import APPLYSET_LABEL_PART_OF, ApplySet, ApplySetContext from nyl.resources.postprocessor import PostProcessor from nyl.secrets.config import SecretsConfig from nyl.templating import NylTemplateEngine @@ -308,6 +308,26 @@ def worker() -> ResourceList: applyset_name = current_default_namespace applyset = ApplySet.new(applyset_name, current_default_namespace) + + # Build context information for the ApplySet + argocd_app_name = os.getenv("ARGOCD_APP_NAME") + argocd_revision = os.getenv("ARGOCD_APP_REVISION") + + if argocd_app_name: + # Running via ArgoCD + applyset.context = ApplySetContext( + source="argocd", + files=[str(source.file)], + revision=argocd_revision, + app_name=argocd_app_name, + ) + else: + # Running via CLI + applyset.context = ApplySetContext( + source="cli", + files=[str(source.file)], + ) + logger.opt(colors=True).info( "Automatically creating ApplySet for {} (name: {}, namespace: {}).", source.file, diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 870d2117..7af6fe5b 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -1,6 +1,8 @@ import base64 import hashlib +import json from dataclasses import dataclass, field +from typing import Any from nyl.tools.types import Resource, ResourceList @@ -16,6 +18,45 @@ APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS = "applyset.kubernetes.io/contains-group-kinds" """ Annotation key to use on ApplySet resources to specify the kinds of resources that are part of the ApplySet. """ +NYL_ANNOTATION_LAST_APPLIED_CONTEXT = "nyl.io/last-applied-context" +""" +Annotation key to store contextual information about the last applied configuration. +Contains a JSON object with fields like: +- source: "cli" or "argocd" +- revision: Git commit hash (when available via ArgoCD) +- files: List of manifest file names used +""" + + +@dataclass +class ApplySetContext: + """ + Contextual information about when/how the ApplySet was last applied. + """ + + source: str + """The source of the apply operation: "cli" or "argocd".""" + + files: list[str] = field(default_factory=list) + """List of manifest file names used to generate the resources.""" + + revision: str | None = None + """Git commit hash (when available, e.g., via ArgoCD).""" + + app_name: str | None = None + """ArgoCD application name (when running via ArgoCD).""" + + def to_json(self) -> str: + """Serialize the context to a JSON string.""" + data: dict[str, Any] = {"source": self.source} + if self.files: + data["files"] = self.files + if self.revision: + data["revision"] = self.revision + if self.app_name: + data["app_name"] = self.app_name + return json.dumps(data, separators=(",", ":")) + @dataclass class ApplySet: @@ -37,6 +78,7 @@ class ApplySet: namespace: str tooling: str = "" contains_group_kinds: list[str] = field(default_factory=list) + context: ApplySetContext | None = None @property def id(self) -> str: @@ -85,16 +127,22 @@ def dump(self) -> Resource: Dump the ApplySet as a ConfigMap resource with the appropriate annotations and labels. """ + annotations: dict[str, str] = { + APPLYSET_ANNOTATION_TOOLING: self.tooling, + APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS: ",".join(self.contains_group_kinds), + } + + # Add the context annotation if available + if self.context is not None: + annotations[NYL_ANNOTATION_LAST_APPLIED_CONTEXT] = self.context.to_json() + return Resource({ "apiVersion": "v1", "kind": "ConfigMap", "metadata": { "name": self.name, "namespace": self.namespace, - "annotations": { - APPLYSET_ANNOTATION_TOOLING: self.tooling, - APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS: ",".join(self.contains_group_kinds), - }, + "annotations": annotations, "labels": { APPLYSET_LABEL_ID: self.id, }, diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index 0222f510..50007786 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -1,4 +1,10 @@ -from nyl.resources.applyset import ApplySet, calculate_applyset_id, get_canonical_resource_kind_name +from nyl.resources.applyset import ( + ApplySet, + ApplySetContext, + NYL_ANNOTATION_LAST_APPLIED_CONTEXT, + calculate_applyset_id, + get_canonical_resource_kind_name, +) def test__ApplySet__dump() -> None: @@ -27,6 +33,55 @@ def test__ApplySet__dump() -> None: } +def test__ApplySet__dump_with_context() -> None: + applyset = ApplySet.new("test-applyset", "default") + applyset.tooling = "kubectl/1.30" + applyset.contains_group_kinds = ["Deployment.apps", "Service"] + applyset.context = ApplySetContext( + source="cli", + files=["test.yaml"], + ) + applyset.validate() + + result = applyset.dump() + assert result["metadata"]["annotations"][NYL_ANNOTATION_LAST_APPLIED_CONTEXT] == '{"source":"cli","files":["test.yaml"]}' + + +def test__ApplySet__dump_with_argocd_context() -> None: + applyset = ApplySet.new("test-applyset", "default") + applyset.tooling = "kubectl/1.30" + applyset.contains_group_kinds = ["Deployment.apps", "Service"] + applyset.context = ApplySetContext( + source="argocd", + files=["test.yaml"], + revision="abc123", + app_name="my-app", + ) + applyset.validate() + + result = applyset.dump() + assert result["metadata"]["annotations"][NYL_ANNOTATION_LAST_APPLIED_CONTEXT] == '{"source":"argocd","files":["test.yaml"],"revision":"abc123","app_name":"my-app"}' + + +def test__ApplySetContext__to_json() -> None: + # Test CLI context + context = ApplySetContext(source="cli", files=["a.yaml", "b.yaml"]) + assert context.to_json() == '{"source":"cli","files":["a.yaml","b.yaml"]}' + + # Test ArgoCD context with all fields + context = ApplySetContext( + source="argocd", + files=["test.yaml"], + revision="abc123def", + app_name="my-app", + ) + assert context.to_json() == '{"source":"argocd","files":["test.yaml"],"revision":"abc123def","app_name":"my-app"}' + + # Test minimal context + context = ApplySetContext(source="cli") + assert context.to_json() == '{"source":"cli"}' + + def test__calculate_applyset_id() -> None: # Verify the ID format is correct for ConfigMap-based ApplySets applyset_id = calculate_applyset_id(name="test", namespace="default") From 4b794955920ae1a4f2dfa16655a9d7abe661a9a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:46:24 +0000 Subject: [PATCH 07/11] Add ApplySetManager and enhance ApplySetContext with more ArgoCD env vars - Add ApplySetContext.from_environment() static method to create context from env vars - Include additional ArgoCD env vars: source_path, source_repo_url, target_revision - Create ApplySetManager class to handle apply/diff operations with ApplySets - Remove separate apply of ApplySet ConfigMap - now included with other resources - Update template.py to use ApplySetManager.prepare_resources() - Add comprehensive tests for new functionality Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 62 ++++----------- src/nyl/resources/applyset.py | 116 +++++++++++++++++++++++++++++ src/nyl/resources/applyset_test.py | 102 ++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 48 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index 0ecd251f..060c5633 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -21,7 +21,7 @@ from nyl.profiles import DEFAULT_PROFILE, ProfileManager from nyl.project.config import ProjectConfig from nyl.resources import API_VERSION_INLINE, NylResource -from nyl.resources.applyset import APPLYSET_LABEL_PART_OF, ApplySet, ApplySetContext +from nyl.resources.applyset import ApplySet, ApplySetContext, ApplySetManager from nyl.resources.postprocessor import PostProcessor from nyl.secrets.config import SecretsConfig from nyl.templating import NylTemplateEngine @@ -309,24 +309,8 @@ def worker() -> ResourceList: applyset_name = current_default_namespace applyset = ApplySet.new(applyset_name, current_default_namespace) - # Build context information for the ApplySet - argocd_app_name = os.getenv("ARGOCD_APP_NAME") - argocd_revision = os.getenv("ARGOCD_APP_REVISION") - - if argocd_app_name: - # Running via ArgoCD - applyset.context = ApplySetContext( - source="argocd", - files=[str(source.file)], - revision=argocd_revision, - app_name=argocd_app_name, - ) - else: - # Running via CLI - applyset.context = ApplySetContext( - source="cli", - files=[str(source.file)], - ) + # Build context information for the ApplySet from environment + applyset.context = ApplySetContext.from_environment(files=[str(source.file)]) logger.opt(colors=True).info( "Automatically creating ApplySet for {} (name: {}, namespace: {}).", @@ -335,26 +319,14 @@ def worker() -> ResourceList: current_default_namespace, ) + # Create the ApplySetManager to handle apply/diff operations + applyset_manager = ApplySetManager(applyset=applyset, add_part_of_labels=applyset_part_of) + if applyset is not None: applyset.set_group_kinds(source.resources) applyset.tooling = f"kubectl/v{generator.kube_version}" applyset.validate() - if apply: - # Apply the ConfigMap that serves as the ApplySet parent - logger.opt(colors=True).info( - "Kubectl-apply ApplySet ConfigMap {}/{} from {}.", - applyset.namespace, - applyset.name, - source.file, - ) - kubectl.apply(ResourceList([applyset.dump()]), force_conflicts=True) - elif diff: - kubectl.diff(ResourceList([applyset.dump()])) - else: - print("---") - print(yaml.dumps(applyset.dump())) - # Validate resources. for resource in source.resources: # Inline resources often don't have metadata and they are not persisted to the cluster, hence @@ -371,32 +343,30 @@ def worker() -> ResourceList: ) exit(1) - # Tag resources as part of the current apply set, if any. - if applyset is not None and applyset_part_of: - for resource in source.resources: - if APPLYSET_LABEL_PART_OF not in (labels := resource["metadata"].setdefault("labels", {})): - labels[APPLYSET_LABEL_PART_OF] = applyset.id - populate_namespace_to_resources(source.resources, current_default_namespace) drop_empty_metadata_labels(source.resources) # Now apply the post-processor. source.resources = PostProcessor.apply_all(source.resources, post_processors, source.file) + # Prepare resources with ApplySet (adds part-of labels and includes ConfigMap) + resources_to_apply = applyset_manager.prepare_resources(source.resources) + if apply: - logger.info("Kubectl-apply {} resource(s) from '{}'", len(source.resources), source.file) + logger.info("Kubectl-apply {} resource(s) from '{}'", len(resources_to_apply), source.file) # Note: We don't use kubectl's --applyset flag because it has limitations with multi-namespace deployments. - # Instead, we manually set the applyset.kubernetes.io/part-of label on all resources. + # Instead, we manually set the applyset.kubernetes.io/part-of label on all resources and include the + # ApplySet ConfigMap with the rest of the resources. kubectl.apply( - manifests=source.resources, + manifests=resources_to_apply, force_conflicts=True, ) elif diff: - logger.info("Kubectl-diff {} resource(s) from '{}'", len(source.resources), source.file) - kubectl.diff(manifests=source.resources, applyset=applyset) + logger.info("Kubectl-diff {} resource(s) from '{}'", len(resources_to_apply), source.file) + kubectl.diff(manifests=resources_to_apply, applyset=applyset) else: # If we're not going to be applying the resources immediately via `kubectl`, we print them to stdout. - for resource in source.resources: + for resource in resources_to_apply: print("---") print(yaml.dumps(resource)) diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 7af6fe5b..98983f0a 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -1,6 +1,7 @@ import base64 import hashlib import json +import os from dataclasses import dataclass, field from typing import Any @@ -46,6 +47,15 @@ class ApplySetContext: app_name: str | None = None """ArgoCD application name (when running via ArgoCD).""" + source_path: str | None = None + """ArgoCD source path (when running via ArgoCD).""" + + source_repo_url: str | None = None + """ArgoCD source repository URL (when running via ArgoCD).""" + + target_revision: str | None = None + """ArgoCD target revision (when running via ArgoCD).""" + def to_json(self) -> str: """Serialize the context to a JSON string.""" data: dict[str, Any] = {"source": self.source} @@ -55,8 +65,48 @@ def to_json(self) -> str: data["revision"] = self.revision if self.app_name: data["app_name"] = self.app_name + if self.source_path: + data["source_path"] = self.source_path + if self.source_repo_url: + data["source_repo_url"] = self.source_repo_url + if self.target_revision: + data["target_revision"] = self.target_revision return json.dumps(data, separators=(",", ":")) + @staticmethod + def from_environment(files: list[str] | None = None) -> "ApplySetContext": + """ + Create an ApplySetContext from the current environment. + + Detects whether running via ArgoCD (by checking ARGOCD_APP_NAME env var) + and populates the context accordingly. + + Args: + files: List of manifest file names used to generate the resources. + + Returns: + An ApplySetContext populated from environment variables. + """ + argocd_app_name = os.getenv("ARGOCD_APP_NAME") + + if argocd_app_name: + # Running via ArgoCD + return ApplySetContext( + source="argocd", + files=files or [], + revision=os.getenv("ARGOCD_APP_REVISION"), + app_name=argocd_app_name, + source_path=os.getenv("ARGOCD_APP_SOURCE_PATH"), + source_repo_url=os.getenv("ARGOCD_APP_SOURCE_REPO_URL"), + target_revision=os.getenv("ARGOCD_APP_SOURCE_TARGET_REVISION"), + ) + else: + # Running via CLI + return ApplySetContext( + source="cli", + files=files or [], + ) + @dataclass class ApplySet: @@ -191,3 +241,69 @@ def get_canonical_resource_kind_name(api_version: str, kind: str) -> str: group = api_version.split("/")[0] if "/" in api_version else "" return (f"{kind}.{group}").rstrip(".") + + +class ApplySetManager: + """ + Helper class to manage applying and diffing resources associated with an ApplySet. + + This class handles: + - Creating and configuring ApplySet resources + - Including the ApplySet ConfigMap with other resources during apply/diff + - Tagging resources with the applyset.kubernetes.io/part-of label + """ + + def __init__(self, applyset: ApplySet | None = None, add_part_of_labels: bool = True) -> None: + """ + Initialize the ApplySetManager. + + Args: + applyset: The ApplySet to manage, or None to skip ApplySet-related logic. + add_part_of_labels: Whether to add the applyset.kubernetes.io/part-of label to resources. + """ + self.applyset = applyset + self.add_part_of_labels = add_part_of_labels + + @property + def enabled(self) -> bool: + """Returns True if ApplySet management is enabled (i.e., an ApplySet is configured).""" + return self.applyset is not None + + def prepare_resources(self, resources: ResourceList) -> ResourceList: + """ + Prepare resources for apply/diff by adding ApplySet labels and including the ApplySet ConfigMap. + + Args: + resources: The list of resources to prepare. + + Returns: + A new ResourceList with the ApplySet ConfigMap included (if enabled) and part-of labels added. + """ + if not self.enabled or self.applyset is None: + return resources + + result = ResourceList(list(resources)) + + # Tag resources as part of the current apply set + if self.add_part_of_labels: + for resource in result: + if "metadata" in resource: + labels = resource["metadata"].setdefault("labels", {}) + if APPLYSET_LABEL_PART_OF not in labels: + labels[APPLYSET_LABEL_PART_OF] = self.applyset.id + + # Include the ApplySet ConfigMap with the resources + result.insert(0, self.applyset.dump()) + + return result + + def get_applyset_resource(self) -> Resource | None: + """ + Get the ApplySet ConfigMap resource. + + Returns: + The ApplySet ConfigMap resource, or None if ApplySet is not enabled. + """ + if not self.enabled or self.applyset is None: + return None + return self.applyset.dump() diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index 50007786..4a534f3d 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -1,10 +1,16 @@ +import os +from unittest.mock import patch + from nyl.resources.applyset import ( + APPLYSET_LABEL_PART_OF, ApplySet, ApplySetContext, + ApplySetManager, NYL_ANNOTATION_LAST_APPLIED_CONTEXT, calculate_applyset_id, get_canonical_resource_kind_name, ) +from nyl.tools.types import Resource, ResourceList def test__ApplySet__dump() -> None: @@ -56,11 +62,15 @@ def test__ApplySet__dump_with_argocd_context() -> None: files=["test.yaml"], revision="abc123", app_name="my-app", + source_path="/apps/myapp", + source_repo_url="https://github.com/example/repo", + target_revision="main", ) applyset.validate() result = applyset.dump() - assert result["metadata"]["annotations"][NYL_ANNOTATION_LAST_APPLIED_CONTEXT] == '{"source":"argocd","files":["test.yaml"],"revision":"abc123","app_name":"my-app"}' + expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123","app_name":"my-app","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main"}' + assert result["metadata"]["annotations"][NYL_ANNOTATION_LAST_APPLIED_CONTEXT] == expected def test__ApplySetContext__to_json() -> None: @@ -74,14 +84,102 @@ def test__ApplySetContext__to_json() -> None: files=["test.yaml"], revision="abc123def", app_name="my-app", + source_path="/apps/myapp", + source_repo_url="https://github.com/example/repo", + target_revision="main", ) - assert context.to_json() == '{"source":"argocd","files":["test.yaml"],"revision":"abc123def","app_name":"my-app"}' + expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123def","app_name":"my-app","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main"}' + assert context.to_json() == expected # Test minimal context context = ApplySetContext(source="cli") assert context.to_json() == '{"source":"cli"}' +def test__ApplySetContext__from_environment_cli() -> None: + # Test CLI context (no ArgoCD env vars) + with patch.dict(os.environ, {}, clear=True): + context = ApplySetContext.from_environment(files=["test.yaml"]) + assert context.source == "cli" + assert context.files == ["test.yaml"] + assert context.app_name is None + assert context.revision is None + + +def test__ApplySetContext__from_environment_argocd() -> None: + # Test ArgoCD context (with ArgoCD env vars) + argocd_env = { + "ARGOCD_APP_NAME": "my-app", + "ARGOCD_APP_REVISION": "abc123", + "ARGOCD_APP_SOURCE_PATH": "/apps/myapp", + "ARGOCD_APP_SOURCE_REPO_URL": "https://github.com/example/repo", + "ARGOCD_APP_SOURCE_TARGET_REVISION": "main", + } + with patch.dict(os.environ, argocd_env, clear=True): + context = ApplySetContext.from_environment(files=["test.yaml"]) + assert context.source == "argocd" + assert context.files == ["test.yaml"] + assert context.app_name == "my-app" + assert context.revision == "abc123" + assert context.source_path == "/apps/myapp" + assert context.source_repo_url == "https://github.com/example/repo" + assert context.target_revision == "main" + + +def test__ApplySetManager__disabled() -> None: + # Test that manager passes through resources when disabled + manager = ApplySetManager(applyset=None) + assert not manager.enabled + + resources = ResourceList([ + Resource({"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "test"}}) + ]) + result = manager.prepare_resources(resources) + assert len(result) == 1 + assert result[0]["kind"] == "Pod" + + +def test__ApplySetManager__prepare_resources() -> None: + # Test that manager adds ConfigMap and part-of labels + applyset = ApplySet.new("test-applyset", "default") + applyset.tooling = "kubectl/1.30" + applyset.contains_group_kinds = ["Pod"] + + manager = ApplySetManager(applyset=applyset, add_part_of_labels=True) + assert manager.enabled + + resources = ResourceList([ + Resource({"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "test"}}) + ]) + result = manager.prepare_resources(resources) + + # Should have ConfigMap at the beginning + original resource + assert len(result) == 2 + assert result[0]["kind"] == "ConfigMap" + assert result[0]["metadata"]["name"] == "test-applyset" + assert result[1]["kind"] == "Pod" + assert result[1]["metadata"]["labels"][APPLYSET_LABEL_PART_OF] == applyset.id + + +def test__ApplySetManager__prepare_resources_no_labels() -> None: + # Test that manager doesn't add part-of labels when disabled + applyset = ApplySet.new("test-applyset", "default") + applyset.tooling = "kubectl/1.30" + applyset.contains_group_kinds = ["Pod"] + + manager = ApplySetManager(applyset=applyset, add_part_of_labels=False) + + resources = ResourceList([ + Resource({"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "test"}}) + ]) + result = manager.prepare_resources(resources) + + # Should have ConfigMap but no part-of label on the Pod + assert len(result) == 2 + assert result[0]["kind"] == "ConfigMap" + assert APPLYSET_LABEL_PART_OF not in result[1]["metadata"].get("labels", {}) + + def test__calculate_applyset_id() -> None: # Verify the ID format is correct for ConfigMap-based ApplySets applyset_id = calculate_applyset_id(name="test", namespace="default") From 65a455ae1a642b3e0689cd0cf245337f7126c16d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:11:59 +0000 Subject: [PATCH 08/11] Add ARGOCD_APP_NAMESPACE, ARGOCD_APP_PROJECT_NAME, and KUBE_VERSION to ApplySetContext - app_namespace: from ARGOCD_APP_NAMESPACE env var - project_name: from ARGOCD_APP_PROJECT_NAME env var - kube_version: from KUBE_VERSION env var (available in both CLI and ArgoCD contexts) - Updated tests to cover new fields Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/resources/applyset.py | 20 ++++++++++++++++++++ src/nyl/resources/applyset_test.py | 23 +++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 98983f0a..52d2d26d 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -47,6 +47,12 @@ class ApplySetContext: app_name: str | None = None """ArgoCD application name (when running via ArgoCD).""" + app_namespace: str | None = None + """ArgoCD application namespace (when running via ArgoCD).""" + + project_name: str | None = None + """ArgoCD project name (when running via ArgoCD).""" + source_path: str | None = None """ArgoCD source path (when running via ArgoCD).""" @@ -56,6 +62,9 @@ class ApplySetContext: target_revision: str | None = None """ArgoCD target revision (when running via ArgoCD).""" + kube_version: str | None = None + """Kubernetes version (from KUBE_VERSION env var).""" + def to_json(self) -> str: """Serialize the context to a JSON string.""" data: dict[str, Any] = {"source": self.source} @@ -65,12 +74,18 @@ def to_json(self) -> str: data["revision"] = self.revision if self.app_name: data["app_name"] = self.app_name + if self.app_namespace: + data["app_namespace"] = self.app_namespace + if self.project_name: + data["project_name"] = self.project_name if self.source_path: data["source_path"] = self.source_path if self.source_repo_url: data["source_repo_url"] = self.source_repo_url if self.target_revision: data["target_revision"] = self.target_revision + if self.kube_version: + data["kube_version"] = self.kube_version return json.dumps(data, separators=(",", ":")) @staticmethod @@ -88,6 +103,7 @@ def from_environment(files: list[str] | None = None) -> "ApplySetContext": An ApplySetContext populated from environment variables. """ argocd_app_name = os.getenv("ARGOCD_APP_NAME") + kube_version = os.getenv("KUBE_VERSION") if argocd_app_name: # Running via ArgoCD @@ -96,15 +112,19 @@ def from_environment(files: list[str] | None = None) -> "ApplySetContext": files=files or [], revision=os.getenv("ARGOCD_APP_REVISION"), app_name=argocd_app_name, + app_namespace=os.getenv("ARGOCD_APP_NAMESPACE"), + project_name=os.getenv("ARGOCD_APP_PROJECT_NAME"), source_path=os.getenv("ARGOCD_APP_SOURCE_PATH"), source_repo_url=os.getenv("ARGOCD_APP_SOURCE_REPO_URL"), target_revision=os.getenv("ARGOCD_APP_SOURCE_TARGET_REVISION"), + kube_version=kube_version, ) else: # Running via CLI return ApplySetContext( source="cli", files=files or [], + kube_version=kube_version, ) diff --git a/src/nyl/resources/applyset_test.py b/src/nyl/resources/applyset_test.py index 4a534f3d..1730d70f 100644 --- a/src/nyl/resources/applyset_test.py +++ b/src/nyl/resources/applyset_test.py @@ -62,14 +62,17 @@ def test__ApplySet__dump_with_argocd_context() -> None: files=["test.yaml"], revision="abc123", app_name="my-app", + app_namespace="argocd", + project_name="default", source_path="/apps/myapp", source_repo_url="https://github.com/example/repo", target_revision="main", + kube_version="1.31", ) applyset.validate() result = applyset.dump() - expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123","app_name":"my-app","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main"}' + expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123","app_name":"my-app","app_namespace":"argocd","project_name":"default","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main","kube_version":"1.31"}' assert result["metadata"]["annotations"][NYL_ANNOTATION_LAST_APPLIED_CONTEXT] == expected @@ -84,11 +87,14 @@ def test__ApplySetContext__to_json() -> None: files=["test.yaml"], revision="abc123def", app_name="my-app", + app_namespace="argocd", + project_name="default", source_path="/apps/myapp", source_repo_url="https://github.com/example/repo", target_revision="main", + kube_version="1.31", ) - expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123def","app_name":"my-app","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main"}' + expected = '{"source":"argocd","files":["test.yaml"],"revision":"abc123def","app_name":"my-app","app_namespace":"argocd","project_name":"default","source_path":"/apps/myapp","source_repo_url":"https://github.com/example/repo","target_revision":"main","kube_version":"1.31"}' assert context.to_json() == expected # Test minimal context @@ -104,6 +110,13 @@ def test__ApplySetContext__from_environment_cli() -> None: assert context.files == ["test.yaml"] assert context.app_name is None assert context.revision is None + assert context.kube_version is None + + # Test CLI context with KUBE_VERSION + with patch.dict(os.environ, {"KUBE_VERSION": "1.31"}, clear=True): + context = ApplySetContext.from_environment(files=["test.yaml"]) + assert context.source == "cli" + assert context.kube_version == "1.31" def test__ApplySetContext__from_environment_argocd() -> None: @@ -111,9 +124,12 @@ def test__ApplySetContext__from_environment_argocd() -> None: argocd_env = { "ARGOCD_APP_NAME": "my-app", "ARGOCD_APP_REVISION": "abc123", + "ARGOCD_APP_NAMESPACE": "argocd", + "ARGOCD_APP_PROJECT_NAME": "default", "ARGOCD_APP_SOURCE_PATH": "/apps/myapp", "ARGOCD_APP_SOURCE_REPO_URL": "https://github.com/example/repo", "ARGOCD_APP_SOURCE_TARGET_REVISION": "main", + "KUBE_VERSION": "1.31", } with patch.dict(os.environ, argocd_env, clear=True): context = ApplySetContext.from_environment(files=["test.yaml"]) @@ -121,9 +137,12 @@ def test__ApplySetContext__from_environment_argocd() -> None: assert context.files == ["test.yaml"] assert context.app_name == "my-app" assert context.revision == "abc123" + assert context.app_namespace == "argocd" + assert context.project_name == "default" assert context.source_path == "/apps/myapp" assert context.source_repo_url == "https://github.com/example/repo" assert context.target_revision == "main" + assert context.kube_version == "1.31" def test__ApplySetManager__disabled() -> None: From e741e0cd2e82a857621b9ac1f84eea44a13b5733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:56:27 +0000 Subject: [PATCH 09/11] Add functions to lookup existing ApplySet and list members - get_existing_applyset(name, namespace): Looks up an existing ApplySet ConfigMap in the cluster - list_applyset_members(applyset_id): Lists all resources with the applyset.kubernetes.io/part-of label These functions enable nyl template --apply and --diff to find removed resources. Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/resources/applyset.py | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 52d2d26d..28202c42 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -271,6 +271,8 @@ class ApplySetManager: - Creating and configuring ApplySet resources - Including the ApplySet ConfigMap with other resources during apply/diff - Tagging resources with the applyset.kubernetes.io/part-of label + - Looking up existing ApplySet ConfigMaps in the cluster + - Listing all resources that are members of an ApplySet """ def __init__(self, applyset: ApplySet | None = None, add_part_of_labels: bool = True) -> None: @@ -327,3 +329,103 @@ def get_applyset_resource(self) -> Resource | None: if not self.enabled or self.applyset is None: return None return self.applyset.dump() + + +def get_existing_applyset(name: str, namespace: str) -> Resource | None: + """ + Look up an existing ApplySet ConfigMap in the cluster. + + Args: + name: The name of the ApplySet ConfigMap. + namespace: The namespace of the ApplySet ConfigMap. + + Returns: + The ApplySet ConfigMap resource if it exists, or None if not found. + """ + import subprocess + + command = ["kubectl", "get", "configmap", name, "-n", namespace, "-o", "json"] + result = subprocess.run(command, capture_output=True, text=True) + + if result.returncode != 0: + # ConfigMap doesn't exist or other error + return None + + try: + data = json.loads(result.stdout) + # Verify it's an ApplySet ConfigMap by checking for the id label + labels = data.get("metadata", {}).get("labels", {}) + if APPLYSET_LABEL_ID in labels: + return Resource(data) + return None + except json.JSONDecodeError: + return None + + +def list_applyset_members(applyset_id: str) -> ResourceList: + """ + List all resources in the cluster that are members of an ApplySet. + + Args: + applyset_id: The ID of the ApplySet (value of applyset.kubernetes.io/id label). + + Returns: + A ResourceList of all resources that have the applyset.kubernetes.io/part-of label + matching the given ApplySet ID. + """ + import subprocess + + # Use kubectl to find all resources with the part-of label + # We need to search across all resource types, so we use "all" plus some common types + # that are not included in "all" + resource_types = [ + "all", # Includes pods, services, deployments, replicasets, etc. + "configmaps", + "secrets", + "persistentvolumeclaims", + "ingresses", + "networkpolicies", + "serviceaccounts", + "roles", + "rolebindings", + "clusterroles", + "clusterrolebindings", + "customresourcedefinitions", + "namespaces", + ] + + all_resources: list[Resource] = [] + + for resource_type in resource_types: + command = [ + "kubectl", + "get", + resource_type, + "--all-namespaces", + "-l", + f"{APPLYSET_LABEL_PART_OF}={applyset_id}", + "-o", + "json", + ] + + result = subprocess.run(command, capture_output=True, text=True) + + if result.returncode == 0: + try: + data = json.loads(result.stdout) + items = data.get("items", []) + for item in items: + all_resources.append(Resource(item)) + except json.JSONDecodeError: + continue + + # Deduplicate resources by their UID + seen_uids: set[str] = set() + unique_resources: list[Resource] = [] + for resource in all_resources: + uid = resource.get("metadata", {}).get("uid", "") + if uid and uid not in seen_uids: + seen_uids.add(uid) + unique_resources.append(resource) + + return ResourceList(unique_resources) From 1e6a8d2771de79b6e67e59365e31296669cebabf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 18:17:24 +0000 Subject: [PATCH 10/11] Implement deleted resource handling for nyl template --apply and --diff - Add Kubectl.delete() method to delete individual resources - Add ApplySetManager.get_deleted_resources() to find resources in cluster but not in manifest - Add _get_resource_identifier() helper for resource comparison - Update template command to delete removed resources during --apply - Update template command to show resources to be deleted during --diff Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> --- src/nyl/commands/template.py | 29 +++++++++++++++ src/nyl/resources/applyset.py | 69 +++++++++++++++++++++++++++++++++++ src/nyl/tools/kubectl.py | 41 ++++++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index 060c5633..3fae36db 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -352,6 +352,9 @@ def worker() -> ResourceList: # Prepare resources with ApplySet (adds part-of labels and includes ConfigMap) resources_to_apply = applyset_manager.prepare_resources(source.resources) + # Find deleted resources (resources that exist in cluster but not in manifest) + deleted_resources = applyset_manager.get_deleted_resources(source.resources) + if apply: logger.info("Kubectl-apply {} resource(s) from '{}'", len(resources_to_apply), source.file) # Note: We don't use kubectl's --applyset flag because it has limitations with multi-namespace deployments. @@ -361,9 +364,35 @@ def worker() -> ResourceList: manifests=resources_to_apply, force_conflicts=True, ) + + # Delete resources that are no longer in the manifest + if deleted_resources: + logger.info("Deleting {} resource(s) that are no longer in the manifest", len(deleted_resources)) + for resource in deleted_resources: + metadata = resource.get("metadata", {}) + resource_name = f"{resource.get('kind', 'unknown')}/{metadata.get('name', 'unknown')}" + if metadata.get("namespace"): + resource_name = f"{metadata['namespace']}/{resource_name}" + logger.opt(colors=True).info("Deleting {}", resource_name) + try: + kubectl.delete(resource) + except Exception as e: + logger.warning("Failed to delete {}: {}", resource_name, e) + elif diff: logger.info("Kubectl-diff {} resource(s) from '{}'", len(resources_to_apply), source.file) kubectl.diff(manifests=resources_to_apply, applyset=applyset) + + # Show deleted resources in diff output + if deleted_resources: + print("\n# Resources to be DELETED (no longer in manifest):") + for resource in deleted_resources: + metadata = resource.get("metadata", {}) + resource_name = f"{resource.get('kind', 'unknown')}/{metadata.get('name', 'unknown')}" + if metadata.get("namespace"): + resource_name = f"{metadata['namespace']}/{resource_name}" + print(f"# - {resource_name}") + else: # If we're not going to be applying the resources immediately via `kubectl`, we print them to stdout. for resource in resources_to_apply: diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 28202c42..06505fbb 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -273,6 +273,7 @@ class ApplySetManager: - Tagging resources with the applyset.kubernetes.io/part-of label - Looking up existing ApplySet ConfigMaps in the cluster - Listing all resources that are members of an ApplySet + - Computing which resources have been removed from the manifest """ def __init__(self, applyset: ApplySet | None = None, add_part_of_labels: bool = True) -> None: @@ -330,6 +331,74 @@ def get_applyset_resource(self) -> Resource | None: return None return self.applyset.dump() + def get_deleted_resources(self, new_resources: ResourceList) -> ResourceList: + """ + Compute which resources have been removed from the manifest. + + This compares the new resources against the existing resources in the cluster + that are part of this ApplySet, and returns the resources that exist in the + cluster but are not in the new manifest. + + Args: + new_resources: The new list of resources from the manifest. + + Returns: + A ResourceList of resources that should be deleted (exist in cluster but not in manifest). + """ + if not self.enabled or self.applyset is None: + return ResourceList([]) + + # Get existing resources from the cluster that belong to this ApplySet + existing_resources = list_applyset_members(self.applyset.id) + + if not existing_resources: + return ResourceList([]) + + # Build a set of resource identifiers from the new manifest + new_resource_ids = set() + for resource in new_resources: + resource_id = _get_resource_identifier(resource) + if resource_id: + new_resource_ids.add(resource_id) + + # Also add the ApplySet ConfigMap itself to avoid deleting it + applyset_cm = self.applyset.dump() + applyset_id = _get_resource_identifier(applyset_cm) + if applyset_id: + new_resource_ids.add(applyset_id) + + # Find resources that exist in the cluster but not in the new manifest + deleted_resources: list[Resource] = [] + for resource in existing_resources: + resource_id = _get_resource_identifier(resource) + if resource_id and resource_id not in new_resource_ids: + deleted_resources.append(resource) + + return ResourceList(deleted_resources) + + +def _get_resource_identifier(resource: Resource) -> str | None: + """ + Get a unique identifier for a resource based on apiVersion, kind, namespace, and name. + + Args: + resource: The resource to identify. + + Returns: + A string identifier in the format "apiVersion/kind/namespace/name" or None if the resource + is missing required fields. + """ + api_version = resource.get("apiVersion") + kind = resource.get("kind") + metadata = resource.get("metadata", {}) + name = metadata.get("name") + namespace = metadata.get("namespace", "") + + if not api_version or not kind or not name: + return None + + return f"{api_version}/{kind}/{namespace}/{name}" + def get_existing_applyset(name: str, namespace: str) -> Resource | None: """ diff --git a/src/nyl/tools/kubectl.py b/src/nyl/tools/kubectl.py index d542b934..dda6b784 100644 --- a/src/nyl/tools/kubectl.py +++ b/src/nyl/tools/kubectl.py @@ -13,7 +13,7 @@ from nyl.resources.applyset import APPLYSET_LABEL_PART_OF from nyl.tools.logging import lazy_str from nyl.tools.shell import pretty_cmd -from nyl.tools.types import ResourceList +from nyl.tools.types import Resource, ResourceList if TYPE_CHECKING: from nyl.resources.applyset import ApplySet @@ -109,6 +109,45 @@ def apply( if status.returncode: raise KubectlError(status.returncode) + def delete(self, resource: Resource) -> bool: + """ + Delete a single resource from the cluster. + + Args: + resource: The resource to delete. + + Returns: + True if the resource was deleted, False if it didn't exist. + """ + api_version = resource.get("apiVersion", "") + kind = resource.get("kind", "") + metadata = resource.get("metadata", {}) + name = metadata.get("name", "") + namespace = metadata.get("namespace") + + # Build the resource identifier + if "/" in api_version: + # For resources like apps/v1 Deployment, the type is "deployment.apps" + group = api_version.split("/")[0] + resource_type = f"{kind.lower()}.{group}" + else: + # For core v1 resources like Pod, the type is just the kind + resource_type = kind.lower() + + command = ["kubectl", "delete", resource_type, name] + if namespace: + command.extend(["-n", namespace]) + + logger.debug("Deleting resource with command: $ {command}", command=lazy_str(pretty_cmd, command)) + status = subprocess.run(command, capture_output=True, text=True, env={**os.environ, **self.env}) + + if status.returncode == 0: + return True + elif "NotFound" in status.stderr: + return False + else: + raise KubectlError(status.returncode, status.stderr) + def diff( self, manifests: ResourceList, From 74dc9a238a514cae6b763044ef239739e02ca29b Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Fri, 28 Nov 2025 16:50:00 +0100 Subject: [PATCH 11/11] let Gemini cook locally --- src/nyl/commands/template.py | 11 ++- src/nyl/resources/applyset.py | 127 ++++++++++++++++++---------------- src/nyl/tools/kubectl.py | 1 + 3 files changed, 72 insertions(+), 67 deletions(-) diff --git a/src/nyl/commands/template.py b/src/nyl/commands/template.py index 3fae36db..80a6e23a 100644 --- a/src/nyl/commands/template.py +++ b/src/nyl/commands/template.py @@ -173,11 +173,6 @@ def template( ) exit(1) - if apply: - # When running with --apply, we must ensure that the --applyset-part-of option is disabled, as it would cause - # an error when passing the generated manifests to `kubectl apply --applyset=...`. - applyset_part_of = False - if apply and diff: logger.error("The --apply and --diff options cannot be combined.") exit(1) @@ -306,7 +301,7 @@ def worker() -> ResourceList: ) exit(1) - applyset_name = current_default_namespace + applyset_name = f"nyl.applyset.{current_default_namespace}" applyset = ApplySet.new(applyset_name, current_default_namespace) # Build context information for the ApplySet from environment @@ -320,7 +315,9 @@ def worker() -> ResourceList: ) # Create the ApplySetManager to handle apply/diff operations - applyset_manager = ApplySetManager(applyset=applyset, add_part_of_labels=applyset_part_of) + applyset_manager = ApplySetManager( + client=client, applyset=applyset, add_part_of_labels=applyset_part_of + ) if applyset is not None: applyset.set_group_kinds(source.resources) diff --git a/src/nyl/resources/applyset.py b/src/nyl/resources/applyset.py index 06505fbb..47428b01 100644 --- a/src/nyl/resources/applyset.py +++ b/src/nyl/resources/applyset.py @@ -4,6 +4,11 @@ import os from dataclasses import dataclass, field from typing import Any +from loguru import logger + +from kubernetes.client.api_client import ApiClient +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError from nyl.tools.types import Resource, ResourceList @@ -276,14 +281,21 @@ class ApplySetManager: - Computing which resources have been removed from the manifest """ - def __init__(self, applyset: ApplySet | None = None, add_part_of_labels: bool = True) -> None: + def __init__( + self, + client: ApiClient, + applyset: ApplySet | None = None, + add_part_of_labels: bool = True, + ) -> None: """ Initialize the ApplySetManager. Args: + client: The Kubernetes API client to use. applyset: The ApplySet to manage, or None to skip ApplySet-related logic. add_part_of_labels: Whether to add the applyset.kubernetes.io/part-of label to resources. """ + self.client = client self.applyset = applyset self.add_part_of_labels = add_part_of_labels @@ -349,7 +361,19 @@ def get_deleted_resources(self, new_resources: ResourceList) -> ResourceList: return ResourceList([]) # Get existing resources from the cluster that belong to this ApplySet - existing_resources = list_applyset_members(self.applyset.id) + # Try to find the existing ApplySet ConfigMap to know which kinds to look for + existing_applyset_cm = get_existing_applyset( + self.applyset.name, self.applyset.namespace, self.client + ) + kinds: list[str] = [] + + if existing_applyset_cm: + annotations = existing_applyset_cm.get("metadata", {}).get("annotations", {}) + kinds_str = annotations.get(APPLYSET_ANNOTATION_CONTAINS_GROUP_KINDS) + if kinds_str: + kinds = kinds_str.split(",") + + existing_resources = list_applyset_members(self.applyset.id, self.client, kinds) if not existing_resources: return ResourceList([]) @@ -400,94 +424,77 @@ def _get_resource_identifier(resource: Resource) -> str | None: return f"{api_version}/{kind}/{namespace}/{name}" -def get_existing_applyset(name: str, namespace: str) -> Resource | None: +def get_existing_applyset(name: str, namespace: str, client: ApiClient) -> Resource | None: """ Look up an existing ApplySet ConfigMap in the cluster. Args: name: The name of the ApplySet ConfigMap. namespace: The namespace of the ApplySet ConfigMap. + client: The Kubernetes API client to use. Returns: The ApplySet ConfigMap resource if it exists, or None if not found. """ - import subprocess - - command = ["kubectl", "get", "configmap", name, "-n", namespace, "-o", "json"] - result = subprocess.run(command, capture_output=True, text=True) - - if result.returncode != 0: - # ConfigMap doesn't exist or other error - return None - try: - data = json.loads(result.stdout) + dynamic = DynamicClient(client) + # ConfigMap is v1 + resource_client = dynamic.resources.get(api_version="v1", kind="ConfigMap") + resource = resource_client.get(name=name, namespace=namespace) + # Verify it's an ApplySet ConfigMap by checking for the id label - labels = data.get("metadata", {}).get("labels", {}) + labels = resource.metadata.get("labels", {}).to_dict() if APPLYSET_LABEL_ID in labels: - return Resource(data) + return Resource(resource.to_dict()) return None - except json.JSONDecodeError: + except NotFoundError: return None -def list_applyset_members(applyset_id: str) -> ResourceList: +def list_applyset_members( + applyset_id: str, client: ApiClient, kinds: list[str] +) -> ResourceList: """ List all resources in the cluster that are members of an ApplySet. Args: applyset_id: The ID of the ApplySet (value of applyset.kubernetes.io/id label). + client: The Kubernetes API client to use. + kinds: List of resource kinds to search for. Returns: A ResourceList of all resources that have the applyset.kubernetes.io/part-of label matching the given ApplySet ID. """ - import subprocess - - # Use kubectl to find all resources with the part-of label - # We need to search across all resource types, so we use "all" plus some common types - # that are not included in "all" - resource_types = [ - "all", # Includes pods, services, deployments, replicasets, etc. - "configmaps", - "secrets", - "persistentvolumeclaims", - "ingresses", - "networkpolicies", - "serviceaccounts", - "roles", - "rolebindings", - "clusterroles", - "clusterrolebindings", - "customresourcedefinitions", - "namespaces", - ] - + dynamic = DynamicClient(client) all_resources: list[Resource] = [] - for resource_type in resource_types: - command = [ - "kubectl", - "get", - resource_type, - "--all-namespaces", - "-l", - f"{APPLYSET_LABEL_PART_OF}={applyset_id}", - "-o", - "json", - ] - - result = subprocess.run(command, capture_output=True, text=True) - - if result.returncode == 0: - try: - data = json.loads(result.stdout) - items = data.get("items", []) - for item in items: - all_resources.append(Resource(item)) - except json.JSONDecodeError: + resources_to_check: list[tuple[str, str]] = [] + + for k in kinds: + parts = k.split(".", 1) + if len(parts) == 2: + resources_to_check.append((parts[0], parts[1])) + else: + resources_to_check.append((parts[0], "")) + + for kind, group in resources_to_check: + try: + api_resources = dynamic.resources.search(kind=kind, group=group) + if not api_resources: continue + res_client = api_resources[0] + + items = res_client.get(label_selector=f"{APPLYSET_LABEL_PART_OF}={applyset_id}") + for item in items.items: + all_resources.append(Resource(item.to_dict())) + + except NotFoundError: + # It's possible the resource kind doesn't exist in the cluster (e.g. CRD was removed). + # In this case, we can just skip it. + continue + # Deduplicate resources by their UID seen_uids: set[str] = set() unique_resources: list[Resource] = [] @@ -497,4 +504,4 @@ def list_applyset_members(applyset_id: str) -> ResourceList: seen_uids.add(uid) unique_resources.append(resource) - return ResourceList(unique_resources) + return ResourceList(unique_resources) \ No newline at end of file diff --git a/src/nyl/tools/kubectl.py b/src/nyl/tools/kubectl.py index dda6b784..11b74dd3 100644 --- a/src/nyl/tools/kubectl.py +++ b/src/nyl/tools/kubectl.py @@ -105,6 +105,7 @@ def apply( command.append("--force-conflicts") logger.debug("Applying manifests with command: $ {command}", command=lazy_str(pretty_cmd, command)) + status = subprocess.run(command, input=yaml.safe_dump_all(manifests), text=True, env={**os.environ, **self.env}) if status.returncode: raise KubectlError(status.returncode)