diff --git a/src/k8s-configuration/HISTORY.rst b/src/k8s-configuration/HISTORY.rst index 381d14141ae..5a16d4e71a6 100644 --- a/src/k8s-configuration/HISTORY.rst +++ b/src/k8s-configuration/HISTORY.rst @@ -2,6 +2,10 @@ Release History =============== +2.3.0 +++++++++++++++++++ +* Added support for using OCIRepository as a source type in Flux configurations. + 2.2.0 ++++++++++++++++++ * Introduce a new feature to add provider authentication for git repositories. diff --git a/src/k8s-configuration/azext_k8s_configuration/_help.py b/src/k8s-configuration/azext_k8s_configuration/_help.py index d30d5f81ea5..63932e22f65 100644 --- a/src/k8s-configuration/azext_k8s_configuration/_help.py +++ b/src/k8s-configuration/azext_k8s_configuration/_help.py @@ -85,6 +85,13 @@ --kind azblob --url https://mystorageaccount.blob.core.windows.net \\ --container-name my-container --kustomization name=my-kustomization \\ --account-key my-account-key + - name: Create a Kubernetes v2 Flux Configuration with OCI Source Kind + text: |- + az k8s-configuration flux create --resource-group my-resource-group \\ + --cluster-name mycluster --cluster-type connectedClusters \\ + --name myconfig --scope cluster --namespace my-namespace \\ + --kind oci --url oci://ghcr.io/owner/repo/manifests/podinfo \\ + --kustomization name=my-kustomization --use-workload-identity """ helps[ @@ -109,6 +116,11 @@ az k8s-configuration flux update --resource-group my-resource-group \\ --cluster-name mycluster --cluster-type connectedClusters --name myconfig \\ --container-name other-container + - name: Update a Flux v2 Kubernetes configuration with OCI Source Kind to use connect insecurely + text: |- + az k8s-configuration flux update --resource-group my-resource-group \\ + --cluster-name mycluster --cluster-type connectedClusters --name myconfig \\ + --oci-insecure """ helps[ diff --git a/src/k8s-configuration/azext_k8s_configuration/_params.py b/src/k8s-configuration/azext_k8s_configuration/_params.py index df822dd9524..cbedd2018fc 100644 --- a/src/k8s-configuration/azext_k8s_configuration/_params.py +++ b/src/k8s-configuration/azext_k8s_configuration/_params.py @@ -19,6 +19,7 @@ from .action import ( KustomizationAddAction, + VerifyConfigAction ) from . import consts @@ -65,7 +66,7 @@ def load_arguments(self, _): ) c.argument( "kind", - arg_type=get_enum_type([consts.GIT, consts.BUCKET, consts.AZBLOB]), + arg_type=get_enum_type([consts.GIT, consts.BUCKET, consts.AZBLOB, consts.OCI]), help="Source kind to reconcile", ) c.argument( @@ -86,13 +87,13 @@ def load_arguments(self, _): ) c.argument( "tag", - arg_group="Git Repo Ref", - help="Tag within the git source to reconcile with the cluster", + arg_group="Git Repo Ref / OCI Repo Ref", + help="Tag within the git or OCI source to reconcile with the cluster", ) c.argument( "semver", - arg_group="Git Repo Ref", - help="Semver range within the git source to reconcile with the cluster", + arg_group="Git Repo Ref / OCI Repo Ref", + help="Semver range within the git or OCI source to reconcile with the cluster", ) c.argument( "commit", @@ -238,6 +239,76 @@ def load_arguments(self, _): options_list=["--mi-client-id", "--managed-identity-client-id"], help="The client ID of the managed identity for authentication with Azure Blob", ) + c.argument( + "digest", + arg_group="OCI Repo Ref", + help="Digest of the OCI artifact to reconcile with the cluster", + ) + c.argument( + "oci_media_type", + arg_group="OCI Repo Ref", + options_list=["--oci-media-type", "--oci-layer-selector-media-type"], + help="OCI artifact layer media type to select for extraction or copy.", + ) + c.argument( + "oci_operation", + arg_group="OCI Repo Ref", + arg_type=get_enum_type(["extract", "copy"]), + options_list=["--oci-operation", "--oci-layer-selector-operation"], + help="Operation to perform on the selected OCI artifact layer: 'extract' to extract the layer, 'copy' to copy the tarball as-is (default: extract)", + ) + c.argument( + "tls_ca_certificate", + arg_group="OCI Repository Auth", + help="Base64-encoded CA certificate for TLS communication with OCI repository", + ) + c.argument( + "tls_client_certificate", + arg_group="OCI Repository Auth", + help="Base64-encoded client certificate for TLS authentication with OCI repository", + ) + c.argument( + "tls_private_key", + arg_group="OCI Repository Auth", + help="Base64-encoded private key for TLS authentication with OCI repository", + ) + c.argument( + "service_account_name", + arg_group="OCI Repository Auth", + help="Name of the Kubernetes service account to use for accessing the OCI repository", + ) + c.argument( + "use_workload_identity", + arg_group="OCI Repository Auth", + arg_type=get_three_state_flag(), + help="Use workload identity for authentication with OCI repository", + ) + c.argument( + "oci_insecure", + arg_type=get_three_state_flag(), + help="Allow connecting to an insecure (HTTP) OCI container registry.", + ) + c.argument( + "verification_provider", + action=VerifyConfigAction, + arg_group="OCI Repository Auth", + help="Provider used for OCI verification." + ) + c.argument( + "match_oidc_identity", + action=VerifyConfigAction, + arg_group="OCI Repository Auth", + nargs="+", + help="List of OIDC identities to match for verification of OCI artifacts. Each entry should be a JSON string with 'issuer' and 'subject' fields." + ) + c.argument( + "verification_config", + action=VerifyConfigAction, + arg_group="OCI Repository Auth", + nargs="+", + help="An object containing trusted public keys of trusted authors for OCI artifacts." + ) + with self.argument_context("k8s-configuration flux update") as c: c.argument( diff --git a/src/k8s-configuration/azext_k8s_configuration/action.py b/src/k8s-configuration/azext_k8s_configuration/action.py index 5fce0fdd042..74ec56f9f93 100644 --- a/src/k8s-configuration/azext_k8s_configuration/action.py +++ b/src/k8s-configuration/azext_k8s_configuration/action.py @@ -5,6 +5,7 @@ # pylint: disable=protected-access import argparse +import json from azure.cli.core.azclierror import InvalidArgumentValueError from .vendored_sdks.v2024_04_01_preview.models import ( KustomizationDefinition, @@ -75,3 +76,41 @@ def __call__(self, parser, namespace, values, option_string=None): ), option_string, ) + + +class VerifyConfigAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + # Handle verification_provider (simple string) + if self.dest == "verification_provider": + setattr(namespace, self.dest, values) + return + + # Handle match_oidc_identity (list of JSON strings) + if self.dest == "match_oidc_identity": + identities = [] + for entry in values: + try: + obj = json.loads(entry) + if not isinstance(obj, dict) or "issuer" not in obj or "subject" not in obj: + raise ValueError() + identities.append({"issuer": obj["issuer"], "subject": obj["subject"]}) + except Exception: + raise InvalidArgumentValueError( + "Each entry for --match-oidc-identity must be a JSON string with 'issuer' and 'subject' fields." + ) + setattr(namespace, self.dest, identities) + return + + # Handle verification_config (list of key=value) + if self.dest == "verification_config": + config = {} + for item in values: + try: + key, value = item.split("=", 1) + config[key] = value + except Exception: + raise InvalidArgumentValueError( + "Each entry for --verification-config must be in key=value format." + ) + setattr(namespace, self.dest, config) + return \ No newline at end of file diff --git a/src/k8s-configuration/azext_k8s_configuration/consts.py b/src/k8s-configuration/azext_k8s_configuration/consts.py index bb22a1e649f..478a404e696 100644 --- a/src/k8s-configuration/azext_k8s_configuration/consts.py +++ b/src/k8s-configuration/azext_k8s_configuration/consts.py @@ -255,6 +255,28 @@ "mi_client_id", } +OCI_REPO_REQUIRED_PARAMS = {"url"} +OCI_REPO_VALID_PARAMS = { + "url", + "tag", + "semver", + "digest", + "sync_interval", + "timeout", + "local_auth_ref", + "oci_media_type", + "oci_operation", + "tls_ca_certificate", + "tls_client_certificate", + "tls_private_key", + "service_account_name", + "use_workload_identity", + "oci_insecure", + "verification_provider", + "match_oidc_identity", + "verification_config", +} + DEPENDENCY_KEYS = ["dependencies", "depends_on", "dependsOn", "depends"] SYNC_INTERVAL_KEYS = ["interval", "sync_interval", "syncInterval"] RETRY_INTERVAL_KEYS = ["retryInterval", "retry_interval"] @@ -266,6 +288,7 @@ VALID_GIT_URL_REGEX = r"^(((http|https|ssh)://)|(git@))" VALID_BUCKET_URL_REGEX = r"^(((http|https)://))" VALID_AZUREBLOB_URL_REGEX = r"^(((http|https)://))" +VALID_OCI_URL_REGEX = r"^oci://.*$" VALID_KUBERNETES_DNS_SUBDOMAIN_NAME_REGEX = r"^[a-z0-9]([\.\-a-z0-9]*[a-z0-9])?$" VALID_KUBERNETES_DNS_NAME_REGEX = r"^[a-z0-9]([\-a-z0-9]*[a-z0-9])?$" @@ -274,8 +297,10 @@ BUCKET = "bucket" BUCKET_CAPS = "Bucket" AZBLOB = "azblob" +OCI = "oci" AZURE_BLOB = "AzureBlob" GIT_REPOSITORY = "GitRepository" +OCI_REPOSITORY = "OCIRepository" CONNECTED_CLUSTER_TYPE = "connectedclusters" MANAGED_CLUSTER_TYPE = "managedclusters" diff --git a/src/k8s-configuration/azext_k8s_configuration/providers/FluxConfigurationProvider.py b/src/k8s-configuration/azext_k8s_configuration/providers/FluxConfigurationProvider.py index 08264b88114..d744448d3dd 100644 --- a/src/k8s-configuration/azext_k8s_configuration/providers/FluxConfigurationProvider.py +++ b/src/k8s-configuration/azext_k8s_configuration/providers/FluxConfigurationProvider.py @@ -45,6 +45,7 @@ validate_duration, validate_private_key, validate_url_with_params, + validate_oci_url, ) from .. import consts from ..vendored_sdks.v2024_11_01.models import ( @@ -62,6 +63,13 @@ KustomizationDefinition, KustomizationPatchDefinition, SourceKindType, + OCIRepositoryDefinition, + OCIRepositoryPatchDefinition, + OCIRepositoryRefDefinition, + MatchOidcIdentityDefinition, + LayerSelectorDefinition, + VerifyDefinition, + TlsConfigDefinition ) from ..vendored_sdks.v2022_07_01.models import Extension, Identity @@ -167,6 +175,18 @@ def create_config( sas_token=None, mi_client_id=None, cluster_resource_provider=None, + digest=None, + oci_media_type=None, + oci_operation=None, + tls_ca_certificate=None, + tls_client_certificate=None, + tls_private_key=None, + service_account_name=None, + use_workload_identity=None, + oci_insecure=None, + verification_provider=None, + match_oidc_identity=None, + verification_config=None, ): # Get Resource Provider to call @@ -206,6 +226,18 @@ def create_config( sp_client_secret=sp_client_secret, sp_client_cert_send_chain=sp_client_cert_send_chain, mi_client_id=mi_client_id, + digest=digest, + oci_media_type=oci_media_type, + oci_operation=oci_operation, + tls_ca_certificate=tls_ca_certificate, + tls_client_certificate=tls_client_certificate, + tls_private_key=tls_private_key, + service_account_name=service_account_name, + use_workload_identity=use_workload_identity, + oci_insecure=oci_insecure, + verification_provider=verification_provider, + match_oidc_identity=match_oidc_identity, + verification_config=verification_config, ) # This update func is a generated update function that modifies @@ -303,6 +335,18 @@ def update_config( sas_token=None, mi_client_id=None, cluster_resource_provider=None, + digest=None, + oci_media_type=None, + oci_operation=None, + tls_ca_certificate=None, + tls_client_certificate=None, + tls_private_key=None, + service_account_name=None, + use_workload_identity=None, + oci_insecure=None, + verification_provider=None, + match_oidc_identity=None, + verification_config=None, ): # Get Resource Provider to call @@ -347,6 +391,18 @@ def update_config( sp_client_secret=sp_client_secret, sp_client_cert_send_chain=sp_client_cert_send_chain, mi_client_id=mi_client_id, + digest=digest, + oci_media_type=oci_media_type, + oci_operation=oci_operation, + tls_ca_certificate=tls_ca_certificate, + tls_client_certificate=tls_client_certificate, + tls_private_key=tls_private_key, + service_account_name=service_account_name, + use_workload_identity=use_workload_identity, + oci_insecure=oci_insecure, + verification_provider=verification_provider, + match_oidc_identity=match_oidc_identity, + verification_config=verification_config, ) # This update func is a generated update function that modifies @@ -827,6 +883,8 @@ def source_kind_generator_factory(kind=consts.GIT, **kwargs): return GitRepositoryGenerator(**kwargs) if kind == consts.BUCKET: return BucketGenerator(**kwargs) + if kind == consts.OCI: + return OCIRepositoryGenerator(**kwargs) return AzureBlobGenerator(**kwargs) @@ -835,6 +893,8 @@ def convert_to_cli_source_kind(rp_source_kind): return consts.GIT elif rp_source_kind == consts.BUCKET_CAPS: return consts.BUCKET + elif rp_source_kind == consts.OCI_REPOSITORY: + return consts.OCI return consts.AZBLOB @@ -1196,6 +1256,160 @@ def azure_blob_patch_updater(config): return azure_blob_patch_updater +class OCIRepositoryGenerator(SourceKindGenerator): + def __init__(self, **kwargs): + # Common Pre-Validation + super().__init__( + consts.OCI, consts.OCI_REPO_REQUIRED_PARAMS, consts.OCI_REPO_VALID_PARAMS + ) + super().validate_params(**kwargs) + + # Pre-Validations + validate_duration("--timeout", kwargs.get("timeout")) + validate_duration("--sync-interval", kwargs.get("sync_interval")) + + self.kwargs = kwargs + self.url = kwargs.get("url") + self.timeout = kwargs.get("timeout") + self.sync_interval = kwargs.get("sync_interval") + self.local_auth_ref = kwargs.get("local_auth_ref") + self.service_account_name = kwargs.get("service_account_name") + self.use_workload_identity = kwargs.get("use_workload_identity") + self.oci_insecure = kwargs.get("oci_insecure") + + self.layer_selector = None + if any( + [ + kwargs.get("oci_media_type"), + kwargs.get("oci_operation") + ] + ): + self.layer_selector = LayerSelectorDefinition( + media_type=kwargs.get("oci_media_type"), + operation=kwargs.get("oci_operation"), + ) + + self.repository_ref = None + if any( + [ + kwargs.get("tag"), + kwargs.get("semver"), + kwargs.get("digest"), + ] + ): + self.repository_ref = OCIRepositoryRefDefinition( + tag=kwargs.get("tag"), + semver=kwargs.get("semver"), + digest=kwargs.get("digest"), + ) + + self.tls_config = None + if any( + [ + kwargs.get("tls_ca_certificate"), + kwargs.get("tls_client_certificate"), + kwargs.get("tls_private_key"), + ] + ): + self.tls_config = TlsConfigDefinition( + ca_certificate=kwargs.get("tls_ca_certificate"), + client_certificate=kwargs.get("tls_client_certificate"), + private_key=kwargs.get("tls_private_key"), + ) + + self.match_oidc_identities = None + if kwargs.get("match_oidc_identity"): + self.match_oidc_identities = [ + MatchOidcIdentityDefinition( + issuer=identity["issuer"], + subject=identity["subject"] + ) + for identity in kwargs.get("match_oidc_identity", []) + ] + + self.verification_config = None + if kwargs.get("verification_config"): + self.verification_config = { + key: value for key, value in kwargs.get("verification_config", {}).items() + } + + self.verify = None + if any( + [ + kwargs.get("verification_provider"), + self.match_oidc_identities, + self.verification_config, + ] + ): + self.verify = VerifyDefinition( + provider=kwargs.get("verification_provider"), + match_oidc_identity=self.match_oidc_identities, + verification_config=self.verification_config, + ) + + def validate(self): + super().validate_required_params(**self.kwargs) + validate_oci_url(self.url) + + def generate_update_func(self): + """ + generate_update_func(self) generates a function to add a OCIRepository + object to the flux configuration for the PUT case + """ + self.validate() + + def oci_repo_updater(config): + config.oci_repository = OCIRepositoryDefinition( + url=self.url, + timeout_in_seconds=parse_duration(self.timeout), + sync_interval_in_seconds=parse_duration(self.sync_interval), + local_auth_ref=self.local_auth_ref, + service_account_name=self.service_account_name, + use_workload_identity=self.use_workload_identity, + layer_selector=self.layer_selector, + repository_ref=self.repository_ref, + tls_config=self.tls_config, + verify=self.verify, + insecure=self.oci_insecure, + ) + config.source_kind = SourceKindType.OCI_REPOSITORY + return config + + return oci_repo_updater + + def generate_patch_update_func(self, swapped_kind): + """ + generate_patch_update_func(self) generates a function update the OCIRepository + object to the flux configuration for the PATCH case. + If the source kind has been changed, we also set the GitRepository, Bucket And AzureBlob to null + """ + + def oci_repo_patch_updater(config): + if any(kwarg is not None for kwarg in self.kwargs.values()): + config.oci_repository = OCIRepositoryPatchDefinition( + url=self.url, + timeout_in_seconds=parse_duration(self.timeout), + sync_interval_in_seconds=parse_duration(self.sync_interval), + local_auth_ref=self.local_auth_ref, + service_account_name=self.service_account_name, + use_workload_identity=self.use_workload_identity, + layer_selector=self.layer_selector, + repository_ref=self.repository_ref, + tls_config=self.tls_config, + verify=self.verify, + insecure=self.oci_insecure, + ) + if swapped_kind: + self.validate() + config.source_kind = SourceKindType.OCI_REPOSITORY + config.azure_blob = AzureBlobPatchDefinition() + config.bucket = BucketPatchDefinition() + config.git_repository = GitRepositoryPatchDefinition() + return config + + return oci_repo_patch_updater + + def get_protected_settings( ssh_private_key, ssh_private_key_file, https_key, bucket_secret_key ): diff --git a/src/k8s-configuration/azext_k8s_configuration/validators.py b/src/k8s-configuration/azext_k8s_configuration/validators.py index bed3b5de216..93c39390c7c 100644 --- a/src/k8s-configuration/azext_k8s_configuration/validators.py +++ b/src/k8s-configuration/azext_k8s_configuration/validators.py @@ -161,6 +161,12 @@ def validate_bucket_url(url: str): raise InvalidArgumentValueError( consts.INVALID_URL_ERROR, consts.INVALID_URL_HELP ) + +def validate_oci_url(url: str): + if not re.match(consts.VALID_OCI_URL_REGEX, url): + raise InvalidArgumentValueError( + consts.INVALID_URL_ERROR, consts.INVALID_URL_HELP + ) # Helper diff --git a/src/k8s-configuration/setup.py b/src/k8s-configuration/setup.py index 53e7255ca5d..0b104bda1b7 100644 --- a/src/k8s-configuration/setup.py +++ b/src/k8s-configuration/setup.py @@ -16,7 +16,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = "2.2.0" +VERSION = "2.3.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers