From ee95a3bbda0a18f4f885f23ec4d064d8fd8c4018 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Fri, 25 Sep 2026 18:16:50 +0200 Subject: [PATCH 01/10] test: cover teamWithRobot The teamWithRobot path - Team, Robot, Token, membership and the group admin binding - had no test at all, and it is what solutions-gitops-prod's ci and production-upbound-deploy Environments run. Two cases: a new Team whose Upbound ID the binding reads off the observed Team, and an adopted Team by external name inside a group someone else manages. Written and passing against the KCL function, so that its output is part of the baseline the Python port is compared against. --- functions/environments/argo/secret.k | 59 --- functions/environments/argo/secretSchema.k | 29 -- functions/environments/aws/crossplaneRole.k | 118 ----- functions/environments/aws/providerConfig.k | 33 -- functions/environments/bootstrapSecretSync.k | 88 ---- functions/environments/kcl.mod | 7 - functions/environments/kcl.mod.lock | 13 - functions/environments/main.k | 455 ------------------ functions/environments/pKubernetesHelper.k | 234 --------- functions/environments/teamRobot.k | 200 -------- functions/environments/utils/metadata.k | 12 - functions/environments/utils/names.k | 24 - functions/environments/utils/policy.k | 16 - functions/environments/utils/secret.k | 29 -- functions/environments/utils/secretSchema.k | 24 - functions/sharedawssecret/kcl.mod | 7 - functions/sharedawssecret/kcl.mod.lock | 13 - functions/sharedawssecret/main.k | 330 ------------- functions/sharedawssecret/model | 1 - functions/upboundreposet/main.k | 103 ---- functions/upboundreposet/model | 1 - functions/upboundreposet/utils/metadata.k | 12 - .../test-environment-team-with-robot}/kcl.mod | 2 +- .../kcl.mod.lock | 0 tests/test-environment-team-with-robot/main.k | 158 ++++++ .../test-environment-team-with-robot}/model | 0 26 files changed, 159 insertions(+), 1809 deletions(-) delete mode 100644 functions/environments/argo/secret.k delete mode 100644 functions/environments/argo/secretSchema.k delete mode 100644 functions/environments/aws/crossplaneRole.k delete mode 100644 functions/environments/aws/providerConfig.k delete mode 100644 functions/environments/bootstrapSecretSync.k delete mode 100644 functions/environments/kcl.mod delete mode 100644 functions/environments/kcl.mod.lock delete mode 100644 functions/environments/main.k delete mode 100644 functions/environments/pKubernetesHelper.k delete mode 100644 functions/environments/teamRobot.k delete mode 100644 functions/environments/utils/metadata.k delete mode 100644 functions/environments/utils/names.k delete mode 100644 functions/environments/utils/policy.k delete mode 100644 functions/environments/utils/secret.k delete mode 100644 functions/environments/utils/secretSchema.k delete mode 100644 functions/sharedawssecret/kcl.mod delete mode 100644 functions/sharedawssecret/kcl.mod.lock delete mode 100644 functions/sharedawssecret/main.k delete mode 120000 functions/sharedawssecret/model delete mode 100644 functions/upboundreposet/main.k delete mode 120000 functions/upboundreposet/model delete mode 100644 functions/upboundreposet/utils/metadata.k rename {functions/upboundreposet => tests/test-environment-team-with-robot}/kcl.mod (63%) rename {functions/upboundreposet => tests/test-environment-team-with-robot}/kcl.mod.lock (100%) create mode 100644 tests/test-environment-team-with-robot/main.k rename {functions/environments => tests/test-environment-team-with-robot}/model (100%) diff --git a/functions/environments/argo/secret.k b/functions/environments/argo/secret.k deleted file mode 100644 index 74677f4..0000000 --- a/functions/environments/argo/secret.k +++ /dev/null @@ -1,59 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.k8s.api.core.v1 as v1 -import utils -import json -import base64 - -argoServerSecret = lambda config: ArgoServerSecret -> any { - """ - Creates a ArgoCD Server Secret. - """ - [ - kubernetesm.Object{ - metadata = utils._metadata("ctp-argocd") | { - name = "{}-ctp-argocd-secret".format(config.ctp) - } - spec = { - forProvider = { - manifest = v1.Secret{ - metadata: { - name: "{}-{}".format(config.group,config.ctp) - namespace: "argocd" - labels: { - "argocd.argoproj.io/secret-type": "cluster" - } - } - type: "Opaque" - # base64 `data`, not `stringData` - see pKubernetesHelper.k for why - # provider-kubernetes cannot observe a stringData-owned field. - data: { - name: base64.encode("{}-{}".format(config.group,config.ctp)) - server: base64.encode("https://{}/apis/spaces.upbound.io/v1beta1/namespaces/{}/controlplanes/{}/k8s".format(config.spaceHost, config.group, config.ctp)) - config: base64.encode(json.encode({ - execProviderConfig: { - apiVersion: "client.authentication.k8s.io/v1" - command: "up" - args: [ - "org" - "token" - ] - env: { - "ORGANIZATION": config.org - "UP_TOKEN": config.accessToken - } - } - tlsClientConfig: { - insecure: False - caData: config.serverCaData - } - })) - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = config.providerConfigName - } - } - } -]} diff --git a/functions/environments/argo/secretSchema.k b/functions/environments/argo/secretSchema.k deleted file mode 100644 index a70c080..0000000 --- a/functions/environments/argo/secretSchema.k +++ /dev/null @@ -1,29 +0,0 @@ -schema ArgoServerSecret: - r""" - ArgoServerSecret represents an Input for an Argo Server Secret. - - Attributes - ---------- - spaceHost : str, required - The Upbound Spaces host (e.g. spaces.upbound.io) - org : str, required - The Upbound organization name - group : str, required - The group name for group-level access - ctp : str, required - The control plane name for control plane-level access - providerConfigName : str, required - The Name of the provider config to reference - accessToken : str, required - The AccessToken (PersonalAccessToken or RobotToken) - serverCaData : str, required - The ServerCaData as base64 encoded string - """ - - spaceHost: str - org: str - group: str - ctp: str - providerConfigName: str - accessToken: str - serverCaData: str diff --git a/functions/environments/aws/crossplaneRole.k b/functions/environments/aws/crossplaneRole.k deleted file mode 100644 index 0d5799d..0000000 --- a/functions/environments/aws/crossplaneRole.k +++ /dev/null @@ -1,118 +0,0 @@ -""" -AWS IAM Role Configuration Module - -This module handles the creation of IAM resources needed for Crossplane to connect -to and manage AWS services. It creates: - -1. An admin role with appropriate permissions -2. Role policy attachments for necessary access -3. OIDC provider configuration for federated authentication between Upbound and AWS -""" - -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import utils - -schema AWSXPRoleInput: - """ - Input parameters for configuring AWS IAM roles and permissions - """ - accountId: str # AWS account ID - deletionPolicy: str # Deletion policy for resources (Delete or Orphan) - envName: str # Environment name - ctpName: str # Control plane name - namePrefix: str # Prefix for AWS resource names - oidcProviderArn?: str # Optional: Existing OIDC provider ARN (if any) - region: str # AWS region - upboundOrg: str # Upbound organization name - - -getXPRoleItems = lambda awsParams: AWSXPRoleInput -> [any] { - """ - Creates IAM roles and permissions required for AWS provider in Crossplane. - - This function generates: - 1. An administrator IAM role with a trust policy allowing Upbound's OIDC provider - 2. A role policy attachment granting the role administrator access - 3. An OIDC provider configuration for federated authentication - - The role is configured to trust the specific Upbound control plane's - provider-aws service account via OIDC JWT token validation. - """ - [ - iamv1beta1.Role { - metadata = utils._metadata("iamAdminRole") | { - # metadata.name is the role's AWS name, and IAM caps it at 64 characters. - name = utils._truncateIamName("{}-admin".format(awsParams.namePrefix), "-admin") - } - spec = { - managementPolicies = utils._managementPolicies(awsParams.deletionPolicy) - forProvider = { - assumeRolePolicy = """{{ - "Version": "2012-10-17", - "Statement": [ - {{ - "Effect": "Allow", - "Principal": {{ - "Federated": "arn:aws:iam::{}:oidc-provider/proidc.upbound.io" - }}, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": {{ - "StringEquals": {{ - "proidc.upbound.io:sub": "mcp:{}/{}:provider:provider-aws", - "proidc.upbound.io:aud": "sts.amazonaws.com" - }} - }} - }} - ] -}}""".format(awsParams.accountId, awsParams.upboundOrg, awsParams.ctpName) - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsParams.envName - } - } - } - iamv1beta1.RolePolicyAttachment { - metadata = utils._metadata("iamAdminRoleAttach") | { - name = utils._truncateIamName("{}-admin".format(awsParams.namePrefix), "-admin") - } - spec = { - managementPolicies = utils._managementPolicies(awsParams.deletionPolicy) - forProvider = { - roleSelector = { - matchControllerRef = True - } - policyArn = "arn:aws:iam::aws:policy/AdministratorAccess" - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsParams.envName - } - } - } - iamv1beta1.OpenIDConnectProvider { - metadata = utils._metadata("upboundOidcProvider") | { - name = "{}-oidc-provider".format(awsParams.namePrefix) - annotations = { - if awsParams.oidcProviderArn: - 'crossplane.io/external-name' = awsParams.oidcProviderArn - } - } - spec = { - # Adoption implies orphaning, whatever the XR-level deletionPolicy says. When - # oidcProviderArn is supplied this composition did not create the provider, and - # AWS allows only ONE OIDC provider per URL per account - proidc.upbound.io is - # shared by every Upbound integration in that account. Deleting it on teardown - # would break all of them, so an adopted provider is never deleted. - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] if awsParams.oidcProviderArn else utils._managementPolicies(awsParams.deletionPolicy) - forProvider = { - clientIdList = ["sts.amazonaws.com"] - url = "https://proidc.upbound.io" - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsParams.envName - } - } - } -]} diff --git a/functions/environments/aws/providerConfig.k b/functions/environments/aws/providerConfig.k deleted file mode 100644 index e3d4a1c..0000000 --- a/functions/environments/aws/providerConfig.k +++ /dev/null @@ -1,33 +0,0 @@ -import models.io.upbound.awsm.v1beta1 as awsv1beta1 -import utils - -schema AWSProviderConfigInput: - awsCredsSecretRef?: awsv1beta1.AwsmUpboundIoV1beta1ProviderConfigSpecCredentialsSecretRef - awsRoleArn?: str - envName: str - -getProviderConfig = lambda awsParams: AWSProviderConfigInput -> [any] {[ - awsv1beta1.ProviderConfig{ - metadata = utils._metadata("awsProviderConfig") | { - name = awsParams.envName - annotations = { - "krm.kcl.dev/ready" = "True" - } - } - spec = { - if awsParams.awsRoleArn: - credentials = { - source = "Upbound" - upbound = { - webIdentity = { - roleARN = awsParams.awsRoleArn - } - } - } - else: - credentials = { - source = "Secret" - secretRef = awsParams.awsCredsSecretRef - } - } -}]} diff --git a/functions/environments/bootstrapSecretSync.k b/functions/environments/bootstrapSecretSync.k deleted file mode 100644 index 78b9c1f..0000000 --- a/functions/environments/bootstrapSecretSync.k +++ /dev/null @@ -1,88 +0,0 @@ -""" -Provides functionality to sync Kubernetes secrets between the bootstrap control plane and environments. - -This module enables copying secret data from the bootstrap control plane to destination -environments through Crossplane's Kubernetes provider. - -Schemas: -- SecretRef: Defines a reference to a Kubernetes secret with name and namespace -- SecretSyncInput: Configuration for secret synchronization including source and destination -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import utils - -schema SecretRef: - """ - Reference to a Kubernetes secret. - - Attributes: - name: The name of the secret - namespace: The namespace where the secret is located - """ - name: str - namespace: str - -schema SecretSyncInput: - """ - Configuration for secret synchronization. - - Attributes: - sourceRef: Reference to the source secret in bootstrap control plane - destRef: Reference to the destination secret in the environment - """ - sourceRef: SecretRef - destRef: SecretRef - providerConfigName: str - -syncedSecrets = lambda input: [SecretSyncInput] -> any { - """ - Creates Crossplane Kubernetes provider objects to sync secrets from bootstrap to environments. - - Uses the Kubernetes provider to copy secret data from bootstrap control plane to environment, - replicating the data field while creating a new secret with the specified name - and namespace in the destination environment. - - Args: - input: List of SecretSyncInput configurations defining the secrets to sync - - Returns: - List of Crossplane Kubernetes provider Object resources - """ - [ - # Copy secret from bootstrap-ctp into destination-ctp - kubernetesm.Object { - metadata = utils._metadata("{}-{}-to-{}-{}-syncedSecret".format( - secret.sourceRef.namespace, secret.sourceRef.name, - secret.destRef.namespace, secret.destRef.name)) - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = secret.destRef.name - namespace = secret.destRef.namespace - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = secret.providerConfigName - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - kind = "Secret" - name = secret.sourceRef.name - namespace = secret.sourceRef.namespace - fieldPath = "data" - } - toFieldPath = "data" - } - ] - } - } for secret in input - ] -} diff --git a/functions/environments/kcl.mod b/functions/environments/kcl.mod deleted file mode 100644 index 96401d6..0000000 --- a/functions/environments/kcl.mod +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "environments" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } -spaces = { oci = "oci://xpkg.upbound.io/upbound/kcl-modules_spaces", tag = "1.12.0", package = "kcl-modules_spaces", version = "1.12.0" } diff --git a/functions/environments/kcl.mod.lock b/functions/environments/kcl.mod.lock deleted file mode 100644 index 1a5de22..0000000 --- a/functions/environments/kcl.mod.lock +++ /dev/null @@ -1,13 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" - [dependencies.spaces] - name = "spaces" - full_name = "kcl-modules_spaces_1.12.0" - version = "1.12.0" - sum = "9tKyGSjYJoIM5QHiNZUKLSb9/jMPMALM1ktgtdsdUyA=" - reg = "xpkg.upbound.io" - repo = "upbound/kcl-modules_spaces" - oci_tag = "1.12.0" diff --git a/functions/environments/main.k b/functions/environments/main.k deleted file mode 100644 index 4f13d53..0000000 --- a/functions/environments/main.k +++ /dev/null @@ -1,455 +0,0 @@ -""" -This KCL function implements the core composition logic for Environment resources. -It automates the creation and management of Upbound Spaces environments with -integrated AWS cloud resources. The function handles: -- Environment initialization using bootstrap credentials -- Control plane creation in Upbound Spaces -- Server Secret for Argo -- Kubernetes provider configurations for various scopes (space/group/control plane) -- AWS IAM role and policy setup for cross-service authentication -- Secret management between AWS Secrets Manager and Upbound Spaces -""" - -import models.io.upbound.sa.v1 as sav1 -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm - -# sytem modules -import base64 -import yaml -import regex - -# our modules -import aws.providerConfig as awsProviderConfig -import aws.crossplaneRole as awsXPRole -import pKubernetesHelper -import teamRobot -import bootstrapSecretSync -import argo -import utils - -oxr = option("params").oxr # observed composite resource -ocds = option("params").ocds # observed composed resources -dxr = option("params").dxr # desired composite resource -dcds = option("params").dcds # desired composed resources - -oxrMeta = sav1.Environment.metadata{**oxr.metadata} -# Take only `parameters` rather than spreading the whole observed spec. A namespaced -# (v2) XR also carries spec.crossplane, whose resourceRefs Crossplane populates with -# plain dicts once resources exist - spreading those into the typed schema fails with -# "expect [...SpecCrossplaneResourceRefsItems0], got list". -oxrSpec = sav1.Environment.spec{parameters = oxr.spec.parameters} - -# ========================================================================= -# Initial Kubeconfig Processing -# ========================================================================= -# First try to read the configuration from initial kubeconfig -# Because the kubeconfig is coming from an external process and we are creating resources -# conditionally based on the fact if these values are set, we first transfer them to the -# status of the XR later in order to prevent losing resources in case the kubeconfig is deleted -# -# This initialization flow: -# 1. Extracts key metadata from bootstrap kubeconfig (org, group, control plane name, etc) -# 2. Stores these values in Environment status for future reconciliations -# 3. Creates resources only after the metadata is properly established - -_initKubeconfigServerUrl = Undefined -_initKubeconfigServerCaData = Undefined -_upboundSpaceHostFromKubeconfig = Undefined -_upboundBootstrapGroupFromKubeconfig = Undefined -_upboundBootstrapCtpFromKubeconfig = Undefined -_upboundOrgFromKubeconfig = Undefined - -# Parse configuration from initial kubeconfig and the contained server-url -# we will then pass the config to the `status.upbound` field of the XR -_initialKubeconfig = ocds.observedCtpKubeconfig?.Resource?.status?.atProvider?.manifest?.data?.kubeconfig -if _initialKubeconfig: - _initKubeconfigServerUrl = yaml.decode(base64.decode(_initialKubeconfig)).clusters[0]?.cluster?.server - _initKubeconfigServerCaData = yaml.decode(base64.decode(_initialKubeconfig)).clusters[0]?.cluster?["certificate-authority-data"] - _upboundSpaceHostFromKubeconfig = regex.replace(_initKubeconfigServerUrl, "https:\/\/([.\w-]+)(?:\/[.\w-]+){8}", "$1") - _upboundBootstrapGroupFromKubeconfig = regex.replace(_initKubeconfigServerUrl, "https:\/(?:\/[.\w-]+){5}\/([.\w-]+)(?:\/[.\w-]+){3}", "$1") - _upboundBootstrapCtpFromKubeconfig = regex.replace(_initKubeconfigServerUrl, "https:\/(?:\/[.\w-]+){7}\/([.\w-]+)(?:\/[.\w-]+)", "$1") - _upboundOrgFromKubeconfig = yaml.decode(base64.decode(_initialKubeconfig)).contexts[0].context.extensions[0].extension.spec.cloud.organization - -# Readiness-check for proceeding with read from status -_oxrStatusUpbound = oxr.status?.upbound -# Truthiness, not `!= Undefined`. On a fresh XR status.upbound is None, and in KCL both -# `None != Undefined` and `None?.field != Undefined` evaluate TRUE - so an Undefined-based -# guard lets an empty status through and the pipeline dies further down on the first -# `.bootstrapGroup`. These are all non-empty strings once set, so a plain truthy test is -# the only form that is correct for None, Undefined and "" alike. -_initReady = _oxrStatusUpbound?.org and \ - _oxrStatusUpbound?.bootstrapCtp and \ - _oxrStatusUpbound?.bootstrapGroup and \ - _oxrStatusUpbound?.spaceHost - -_initItems = [ - sav1.Environment{ - spec = {} - status = { - upbound = { - bootstrapCtp = _upboundBootstrapCtpFromKubeconfig - bootstrapGroup = _upboundBootstrapGroupFromKubeconfig - org = _upboundOrgFromKubeconfig - spaceHost = _upboundSpaceHostFromKubeconfig - } - } - } - - # observed kubeconfig for bootstrap-ctp, will be used to derive settings - # like upbound org, bootstrap-group, bootstrap-controlplane and space host - kubernetesm.Object{ - metadata = { - name = "{}-bootstrap-ctp-kubeconfig-observed".format(oxrMeta.name) - annotations = { - # Set explicitly rather than merging onto utils._metadata(): `|` REPLACES the - # annotations map instead of merging into it, so adding krm.kcl.dev/ready on - # top of it silently drops the composition resource name below - and main.k - # looks this resource up by that name as ocds.observedCtpKubeconfig. Losing it - # makes the lookup return nothing, so status.upbound stays {} and the - # Environment never initialises. - "krm.kcl.dev/composition-resource-name" = "observedCtpKubeconfig" - # Gates the whole XR's readiness on initialisation having finished. - # - # Until the bootstrap kubeconfig is observed and status.upbound populated, - # this observer is the ONLY composed resource - and it goes ready as soon as - # the Secret it watches exists. function-auto-ready would then see "every - # composed resource is ready" and report the Environment as Ready before a - # single group, control plane, ProviderConfig or IAM resource had been - # created. Anything polling Ready to know the environment is up gets a - # false positive for that window. - "krm.kcl.dev/ready" = "True" if _initReady else "False" - } - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = oxrSpec.parameters.upbound.initKubeconfigSecretRef.name - namespace = oxrSpec.parameters.upbound.initKubeconfigSecretRef.namespace - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = oxrSpec.parameters.upbound.initProviderConfigName - } - managementPolicies = ["Observe"] - } - } -] - -_upboundItems = [] -_awsItems = [] - -if _initReady: - # ========================================================================= - # Environment Resource Creation - # ========================================================================= - # Now that we have verified initialization data is present, we can create - # all the resources needed for the environment: - # - Upbound control plane in Spaces - # - Kubernetes provider configurations for access - # - AWS IAM role setup for cross-service authentication - # - Secret stores and external secrets for configuration - - # Every environment consists of a group containing a single controlplane, this is the name of that group. - # - # The namespace is part of it. The XRD is Namespaced, so team-a/prod and team-b/prod are - # both valid - and the group is org-wide, as is everything named after it: the Team, - # Robot and Argo secret, and every AWS name via awsNamePrefix. Built from metadata.name - # alone, those two XRs would share all of it, and with deletionPolicy: Delete deleting - # either would tear down the other's environment. - envGroupName = "{}-{}-{}".format(_oxrStatusUpbound.bootstrapGroup, oxrMeta.namespace, oxrMeta.name) - awsNamePrefix = "{}-{}-{}".format(_oxrStatusUpbound.org, envGroupName, oxrMeta.name) - - _upboundItems = [ - # Main controlplane for the environment - if oxrSpec.parameters.upbound.createCtp: - kubernetesm.Object{ - metadata = utils._metadata("ctp") | { - name = "{}-ctp".format(oxrMeta.name) - } - spec = { - readiness: { - policy: "DeriveFromObject" - } - managementPolicies = utils._managementPolicies(oxrSpec.parameters.deletionPolicy) - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1beta1" - kind = "ControlPlane" - metadata = { - name = oxrMeta.name - namespace = envGroupName - annotations = { - foo = str(oxrSpec) - } - } - spec = { - class = "default" - crossplane = { - autoUpgrade = { - channel = "Rapid" - } - } - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-group".format(envGroupName) - } - } - } - - if oxrSpec.parameters.upbound.createGroup: - kubernetesm.Object{ - metadata = utils._metadata("envGroup") | { - name = envGroupName - } - spec = { - managementPolicies = utils._managementPolicies(oxrSpec.parameters.deletionPolicy) - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Namespace" - metadata = { - name = envGroupName - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-space".format(oxrMeta.name) - } - } - } - ] - if oxrSpec.parameters.upbound.createArgoSecret: - # ========================================================================= - # Argo Server Secret Creation - # ========================================================================= - # - Observe the Access Token (PersonalAccessToken or RobotToken) - # - Create Argo Server Secret when Access Token is available - - _upboundItems += utils.observeSecret(utils.ObserveSecret{ - ctp = oxrMeta.name - name = oxrSpec.parameters.upbound.tokenSecretRef.name - namespace = oxrSpec.parameters.upbound.tokenSecretRef.namespace - providerConfigName = "{}-ctp".format(_oxrStatusUpbound.bootstrapCtp) - resourceName = "observed-access-token" - }) - - _accessToken = base64.decode(ocds["observed-access-token"]?.Resource?.status?.atProvider?.manifest?.data?.token) - if _accessToken: - _upboundItems += argo.argoServerSecret(argo.ArgoServerSecret{ - accessToken: _accessToken - org = _oxrStatusUpbound.org - group = envGroupName - ctp = oxrMeta.name - providerConfigName: "{}-ctp".format(_oxrStatusUpbound.bootstrapCtp) - serverCaData: _initKubeconfigServerCaData - spaceHost = _oxrStatusUpbound.spaceHost - }) - - if oxrSpec.parameters.upbound.createCtp: - _upboundItems += pKubernetesHelper.upboundProviderConfig(pKubernetesHelper.UpboundProviderConfigInput{ - # controlplane level providerconfig for provider-kubernetes - spaceHost = _oxrStatusUpbound.spaceHost - org = _oxrStatusUpbound.org - group = envGroupName - ctp = oxrMeta.name - providerConfigName = "{}-ctp".format(_oxrStatusUpbound.bootstrapCtp) - secretNamespace = oxrMeta.namespace - upboundTokenSecretRef = pKubernetesHelper.UpboundTokenSecretRef{ - name = oxrSpec.parameters.upbound.tokenSecretRef.name - namespace = oxrSpec.parameters.upbound.tokenSecretRef.namespace - key = oxrSpec.parameters.upbound.tokenSecretRef.key - } - }) - - # Not gated on createGroup. Everything the composition places *inside* the environment - # group goes through this ProviderConfig - the ControlPlane above and the SharedAWSSecret - # below both name it - and that is just as true when somebody else created the group. Only - # the group object itself, and the space-level ProviderConfig that creates it, belong - # behind createGroup. - _upboundItems += pKubernetesHelper.upboundProviderConfig(pKubernetesHelper.UpboundProviderConfigInput{ - # environment group level providerconfig for provider-kubernetes - spaceHost = _oxrStatusUpbound.spaceHost - org = _oxrStatusUpbound.org - group = envGroupName - providerConfigName = "{}-ctp".format(_oxrStatusUpbound.bootstrapCtp) - secretNamespace = oxrMeta.namespace - upboundTokenSecretRef = pKubernetesHelper.UpboundTokenSecretRef{ - name = oxrSpec.parameters.upbound.tokenSecretRef.name - namespace = oxrSpec.parameters.upbound.tokenSecretRef.namespace - key = oxrSpec.parameters.upbound.tokenSecretRef.key - } - }) - - if oxrSpec.parameters.upbound.createGroup: - _upboundItems += pKubernetesHelper.upboundProviderConfig(pKubernetesHelper.UpboundProviderConfigInput{ - # space level providerconfig for provider-kubernetes - spaceHost = _oxrStatusUpbound.spaceHost - org = _oxrStatusUpbound.org - prefix = oxrMeta.name - providerConfigName = "{}-ctp".format(_oxrStatusUpbound.bootstrapCtp) - secretNamespace = oxrMeta.namespace - upboundTokenSecretRef = pKubernetesHelper.UpboundTokenSecretRef{ - name = oxrSpec.parameters.upbound.tokenSecretRef.name - namespace = oxrSpec.parameters.upbound.tokenSecretRef.namespace - key = oxrSpec.parameters.upbound.tokenSecretRef.key - } - }) - - # If creation of team with robot is enabled - if oxrSpec.parameters.upbound.teamWithRobot != Undefined: - _upboundItems += teamRobot.teamWithRobot(teamRobot.TeamWithRobotInput{ - group = envGroupName - org = _oxrStatusUpbound.org - secretDestProviderConfigName = "{}-ctp".format(oxrMeta.name) - spaceProviderConfigName = "{}-space".format(oxrMeta.name) - ocds = ocds - teamNameOverride = oxrSpec.parameters.upbound.teamWithRobot.teamNameOverride - teamExternalName = oxrSpec.parameters.upbound.teamWithRobot.teamExternalName - tokenSecretRef = teamRobot.TeamRobotTokenSecretRef{ - name = oxrSpec.parameters.upbound.tokenSecretRef.name - namespace = oxrSpec.parameters.upbound.tokenSecretRef.namespace - key = oxrSpec.parameters.upbound.tokenSecretRef.key - } - createGroupAdminBinding = oxrSpec.parameters.upbound.createGroup - }) - - if oxrSpec.parameters.upbound.secretSync: - _upboundItems += bootstrapSecretSync.syncedSecrets([bootstrapSecretSync.SecretSyncInput{ - sourceRef = { - name = secret.sourceRef.name - namespace = secret.sourceRef.namespace - } - destRef = { - name = secret.destRef.name - namespace = secret.destRef.namespace - } - providerConfigName = "{}-ctp".format(oxrMeta.name) - } for secret in oxrSpec.parameters.upbound.secretSync]) - - # If aws features are enabled - if oxrSpec.parameters.aws != Undefined: - # create providerconfig for aws - _awsItems += awsProviderConfig.getProviderConfig(awsProviderConfig.AWSProviderConfigInput{ - if oxrSpec.parameters.aws.roleArn: - awsRoleArn = oxrSpec.parameters.aws.roleArn - if oxrSpec.parameters.aws.credsSecretRef: - awsCredsSecretRef = { - namespace = oxrSpec.parameters.aws.credsSecretRef.namespace - name = oxrSpec.parameters.aws.credsSecretRef.name - key = "credentials" - } - envName = envGroupName - }) - - # if creation of provider role is enabled - if oxrSpec.parameters.aws.providerRole != Undefined: - _awsItems += awsXPRole.getXPRoleItems(awsXPRole.AWSXPRoleInput{ - accountId = oxrSpec.parameters.aws.accountId - deletionPolicy = oxrSpec.parameters.deletionPolicy - envName = envGroupName - ctpName = oxrMeta.name - namePrefix = "{}-{}-{}".format(_oxrStatusUpbound.org, envGroupName, oxrMeta.name) - oidcProviderArn: oxrSpec.parameters.aws?.providerRole?.oidcProviderArn - region = oxrSpec.parameters.aws.region - upboundOrg = _oxrStatusUpbound.org - }) - - # if creation of shared secret is enabled - if oxrSpec.parameters.aws.sharedSecret != Undefined: - _awsItems += [sav1.SharedAWSSecret{ - metadata = utils._metadata("sharedAWSSecret") | { - name = "{}-shared-secret".format(oxrMeta.name) - } - spec = { - parameters = { - deletionPolicy = oxrSpec.parameters.deletionPolicy - aws = { - accountId = oxrSpec.parameters.aws.accountId - region = oxrSpec.parameters.aws.region - namePrefix = "{}-{}-{}".format(_oxrStatusUpbound.org, envGroupName, oxrMeta.name) - if oxrSpec.parameters.aws?.sharedSecret?.secretsManagerSecret: - secretsManagerSecret = { - if oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret?.arn: - arn = oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret.arn - if oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret?.name: - name = oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret.name - if oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret?.recoveryWindowInDays != Undefined: - recoveryWindowInDays = oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret.recoveryWindowInDays - if oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret?.create != Undefined: - create = oxrSpec.parameters.aws.sharedSecret.secretsManagerSecret.create - } - providerConfigRef = { - name = envGroupName - } - } - upbound = { - group = envGroupName - controlPlane = oxrMeta.name - providerConfigRef = { - name = "{}-group".format(envGroupName) - } - } - if oxrSpec.parameters.aws?.sharedSecret: - externalSecret = { - if oxrSpec.parameters.aws.sharedSecret.externalSecret?.name: - name = oxrSpec.parameters.aws.sharedSecret.externalSecret.name - if oxrSpec.parameters.aws.sharedSecret.externalSecret?.namespace: - namespace = oxrSpec.parameters.aws.sharedSecret.externalSecret.namespace - if oxrSpec.parameters.aws.sharedSecret.externalSecret?.spec: - spec = { - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec?.data: - data = [{ - secretKey = item.secretKey - remoteRef = { - key = item.remoteRef.key - if item.remoteRef?.property: - property = item.remoteRef.property - if item.remoteRef?.version: - version = item.remoteRef.version - if item.remoteRef?.metadataPolicy: - metadataPolicy = item.remoteRef.metadataPolicy - if item.remoteRef?.conversionStrategy: - conversionStrategy = item.remoteRef.conversionStrategy - if item.remoteRef?.decodingStrategy: - decodingStrategy = item.remoteRef.decodingStrategy - } - if item?.sourceRef: - sourceRef = { - if item.sourceRef?.generatorRef: - generatorRef = { - apiVersion = item.sourceRef.generatorRef.apiVersion - kind = item.sourceRef.generatorRef.kind - name = item.sourceRef.generatorRef.name - } - } - } for item in oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.data] - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec?.target: - target = { - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target?.template: - template = { - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target.template?.data: - data = oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target.template.data - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target.template?.metadata: - metadata = { - if oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target.template.metadata?.labels: - labels = oxrSpec.parameters.aws.sharedSecret.externalSecret.spec.target.template.metadata.labels - } - } - } - } - } - } - } - }] - -# Return final resource list -items = _initItems + _upboundItems + _awsItems diff --git a/functions/environments/pKubernetesHelper.k b/functions/environments/pKubernetesHelper.k deleted file mode 100644 index 24a49d0..0000000 --- a/functions/environments/pKubernetesHelper.k +++ /dev/null @@ -1,234 +0,0 @@ -""" -Helper module that provides functions for configuring Kubernetes providers and -generating kubeconfig objects for connecting to Upbound Spaces. - -This module supports creating provider configurations at different scopes: -- Space level: For accessing APIs at the Upbound Spaces level -- Group level: For accessing APIs at the environment group level -- Control plane level: For accessing APIs within a specific control plane -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.crossplane.protection.v1beta1 as protectionv1beta1 -import base64 -import utils - -schema UpboundTokenSecretRef: - name: str - namespace: str - key: str - -schema UpboundProviderConfigInput: - """ - Input schema for configuring a Kubernetes provider for Upbound Spaces. - Exactly one of group+ctp or prefix must be provided to determine the scope. - """ - spaceHost: str # The Upbound Spaces host (e.g. spaces.upbound.io) - org: str # The Upbound organization name - group?: str # Optional: The group name for group-level access - ctp?: str # Optional: The control plane name for control plane-level access - # should be set when neither group nor ctp is set - prefix?: str # Optional: Prefix for space-level access (when no group/ctp provided) - providerConfigName: str # Name of the provider config to reference - # Namespace on the bootstrap control plane for the kubeconfig Secret - the XR's own. - # A shared `default` would make same-named Environments in different namespaces - # overwrite each other's credentials. - secretNamespace: str - upboundTokenSecretRef: UpboundTokenSecretRef # Reference to the Upbound authentication token - -# Helper for generating a controlplane, space or group-level kubeconfig for provider-kubernetes on upbound -# This function creates a kubeconfig that targets the appropriate API endpoint based on the scope: -# - If ctp and group are provided: Targets a specific control plane within a group -# - If only group is provided: Targets all control planes within a group -# - If neither is provided: Targets the space-level APIs -upboundKubeconfig = lambda spaceHost: str, org: str, group: str, ctp: str -> any { str({ - apiVersion = "v1" - clusters = [ - { - cluster = { - "insecure-skip-tls-verify" = True - if ctp == "": - server = "https://{}".format(spaceHost) - else: - server = "https://{}/apis/spaces.upbound.io/v1beta1/namespaces/{}/controlplanes/{}/k8s".format(spaceHost, group, ctp) - } - name = "upbound" - } - ] - contexts = [ - { - context = { - cluster = "upbound" - extensions = [ - { - extension = { - apiVersion = "upbound.io/v1alpha1" - kind = "SpaceExtension" - spec = { - cloud = { - organization = org - } - } - } - name = "spaces.upbound.io/space" - } - ] - if ctp == "": - namespace = group - else: - namespace = "default" - user = "upbound" - } - name = "upbound" - } - ] - "current-context" = "upbound" - kind = "Config" - preferences = {} - users = [ - { - name = "upbound" - user = { - exec = { - apiVersion = "client.authentication.k8s.io/v1" - args = [ - "organization" - "token" - ] - command = "up" - env = [ - { - name = "ORGANIZATION" - value = org - } - { - name = "UP_PROFILE" - value = "default" - } - ] - interactiveMode: "IfAvailable" - provideClusterInfo = False - } - } - - } - ] -})} - -configName = lambda group: str, ctp: str, prefix: str -> any { - """ - Generates a configuration name based on the scope: - - For control plane scope: "-ctp" - - For group scope: "-group" - - For space scope: "-space" - """ - "{}-ctp".format(ctp) if ctp else ("{}-group".format(group) if group else "{}-space".format(prefix)) -} - -resourceName = lambda group: str, ctp: str -> any { - """ - Generates a resource name identifier based on the scope: - - For control plane scope: "envCtp" - - For group scope: "envGroup" - - For space scope: "space" - """ - "envCtp" if ctp else ("envGroup" if group else "space") -} - -upboundProviderConfig = lambda config: UpboundProviderConfigInput -> any { - """ - Creates a complete Kubernetes provider configuration bundle for Upbound Spaces, including: - 1. A Kubernetes Object to store the kubeconfig secret - 2. A ProviderConfig that references the kubeconfig secret - 3. A Usage resource to establish dependency between the secret and provider config - - Returns a list of these three resources properly configured for the specified scope. - """ - [ - kubernetesm.Object{ - metadata = utils._metadata("{}Kubeconfig".format(resourceName(config.group, config.ctp))) | { - name = "{}-kubeconfig".format(configName(config.group, config.ctp, config.prefix)) - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "{}-kubeconfig".format(configName(config.group, config.ctp, config.prefix)) - namespace = config.secretNamespace - } - # `data` with base64, not `stringData`. provider-kubernetes applies - # server-side and records field ownership for what it wrote; because - # Kubernetes converts stringData -> data on write, the owned field is - # never present on the stored object and the next observe fails with - # "unable to convert managed fields ... expected map, got ". - data = { - kubeconfig = base64.encode(upboundKubeconfig( - config.spaceHost, - config.org, - config.group if config.group else "default", - config.ctp if config.ctp else "")) - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = config.providerConfigName - } - } - } - - kubernetesm.ProviderConfig{ - metadata = utils._metadata("{}ProviderConfig".format(resourceName(config.group, config.ctp))) | { - name = configName(config.group, config.ctp, config.prefix) - annotations = { - "krm.kcl.dev/ready" = "True" - } - } - spec = { - credentials = { - source = "Secret" - secretRef = { - name = "{}-kubeconfig".format(configName(config.group, config.ctp, config.prefix)) - namespace = config.secretNamespace - key = "kubeconfig" - } - } - identity = { - type = "UpboundTokens" - source = "Secret" - secretRef = { - # Uses the token from the shared bootstrap control plane - name = config.upboundTokenSecretRef.name - namespace = config.upboundTokenSecretRef.namespace - key = config.upboundTokenSecretRef.key - } - } - } - } - - protectionv1beta1.Usage{ - metadata = utils._metadata("{}Usage".format(resourceName(config.group, config.ctp))) | { - name = "{}-kubeconfig".format(configName(config.group, config.ctp, config.prefix)) - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "{}-kubeconfig".format(configName(config.group, config.ctp, config.prefix)) - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = configName(config.group, config.ctp, config.prefix) - } - } - } - } -]} - diff --git a/functions/environments/teamRobot.k b/functions/environments/teamRobot.k deleted file mode 100644 index b07fcbf..0000000 --- a/functions/environments/teamRobot.k +++ /dev/null @@ -1,200 +0,0 @@ -""" -Team and Robot Configuration Module - -This module handles the creation of Upbound team and robot resources for environments. It creates: - -1. An Upbound team that can be assigned permissions -2. A robot account with appropriate API tokens -3. Team membership for the robot -4. Role bindings for admin access to the environment group -5. Secrets containing token credentials for authentication -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.iamm.v1alpha1 as iamv1alpha1 -import models.io.upbound.m.v1alpha1 as v1alpha1 -import utils - -schema TeamRobotTokenSecretRef: - """ - Reference to the secret containing an Upbound token - """ - name: str # Name of the secret - namespace: str # Namespace of the secret - key: str # Key in the secret containing the token - -schema TeamWithRobotInput: - """ - Input parameters for creating a team with a robot account - """ - group: str # Environment group name - org: str # Upbound organization name - secretDestProviderConfigName: str # Provider config for secret destination - spaceProviderConfigName: str # Provider config for Space operations - tokenSecretRef: TeamRobotTokenSecretRef # Reference to existing token secret - ocds: any - teamNameOverride?: str - teamExternalName?: str - createGroupAdminBinding?: bool - -teamWithRobot = lambda input: TeamWithRobotInput -> any { - """ - Creates team and robot resources for Upbound Space environments. - - This function generates: - 1. An Upbound provider configuration using the supplied token - 2. A robot account in the organization - 3. An access token for the robot - 4. A secret with the robot's token in the environment - 5. A team within the Upbound organization - 6. Team membership for the robot - 7. Admin role binding for the team in the environment group - """ - [ - # ProviderConfig provider-upbound - v1alpha1.ProviderConfig{ - metadata = utils._metadata("providerConfigUpbound") | { - annotations = { - "krm.kcl.dev/ready" = "True" - } - name = "{}-upbound".format(input.group) - } - spec = { - credentials = { - secretRef = { - name = input.tokenSecretRef.name - namespace = input.tokenSecretRef.namespace - key = input.tokenSecretRef.key - } - source = "Secret" - } - organization = input.org - } - } - - # Robot - iamv1alpha1.Robot { - metadata = utils._metadata("envRobot") | { - name = "{}-robot".format(input.group) - } - spec = { - forProvider = { - description = "Robot for {}".format(input.group) - name = "{}-bot".format(input.group) - owner = { - name = input.org - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-upbound".format(input.group) - } - } - - } - # Robot Token - iamv1alpha1.Token { - metadata = utils._metadata("envRobotToken") | { - name = "{}-robot-token".format(input.group) - } - spec = { - forProvider = { - name = input.group - owner = { - idRef = { - name = "{}-robot".format(input.group) - } - type = "robots" - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-upbound".format(input.group) - } - writeConnectionSecretToRef = { - name = "{}-robot-token".format(input.group) - } - } - } - # Team - iamv1alpha1.Team { - metadata: utils._metadata("envTeam") | { - name = "{}-team".format(input.group) - if input.teamExternalName: - annotations: { - "crossplane.io/external-name" = input.teamExternalName - } - } - spec = { - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - forProvider = { - name = input.teamNameOverride or "{}-team".format(input.group) - organizationName = input.org - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-upbound".format(input.group) - } - } - - } - # Robot team membership - iamv1alpha1.RobotTeamMembership { - metadata = utils._metadata("envRobotTeamMembership") | { - name = "{}-robot-team-membership".format(input.group) - } - spec = { - forProvider = { - robotIdRef = { - name = "{}-robot".format(input.group) - } - teamIdRef = { - name = "{}-team".format(input.group) - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-upbound".format(input.group) - } - } - - } - # Grant admin rights on group to team - if input.createGroupAdminBinding: - kubernetesm.Object{ - metadata: utils._metadata("teamAdminBinding") | { - name = "{}-admin-binding".format(input.group) - } - spec = { - forProvider = { - manifest = { - apiVersion = "authorization.spaces.upbound.io/v1alpha1" - kind = "ObjectRoleBinding" - metadata = { - name = "{}-admin-binding".format(input.group) - namespace = input.group - } - spec = { - object = { - apiGroup = "core" - resource = "namespaces" - name = input.group - } - subjects = [ - { - kind = "UpboundTeam" - role = "admin" - name = input.ocds.envTeam?.Resource?.metadata?.annotations?["crossplane.io/external-name"] - } - ] - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = input.spaceProviderConfigName - } - } - } - ] -} diff --git a/functions/environments/utils/metadata.k b/functions/environments/utils/metadata.k deleted file mode 100644 index 4926472..0000000 --- a/functions/environments/utils/metadata.k +++ /dev/null @@ -1,12 +0,0 @@ -_metadata = lambda name: str -> any { - """ - Creates metadata with a standardized composition resource name. - - Args: - name: The name to include in the annotations - - Returns: - A metadata object with annotations for composition resource naming - """ - { annotations = { "krm.kcl.dev/composition-resource-name" = name }} -} diff --git a/functions/environments/utils/names.k b/functions/environments/utils/names.k deleted file mode 100644 index 1b8af93..0000000 --- a/functions/environments/utils/names.k +++ /dev/null @@ -1,24 +0,0 @@ -# IAM name truncation. Mirrors functions/sharedawssecret/main.k: composition functions are -# separate packages and cannot import each other, so the two copies must be kept in step - -# and must stay byte-for-byte deterministic, because the result is the resource's AWS name. - -_simpleHash = lambda s: str -> str { - hash = len(s) * 31 - chars = [c for c in s] - result = sum([ord(chars[i]) * (i + 1) for i in range(len(chars))]) - str(abs(hash + result))[:8] -} - -# IAM caps role names at 64 characters. Over that, keep the suffix and replace the tail of -# the prefix with a hash of it, so distinct long names stay distinct. -_truncateIamName = lambda name: str, suffix: str -> str { - maxLength = 64 - suffixLength = len(suffix) - hashLength = 8 - separatorLength = 1 - prefixSpace = maxLength - suffixLength - hashLength - separatorLength - baseName = name[:len(name) - suffixLength] - hash = _simpleHash(baseName) - - name if len(name) <= maxLength else ("{}-{}{}".format(baseName[:prefixSpace].rstrip("-"), hash, suffix) if prefixSpace > 0 else "{}{}".format(hash, suffix)) -} diff --git a/functions/environments/utils/policy.k b/functions/environments/utils/policy.k deleted file mode 100644 index eb0cdb3..0000000 --- a/functions/environments/utils/policy.k +++ /dev/null @@ -1,16 +0,0 @@ -_managementPolicies = lambda deletionPolicy: str -> [str] { - """ - Translates the XR-level deletionPolicy parameter into managementPolicies. - - Namespaced (.m.) managed resources have no deletionPolicy field in Crossplane v2 — - managementPolicies is the only way to express "do not delete the external resource". - The XR keeps the friendlier Delete/Orphan parameter and this maps it. - - Args: - deletionPolicy: "Delete" or "Orphan" - - Returns: - The managementPolicies list for a managed resource - """ - ["*"] if deletionPolicy == "Delete" else ["Create", "Observe", "Update", "LateInitialize"] -} diff --git a/functions/environments/utils/secret.k b/functions/environments/utils/secret.k deleted file mode 100644 index 4452e36..0000000 --- a/functions/environments/utils/secret.k +++ /dev/null @@ -1,29 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.k8s.api.core.v1 as v1 - -observeSecret = lambda config: ObserveSecret -> any { - """ - Observe a Secret. - """ - [ - kubernetesm.Object{ - metadata = _metadata(config.resourceName) | { - name = "{}-{}-observed".format(config.ctp, config.resourceName) - } - spec = { - forProvider = { - manifest = v1.Secret{ - metadata = { - name = config.name - namespace = config.namespace - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = config.providerConfigName - } - managementPolicies = ["Observe"] - } - } -]} diff --git a/functions/environments/utils/secretSchema.k b/functions/environments/utils/secretSchema.k deleted file mode 100644 index 8f54264..0000000 --- a/functions/environments/utils/secretSchema.k +++ /dev/null @@ -1,24 +0,0 @@ -schema ObserveSecret: - r""" - ObserveSecret represents an Input for an Observe Secret. - - Attributes - ---------- - name : str, required - The metadata.name of the observed secret. - namespace : str, required - The metadata.namespace of the observed secret. - ctp : str, required - The control plane name for control plane-level access - resourceName : str, required - The crossplane.io/composition-resource-name for the observed secret. - providerConfigName : str, required - The Name of the provider config to reference - """ - - name: str - namespace: str - ctp: str - resourceName: str - providerConfigName: str - diff --git a/functions/sharedawssecret/kcl.mod b/functions/sharedawssecret/kcl.mod deleted file mode 100644 index d76528f..0000000 --- a/functions/sharedawssecret/kcl.mod +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "sharedawssecret" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } -spaces = { oci = "oci://xpkg.upbound.io/upbound/kcl-modules_spaces", tag = "1.12.0", package = "kcl-modules_spaces", version = "1.12.0" } diff --git a/functions/sharedawssecret/kcl.mod.lock b/functions/sharedawssecret/kcl.mod.lock deleted file mode 100644 index 1a5de22..0000000 --- a/functions/sharedawssecret/kcl.mod.lock +++ /dev/null @@ -1,13 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" - [dependencies.spaces] - name = "spaces" - full_name = "kcl-modules_spaces_1.12.0" - version = "1.12.0" - sum = "9tKyGSjYJoIM5QHiNZUKLSb9/jMPMALM1ktgtdsdUyA=" - reg = "xpkg.upbound.io" - repo = "upbound/kcl-modules_spaces" - oci_tag = "1.12.0" diff --git a/functions/sharedawssecret/main.k b/functions/sharedawssecret/main.k deleted file mode 100644 index 006a621..0000000 --- a/functions/sharedawssecret/main.k +++ /dev/null @@ -1,330 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.awsm.secretsmanager.v1beta1 as secretsmanagerv1beta1 -import models.io.k8s.api.core.v1 as v1 -import spaces.v1alpha1 as spacesv1alpha1 - -import json - -oxr = option("params").oxr # observed composite resource -_ocds = option("params").ocds # observed composed resources -_dxr = option("params").dxr # desired composite resource -dcds = option("params").dcds # desired composed resources - -_metadata = lambda name: str -> any { - { annotations = { "krm.kcl.dev/composition-resource-name" = name }} -} - -# Simple hash function for string using character sum -_simpleHash = lambda s: str -> str { - # Simple hash based on character codes (sum with weights) - hash = len(s) * 31 - chars = [c for c in s] - result = sum([ord(chars[i]) * (i + 1) for i in range(len(chars))]) - str(abs(hash + result))[:8] -} - -# Function to truncate IAM resource names to 64 characters -# When name exceeds limit, replace prefix with hash to preserve suffix -_truncateIamName = lambda name: str, suffix: str -> str { - maxLength = 64 - suffixLength = len(suffix) - hashLength = 8 - separatorLength = 1 - prefixSpace = maxLength - suffixLength - hashLength - separatorLength - baseName = name[:len(name) - suffixLength] - hash = _simpleHash(baseName) - - name if len(name) <= maxLength else ("{}-{}{}".format(baseName[:prefixSpace].rstrip("-"), hash, suffix) if prefixSpace > 0 else "{}{}".format(hash, suffix)) -} - -# Extract parameters from the XR spec -deletionPolicy = oxr.spec.parameters.deletionPolicy or "Orphan" -# Namespaced (.m.) managed resources have no deletionPolicy field; managementPolicies -# is the v2 equivalent. The XR keeps the friendlier Delete/Orphan parameter. -_mgmt = ["*"] if deletionPolicy == "Delete" else ["Create", "Observe", "Update", "LateInitialize"] -accountId = oxr.spec.parameters.aws.accountId -region = oxr.spec.parameters.aws.region - -secretsManagerSecretArn = oxr.spec.parameters.aws.secretsManagerSecret?.arn or Undefined -secretsManagerSecretName = oxr.spec.parameters.aws.secretsManagerSecret?.name or Undefined -# Keep the `?.` chain on every access: secretsManagerSecret is optional, and reading -# .create directly off it crashes the whole pipeline when the caller omits the block -# (which Environment does whenever sharedSecret is given without secretsManagerSecret). -# Creation is opt-out, not opt-in: only an explicit `false` disables it. Written this way -# because the block is optional and an absent one yields None rather than Undefined, so -# neither a bare `.create` (crashes) nor an `== Undefined` test (silently returns falsy, -# skipping the secret entirely) is safe here. -_recoveryWindowInDays = oxr.spec.parameters.aws.secretsManagerSecret?.recoveryWindowInDays -_smsCreate = oxr.spec.parameters.aws.secretsManagerSecret?.create -_secretsManagerSecretCreate = False if _smsCreate == False else True - -groupName = oxr.spec.parameters.upbound.group -ctpName = oxr.spec.parameters.upbound.controlPlane -namePrefix = oxr.spec.parameters.aws.namePrefix -awsSecretName = secretsManagerSecretName or "{}-config".format(namePrefix) -awsProviderConfigName = oxr.spec.parameters.aws.providerConfigRef.name -upboundProviderConfigName = oxr.spec.parameters.upbound.providerConfigRef.name -secretLabels = oxr.spec.parameters.externalSecret?.spec?.target?.template?.metadata?.labels or {} -secretNamespace = oxr.spec.parameters.externalSecret?.namespace or "default" -secretData = oxr.spec.parameters.externalSecret?.spec?.data or Undefined -secretTemplateData = oxr.spec.parameters.externalSecret?.spec?.target?.template?.data or Undefined -externalSecretName = oxr.spec.parameters.externalSecret?.name or ctpName - -_items = [ - ### Needed until SharedSecretStore supports IAM Roles ### - iamv1beta1.User { - metadata = _metadata("iamUserSecretRead") | { - name = _truncateIamName("{}-{}-secrets-read".format(oxr.metadata.name, awsSecretName), "-secrets-read") - } - spec = { - managementPolicies = _mgmt - forProvider = {} - providerConfigRef = { - kind = "ProviderConfig" - name = awsProviderConfigName - } - } - } - iamv1beta1.Policy { - metadata = _metadata("iamPolicySecretRead") | { - name = _truncateIamName("{}-{}-secrets-read".format(oxr.metadata.name, awsSecretName), "-secrets-read") - } - spec = { - managementPolicies = _mgmt - forProvider = { - policy = json.encode({ - "Version": "2012-10-17" - "Statement": [ - { - "Effect": "Allow" - "Action": [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - "Resource": [ - "arn:aws:secretsmanager:{}:{}:secret:{}-*".format(region, accountId, awsSecretName) - ] - } - ] - }) - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsProviderConfigName - } - } - } - iamv1beta1.UserPolicyAttachment { - metadata = _metadata("iamPolicySecretReadAttach") | { - name = _truncateIamName("{}-{}-secrets-read".format(oxr.metadata.name, awsSecretName), "-secrets-read") - } - spec = { - managementPolicies = _mgmt - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsProviderConfigName - } - } - } - iamv1beta1.AccessKey { - metadata = _metadata("iamUserAccessKey") | { - name = _truncateIamName("{}-{}-secrets-read".format(oxr.metadata.name, awsSecretName), "-secrets-read") - } - spec = { - managementPolicies = _mgmt - forProvider = { - userSelector = { - matchControllerRef = True - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsProviderConfigName - } - writeConnectionSecretToRef = { - name = "{}-secrets-read-access-key".format(groupName) - } - } - } - # copy the iam access key secret - kubernetesm.Object{ - metadata = _metadata("envIamUserKeySecret") | { - name = "{}-secrets-read-access-key".format(groupName) - } - spec = { - managementPolicies = _mgmt - forProvider = { - manifest = v1.Secret{ - metadata = { - name = "{}-secrets-read-access-key".format(groupName) - namespace = groupName - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = upboundProviderConfigName - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - kind = "Secret" - name = "{}-secrets-read-access-key".format(groupName) - # The AccessKey connection secret is written into the MR's own - # namespace (v2 dropped writeConnectionSecretToRef.namespace), - # which is the XR's namespace - not the hardcoded "default". - namespace = oxr.metadata.namespace - fieldPath = "data" - } - toFieldPath = "data" - } - ] - } - } - ### end iam user workaround ### - if _secretsManagerSecretCreate: - secretsmanagerv1beta1.Secret { - metadata = _metadata("secretsmanagerSecret") | { - name = "{}-secretsmanager-secret".format(awsSecretName) - annotations = { - if secretsManagerSecretArn: - 'crossplane.io/external-name' = secretsManagerSecretArn - } - } - spec = { - managementPolicies = _mgmt - forProvider = { - name = awsSecretName - region = region - # Explicit `!= Undefined`, not truthiness: 0 is the value that matters here - # and it is falsy, so a truthy test would silently drop exactly the setting - # anyone bothers to specify. - if _recoveryWindowInDays != Undefined: - recoveryWindowInDays = _recoveryWindowInDays - if deletionPolicy == "Delete": - forceOverwriteReplicaSecret = True - } - providerConfigRef = { - kind = "ProviderConfig" - name = awsProviderConfigName - } - } - } - # Shared secret store mapped to secret on cloud-provider - kubernetesm.Object{ - metadata = _metadata("sharedSecretsStore") | { - name = "{}-sss".format(ctpName) - } - spec = { - managementPolicies = _mgmt - forProvider = { - manifest = spacesv1alpha1.SharedSecretStore{ - metadata = { - name = ctpName - namespace = groupName - } - spec = { - controlPlaneSelector = { - names = [ctpName] - } - namespaceSelector = { - names = [secretNamespace] - } - provider = { - aws = { - service = "SecretsManager" - region = region - auth = { - secretRef = { - accessKeyIDSecretRef = { - name = "{}-secrets-read-access-key".format(groupName) - key = "username" - } - secretAccessKeySecretRef = { - name = "{}-secrets-read-access-key".format(groupName) - key = "password" - } - } - } - } - } - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = upboundProviderConfigName - } - } - } - - # Shared secret which will populate initial secret from cloud into - # the main controlplane of the environment - kubernetesm.Object{ - metadata = _metadata("sharedExternalSecret") | { - name = "{}-ses".format(ctpName) - } - spec = { - managementPolicies = _mgmt - forProvider = { - manifest = spacesv1alpha1.SharedExternalSecret{ - metadata = { - name = externalSecretName - namespace = groupName - } - spec = { - controlPlaneSelector = { - names = [ctpName] - } - namespaceSelector = { - names = [secretNamespace] - } - externalSecretSpec = { - refreshInterval = "1m" - secretStoreRef = { - name = ctpName - kind = "ClusterSecretStore" - } - target = { - name = externalSecretName - template = { - metadata = { - if secretLabels: - labels = secretLabels - } - if secretTemplateData != Undefined: - data = secretTemplateData - } - } - if secretData != Undefined: - data = secretData - else: - dataFrom = [{ - extract = { - key = awsSecretName - } - }] - } - } - } - } - providerConfigRef = { - kind = "ProviderConfig" - name = upboundProviderConfigName - } - } - } -] - -items = _items diff --git a/functions/sharedawssecret/model b/functions/sharedawssecret/model deleted file mode 120000 index faff6e4..0000000 --- a/functions/sharedawssecret/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/functions/upboundreposet/main.k b/functions/upboundreposet/main.k deleted file mode 100644 index 963a3c9..0000000 --- a/functions/upboundreposet/main.k +++ /dev/null @@ -1,103 +0,0 @@ -""" -UpboundRepoSet Function - -This Crossplane composition function manages Upbound repositories and their permissions. -It creates the following resources: -- Repository resources for each repository specified in parameters -- Permission resources connecting teams to repositories with specified permission levels -- ProviderConfig for authenticating with Upbound API - -Parameters: -- organization: The Upbound organization name -- repositories: Map of repository names to empty objects -- permissions.teams: Map of team names to permission objects -- tokenSecretRef: Reference to a Kubernetes secret containing the Upbound token -""" - -import models.io.upbound.sa.v1 as sav1 -import models.io.upbound.repositorym.v1alpha1 as repositoryv1alpha1 -import models.io.upbound.m.v1alpha1 as v1alpha1 -import utils - -# Extract the UpboundRepoSet object from the parameters. -# Only metadata and spec.parameters are taken: a namespaced (v2) XR also carries -# spec.crossplane, whose resourceRefs Crossplane fills with plain dicts once resources -# exist, and spreading those into the typed schema fails to type-check. -_observedXR = option("params").oxr -oxr = sav1.UpboundRepoSet{ - metadata = _observedXR.metadata - spec = { - parameters = _observedXR.spec.parameters - } -} - -# Generate list of resources to create -_items = [ - # Create Repository resources for each repository specified in the parameters - repositoryv1alpha1.Repository{ - metadata: utils._metadata("{}-{}".format(oxr.spec.parameters.organization, repo)) | { - annotations: { - "crosslane.io/external-name": repo - } - } - spec = { - # Orphan on delete: keep the repository when the UpboundRepoSet is deleted. - # Namespaced MRs have no deletionPolicy; this is the managementPolicies equivalent. - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - forProvider = { - name = repo - organizationName = oxr.spec.parameters.organization - public = opts.public if opts.public != Undefined else oxr.spec.parameters.settings.public - publish = opts.publish if opts.publish != Undefined else oxr.spec.parameters.settings.publish - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-{}-reposet".format(oxr.metadata.name, oxr.spec.parameters.organization) - } - } - } for repo, opts in oxr.spec.parameters.repositories -] + [ - # Create Permission resources connecting teams to repositories with specified permission levels - # This creates a Permission resource for each repository and team combination - repositoryv1alpha1.Permission{ - metadata: utils._metadata("{}-{}-{}".format(oxr.spec.parameters.organization, repo, team)) - spec = { - forProvider = { - organizationName = oxr.spec.parameters.organization - repository = repo - teamIdRef = { - # Reference to the team by name - name = team - } - # Get the permission level from the parameters (read, write, admin) - permission = oxr.spec.parameters.permissions.teams[team].permission - } - providerConfigRef = { - kind = "ProviderConfig" - name = "{}-{}-reposet".format(oxr.metadata.name, oxr.spec.parameters.organization) - } - } - } for repo in oxr.spec.parameters.repositories for team in oxr.spec.parameters.permissions?.teams -] + [ - v1alpha1.ProviderConfig{ - metadata: utils._metadata("providerConfigUpbound") | { - annotations: { - "krm.kcl.dev/ready" = "True" - } - name = "{}-{}-reposet".format(oxr.metadata.name, oxr.spec.parameters.organization) - } - spec = { - credentials = { - secretRef = { - name = oxr.spec.parameters.tokenSecretRef.name - namespace = oxr.spec.parameters.tokenSecretRef.namespace - key = oxr.spec.parameters.tokenSecretRef.key - } - source = "Secret" - } - organization = oxr.spec.parameters.organization - } - } -] - -items = _items diff --git a/functions/upboundreposet/model b/functions/upboundreposet/model deleted file mode 120000 index faff6e4..0000000 --- a/functions/upboundreposet/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/functions/upboundreposet/utils/metadata.k b/functions/upboundreposet/utils/metadata.k deleted file mode 100644 index 577914a..0000000 --- a/functions/upboundreposet/utils/metadata.k +++ /dev/null @@ -1,12 +0,0 @@ -_metadata = lambda name: str -> any { - """ - Creates metadata with a standardized composition resource name. - - Args: - name: The name to include in the annotations - - Returns: - A metadata object with annotations for composition resource naming - """ - { annotations = { "krm.kcl.dev/composition-resource-name" = name }} -} \ No newline at end of file diff --git a/functions/upboundreposet/kcl.mod b/tests/test-environment-team-with-robot/kcl.mod similarity index 63% rename from functions/upboundreposet/kcl.mod rename to tests/test-environment-team-with-robot/kcl.mod index 994c00a..c58894c 100644 --- a/functions/upboundreposet/kcl.mod +++ b/tests/test-environment-team-with-robot/kcl.mod @@ -1,5 +1,5 @@ [package] -name = "upboundreposet" +name = "test-environment-team-with-robot" version = "0.0.1" [dependencies] diff --git a/functions/upboundreposet/kcl.mod.lock b/tests/test-environment-team-with-robot/kcl.mod.lock similarity index 100% rename from functions/upboundreposet/kcl.mod.lock rename to tests/test-environment-team-with-robot/kcl.mod.lock diff --git a/tests/test-environment-team-with-robot/main.k b/tests/test-environment-team-with-robot/main.k new file mode 100644 index 0000000..ce1948f --- /dev/null +++ b/tests/test-environment-team-with-robot/main.k @@ -0,0 +1,158 @@ +""" +teamWithRobot - a Team, a Robot in it, a Token for the Robot, and, when the composition also +creates the group, an ObjectRoleBinding making the Team admin of that group. + +This is the path solutions-gitops-prod's `ci` Environment runs, and it had no test at all. +The binding's subject is the Team's Upbound ID, which only exists once the Team has been +created, so the composition reads it off the observed Team's external name. The test +supplies that observed Team, which is what makes the binding's subject renderable. +""" + +import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 +import models.io.upbound.iamm.v1alpha1 as iamv1alpha1 +import models.io.upbound.sa.v1 as sav1 + +_status = { + bootstrapCtp = "bootstrap" + bootstrapGroup = "solutions-non-prod" + org = "upbound" + spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" +} + +_items = [ + metav1alpha1.CompositionTest{ + metadata.name: "test-environment-team-with-robot" + spec = { + assertResources = [ + iamv1alpha1.Robot{ + metadata.name = "solutions-non-prod-default-example-robot" + spec.forProvider = { + description = "Robot for solutions-non-prod-default-example" + name = "solutions-non-prod-default-example-bot" + owner.name = "upbound" + } + } + iamv1alpha1.Token{ + metadata.name = "solutions-non-prod-default-example-robot-token" + spec.forProvider = { + name = "solutions-non-prod-default-example" + owner.type = "robots" + } + } + iamv1alpha1.Team{ + metadata.name = "solutions-non-prod-default-example-team" + spec = { + # Teams are orphaned regardless of deletionPolicy. + managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] + forProvider = { + name = "solutions-non-prod-default-example-team" + organizationName = "upbound" + } + } + } + iamv1alpha1.RobotTeamMembership{ + metadata.name = "solutions-non-prod-default-example-robot-team-membership" + spec.forProvider = { + robotIdRef.name = "solutions-non-prod-default-example-robot" + teamIdRef.name = "solutions-non-prod-default-example-team" + } + } + # The binding's subject is the observed Team's Upbound ID. + kubernetesm.Object{ + metadata.name = "solutions-non-prod-default-example-admin-binding" + spec.forProvider.manifest = { + apiVersion = "authorization.spaces.upbound.io/v1alpha1" + kind = "ObjectRoleBinding" + spec.subjects = [{ + kind = "UpboundTeam" + role = "admin" + name = "11111111-2222-3333-4444-555555555555" + }] + } + } + ] + compositionPath = "apis/environments/composition.yaml" + xrdPath = "apis/environments/definition.yaml" + xr: sav1.Environment{ + metadata = {name = "example", namespace = "default"} + spec.parameters.upbound = { + initKubeconfigSecretRef = {name = "init-kubeconfig"} + tokenSecretRef = {name = "upbound-token"} + createArgoSecret = False + teamWithRobot = {} + } + status.upbound = _status + } + observedResources = [ + iamv1alpha1.Team{ + metadata = { + name = "solutions-non-prod-default-example-team" + namespace = "default" + annotations = { + "crossplane.io/composition-resource-name" = "envTeam" + "crossplane.io/external-name" = "11111111-2222-3333-4444-555555555555" + } + } + spec.forProvider = { + name = "solutions-non-prod-default-example-team" + organizationName = "upbound" + } + } + ] + timeoutSeconds = 60 + validate = False + } + } + # The shape of solutions-gitops-prod's `production-upbound-deploy`: adopt an existing Team + # by ID and add a new Robot to it, inside a group somebody else manages. + metav1alpha1.CompositionTest{ + metadata.name: "test-environment-team-external-name" + spec = { + assertResources = [ + iamv1alpha1.Team{ + metadata = { + name = "solutions-non-prod-default-example-team" + annotations = {"crossplane.io/external-name" = "ae0e38df-fd52-4724-9c98-b8cd455d3d38"} + } + spec = { + managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] + forProvider = { + name = "CI" + organizationName = "upbound" + } + } + } + iamv1alpha1.Robot{ + metadata.name = "solutions-non-prod-default-example-robot" + spec.forProvider = { + description = "Robot for solutions-non-prod-default-example" + name = "solutions-non-prod-default-example-bot" + owner.name = "upbound" + } + } + ] + compositionPath = "apis/environments/composition.yaml" + xrdPath = "apis/environments/definition.yaml" + xr: sav1.Environment{ + metadata = {name = "example", namespace = "default"} + spec.parameters.upbound = { + initKubeconfigSecretRef = {name = "init-kubeconfig"} + tokenSecretRef = {name = "upbound-token"} + createArgoSecret = False + createGroup = False + createCtp = False + teamWithRobot = { + teamNameOverride = "CI" + teamExternalName = "ae0e38df-fd52-4724-9c98-b8cd455d3d38" + } + } + status.upbound = _status + } + timeoutSeconds = 60 + validate = False + } + } +] + +items = _items diff --git a/functions/environments/model b/tests/test-environment-team-with-robot/model similarity index 100% rename from functions/environments/model rename to tests/test-environment-team-with-robot/model From 5d445024f5f4a9fe5120ef557734764faad9ab46 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Fri, 25 Sep 2026 18:16:59 +0200 Subject: [PATCH 02/10] refactor: rewrite composition functions in Python Ports environments, sharedawssecret and upboundreposet from KCL to the Python function SDK, in the SDK layout `up function generate` produces, on crossplane-function-sdk-python 0.15.1. Function directory names are unchanged, so the published packages keep their paths. The port reproduces the KCL functions' rendered output, not just what the tests assert: every composition test's full render was diffed resource by resource and field by field against the KCL baseline. Across 20 tests two resources differ, both deliberately: - the ControlPlane manifest no longer carries a `foo` annotation holding KCL's own rendering of the whole spec - a debug leftover with no Python equivalent - a Secrets Manager secret with no secretsManagerSecret block no longer sets `recoveryWindowInDays: null`; KCL's `None != Undefined` let it through, where the intent was to omit it Getting to parity meant reproducing KCL behaviour Python does not have: defaults KCL's typed models materialise into the output, KCL's str() format for the kubeconfig Secrets, and the composition keys KCL actually produced where merging metadata had replaced the annotation carrying the intended one. A changed key would make Crossplane replace the resource. Also preserved, and worth a separate look: the Repository external-name annotation is misspelled `crosslane.io/external-name`. --- .gitignore | 2 + functions/environments/README.md | 4 + functions/environments/function/__init__.py | 0 .../environments/function/__version__.py | 1 + functions/environments/function/compat.py | 41 ++ functions/environments/function/fn.py | 349 +++++++++++++++ functions/environments/function/main.py | 51 +++ functions/environments/function/resources.py | 408 ++++++++++++++++++ functions/environments/pyproject.toml | 31 ++ functions/sharedawssecret/README.md | 4 + .../sharedawssecret/function/__init__.py | 0 .../sharedawssecret/function/__version__.py | 1 + functions/sharedawssecret/function/fn.py | 327 ++++++++++++++ functions/sharedawssecret/function/main.py | 51 +++ functions/sharedawssecret/pyproject.toml | 30 ++ functions/upboundreposet/README.md | 4 + functions/upboundreposet/function/__init__.py | 0 .../upboundreposet/function/__version__.py | 1 + functions/upboundreposet/function/fn.py | 121 ++++++ functions/upboundreposet/function/main.py | 51 +++ functions/upboundreposet/pyproject.toml | 30 ++ 21 files changed, 1507 insertions(+) create mode 100644 functions/environments/README.md create mode 100644 functions/environments/function/__init__.py create mode 100644 functions/environments/function/__version__.py create mode 100644 functions/environments/function/compat.py create mode 100644 functions/environments/function/fn.py create mode 100644 functions/environments/function/main.py create mode 100644 functions/environments/function/resources.py create mode 100644 functions/environments/pyproject.toml create mode 100644 functions/sharedawssecret/README.md create mode 100644 functions/sharedawssecret/function/__init__.py create mode 100644 functions/sharedawssecret/function/__version__.py create mode 100644 functions/sharedawssecret/function/fn.py create mode 100644 functions/sharedawssecret/function/main.py create mode 100644 functions/sharedawssecret/pyproject.toml create mode 100644 functions/upboundreposet/README.md create mode 100644 functions/upboundreposet/function/__init__.py create mode 100644 functions/upboundreposet/function/__version__.py create mode 100644 functions/upboundreposet/function/fn.py create mode 100644 functions/upboundreposet/function/main.py create mode 100644 functions/upboundreposet/pyproject.toml diff --git a/.gitignore b/.gitignore index 259b303..f9f4fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ _output .venv .up .agents +.vscode +__pycache__ diff --git a/functions/environments/README.md b/functions/environments/README.md new file mode 100644 index 0000000..7bdbd95 --- /dev/null +++ b/functions/environments/README.md @@ -0,0 +1,4 @@ +# Composition Function + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition function here. diff --git a/functions/environments/function/__init__.py b/functions/environments/function/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/environments/function/__version__.py b/functions/environments/function/__version__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/functions/environments/function/__version__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/functions/environments/function/compat.py b/functions/environments/function/compat.py new file mode 100644 index 0000000..2f5d6fa --- /dev/null +++ b/functions/environments/function/compat.py @@ -0,0 +1,41 @@ +"""Output the KCL implementation produced that Python does not produce on its own. + +This function replaced a KCL one, and keeps its rendered output identical - the composition +tests compare it byte for byte in places, and a changed field on a live control plane is a +changed resource. Two KCL behaviours need reproducing to get there: + +- `str()` of a dict, which KCL renders in its own syntax rather than as YAML or JSON. The + kubeconfig Secrets were written that way, and their bytes are what provider-kubernetes + reads - it happens to parse as a YAML flow mapping. +- defaults KCL's typed models materialise into every resource, whether or not the function + set them. +""" + +# provider-kubernetes Object defaults KCL emitted on every Object. +OBJECT_FOR_PROVIDER_DEFAULTS = {"deletionPropagationPolicy": "Background"} +OBJECT_SPEC_DEFAULTS = {"watch": False} + + +def kcl_str(value, in_list: bool = False) -> str: + """Render a value the way KCL's str() does. + + Dict keys and dict values that are strings are single-quoted; a string that is a list + element is NOT quoted - so a list of strings renders as `[organization, token]`. Booleans + are True/False. Insertion order is kept. + """ + if isinstance(value, dict): + return "{" + ", ".join(f"'{k}': {kcl_str(v)}" for k, v in value.items()) + "}" + if isinstance(value, list): + return "[" + ", ".join(kcl_str(v, in_list=True) for v in value) + "]" + if isinstance(value, bool): + return "True" if value else "False" + if isinstance(value, str): + return value if in_list else f"'{value}'" + return str(value) + + +def object_spec(spec: dict) -> dict: + """An Object spec with the provider-kubernetes defaults KCL materialised.""" + spec = {**OBJECT_SPEC_DEFAULTS, **spec} + spec["forProvider"] = {**OBJECT_FOR_PROVIDER_DEFAULTS, **spec["forProvider"]} + return spec diff --git a/functions/environments/function/fn.py b/functions/environments/function/fn.py new file mode 100644 index 0000000..55ddf12 --- /dev/null +++ b/functions/environments/function/fn.py @@ -0,0 +1,349 @@ +"""Environment composition function. + +An Environment is an Upbound group holding one control plane, wired to AWS. The function +works in two phases: + +1. Initialisation. It observes the bootstrap control plane's kubeconfig Secret and parses out + the Space host, organization, bootstrap group and bootstrap control plane, and records them + in status.upbound. Nothing else is composed until those are known. +2. Composition. With status.upbound populated it composes the group and control plane, the + provider-kubernetes ProviderConfigs that reach them, optional Argo CD registration, Team + and Robot, and secret sync, plus the AWS ProviderConfig, admin IAM role and the nested + SharedAWSSecret. + +The values go through status rather than straight into the composition so that a kubeconfig +that later disappears does not take the environment's resources with it. +""" + +import base64 +import re + +import grpc +import yaml +from crossplane.function import logging, resource, response +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 + +from models.io.upbound.sa.environment import v1 as envv1 +from models.io.upbound.sa.sharedawssecret import v1 as sasv1 + +from . import resources as r + +# The bootstrap kubeconfig's server URL has the shape +# https:///apis/spaces.upbound.io/v1beta1/namespaces//controlplanes//k8s +# and these pick it apart by path position. +SPACE_HOST_RE = re.compile(r"https:\/\/([.\w-]+)(?:\/[.\w-]+){8}") +BOOTSTRAP_GROUP_RE = re.compile(r"https:\/(?:\/[.\w-]+){5}\/([.\w-]+)(?:\/[.\w-]+){3}") +BOOTSTRAP_CTP_RE = re.compile(r"https:\/(?:\/[.\w-]+){7}\/([.\w-]+)(?:\/[.\w-]+)") + + +def _dig(d, *path): + """Walk nested dicts, returning None at the first missing level.""" + for key in path: + if not isinstance(d, dict): + return None + d = d.get(key) + return d + + +def _observed(req: fnv1.RunFunctionRequest, key: str) -> dict: + if key not in req.observed.resources: + return {} + return resource.struct_to_dict(req.observed.resources[key].resource) + + +def parse_bootstrap_kubeconfig(encoded: str) -> dict: + """Extract the Space coordinates from the bootstrap control plane's kubeconfig.""" + kubeconfig = yaml.safe_load(base64.b64decode(encoded)) + cluster = (kubeconfig.get("clusters") or [{}])[0].get("cluster") or {} + server = cluster.get("server") + return { + "serverCaData": cluster.get("certificate-authority-data"), + "spaceHost": SPACE_HOST_RE.sub(r"\1", server), + "bootstrapGroup": BOOTSTRAP_GROUP_RE.sub(r"\1", server), + "bootstrapCtp": BOOTSTRAP_CTP_RE.sub(r"\1", server), + "org": kubeconfig["contexts"][0]["context"]["extensions"][0]["extension"]["spec"]["cloud"]["organization"], + } + + +def _external_secret_spec(spec) -> dict: + """Carry externalSecret.spec across, field by field, as the KCL version did. + + Only these fields reach the nested XR; anything else the caller set is dropped. Truthiness + for each optional field, again as before - an empty string or list is treated as unset. + """ + out = {} + if spec.data: + items = [] + for item in spec.data: + ref = {"key": item.remoteRef.key} + for field in ("property", "version", "metadataPolicy", "conversionStrategy", "decodingStrategy"): + if getattr(item.remoteRef, field): + ref[field] = getattr(item.remoteRef, field) + entry = {"secretKey": item.secretKey, "remoteRef": ref} + if item.sourceRef: + entry["sourceRef"] = {} + gen = item.sourceRef.generatorRef + if gen: + entry["sourceRef"]["generatorRef"] = {"apiVersion": gen.apiVersion, "kind": gen.kind, "name": gen.name} + items.append(entry) + out["data"] = items + if spec.target: + out["target"] = {} + template = spec.target.template + if template: + out["target"]["template"] = {} + if template.data: + out["target"]["template"]["data"] = dict(template.data) + if template.metadata: + out["target"]["template"]["metadata"] = ( + {"labels": dict(template.metadata.labels)} if template.metadata.labels else {} + ) + return out + + +class FunctionRunner(grpcv1.FunctionRunnerService): + """A FunctionRunner handles gRPC RunFunctionRequests.""" + + def __init__(self): + """Create a new FunctionRunner.""" + self.log = logging.get_logger() + + async def RunFunction( + self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext + ) -> fnv1.RunFunctionResponse: + """Run the function.""" + log = self.log.bind(tag=req.meta.tag) + rsp = response.to(req) + + xr = envv1.Environment(**resource.struct_to_dict(req.observed.composite.resource)) + name, namespace = xr.metadata.name, xr.metadata.namespace + params = xr.spec.parameters + up = params.upbound + token_ref = {"name": up.tokenSecretRef.name, "namespace": up.tokenSecretRef.namespace, "key": up.tokenSecretRef.key} + + desired = [] + + # ================================================================================= + # Initialisation + # ================================================================================= + parsed = {} + encoded = _dig(_observed(req, "observedCtpKubeconfig"), "status", "atProvider", "manifest", "data", "kubeconfig") + if encoded: + parsed = parse_bootstrap_kubeconfig(encoded) + + # Truthiness, not presence: a fresh XR's status.upbound is absent or empty, and every + # one of these is a non-empty string once set. + status_upbound = xr.status.upbound if xr.status and xr.status.upbound else None + init_ready = bool( + status_upbound + and status_upbound.org + and status_upbound.bootstrapCtp + and status_upbound.bootstrapGroup + and status_upbound.spaceHost + ) + + # Observe the bootstrap kubeconfig. Its readiness gates the whole XR's: until + # status.upbound is populated this observer is the ONLY composed resource, and it is + # ready as soon as the Secret exists - so function-auto-ready would report the + # Environment Ready before any group, control plane or IAM resource had been created. + desired.append(("observedCtpKubeconfig", r.k8s_object(f"{name}-bootstrap-ctp-kubeconfig-observed", { + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": up.initKubeconfigSecretRef.name, + "namespace": up.initKubeconfigSecretRef.namespace, + }, + }}, + "providerConfigRef": r.pc_ref(up.initProviderConfigName), + "managementPolicies": ["Observe"], + }))) + ready = {"observedCtpKubeconfig": fnv1.READY_TRUE if init_ready else fnv1.READY_FALSE} + + # One write: resource.update() replaces nested maps rather than merging them. + resource.update(rsp.desired.composite, {"status": {"upbound": { + k: parsed[k] for k in ("bootstrapCtp", "bootstrapGroup", "org", "spaceHost") if parsed.get(k) + }}}) + + if init_ready: + ready.update(self._compose(req, xr, status_upbound, parsed, token_ref, desired)) + + for key, res in desired: + resource.update(rsp.desired.resources[key], res) + for key, value in ready.items(): + rsp.desired.resources[key].ready = value + + log.info("Composed Environment", initialised=init_ready, resources=len(desired)) + return rsp + + def _compose(self, req, xr, st, parsed, token_ref, desired) -> dict: + """Everything after initialisation. Appends to `desired`; returns readiness overrides.""" + name, namespace = xr.metadata.name, xr.metadata.namespace + params = xr.spec.parameters + up = params.upbound + mgmt = r.management_policies(params.deletionPolicy) + bootstrap_pc = f"{st.bootstrapCtp}-ctp" + + # The group carries the namespace. The XRD is Namespaced, so team-a/prod and + # team-b/prod are both valid, while the group - and everything named after it, the Team, + # Robot, Argo secret and every AWS name - is org-wide. + group = f"{st.bootstrapGroup}-{namespace}-{name}" + aws_name_prefix = f"{st.org}-{group}-{name}" + ready = {} + + if up.createCtp: + desired.append(("ctp", r.k8s_object(f"{name}-ctp", { + "readiness": {"policy": "DeriveFromObject"}, + "managementPolicies": mgmt, + "forProvider": {"manifest": { + "apiVersion": "spaces.upbound.io/v1beta1", + "kind": "ControlPlane", + "metadata": {"name": name, "namespace": group}, + "spec": {"class": "default", "crossplane": {"autoUpgrade": {"channel": "Rapid"}}}, + }}, + "providerConfigRef": r.pc_ref(f"{group}-group"), + }))) + + if up.createGroup: + desired.append(("envGroup", r.k8s_object(group, { + "managementPolicies": mgmt, + "forProvider": {"manifest": {"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": group}}}, + "providerConfigRef": r.pc_ref(f"{name}-space"), + }))) + + if up.createArgoSecret: + desired += r.observe_secret( + ctp=name, + name=up.tokenSecretRef.name, + namespace=up.tokenSecretRef.namespace, + provider_config_name=bootstrap_pc, + resource_name="observed-access-token", + ) + token = _dig(_observed(req, "observed-access-token"), "status", "atProvider", "manifest", "data", "token") + if token: + desired += r.argo_server_secret( + access_token=base64.b64decode(token).decode(), + org=st.org, + group=group, + ctp=name, + provider_config_name=bootstrap_pc, + server_ca_data=parsed.get("serverCaData"), + space_host=st.spaceHost, + ) + + pc_common = dict(space_host=st.spaceHost, org=st.org, provider_config_name=bootstrap_pc, + secret_namespace=namespace, token_ref=token_ref) + if up.createCtp: + desired += r.upbound_provider_config(group=group, ctp=name, **pc_common) + # Not gated on createGroup: the ControlPlane and the SharedAWSSecret both reach the + # group through this ProviderConfig, whoever created the group. + desired += r.upbound_provider_config(group=group, **pc_common) + if up.createGroup: + desired += r.upbound_provider_config(prefix=name, **pc_common) + + if up.teamWithRobot is not None: + desired += r.team_with_robot( + group=group, + org=st.org, + token_ref=token_ref, + observed_team_external_name=_dig( + _observed(req, "envTeam"), "metadata", "annotations", "crossplane.io/external-name" + ), + space_provider_config_name=f"{name}-space", + team_name_override=up.teamWithRobot.teamNameOverride, + team_external_name=up.teamWithRobot.teamExternalName, + create_group_admin_binding=up.createGroup, + ) + + for s in up.secretSync or []: + desired += r.synced_secret( + source_ref={"name": s.sourceRef.name, "namespace": s.sourceRef.namespace}, + dest_ref={"name": s.destRef.name, "namespace": s.destRef.namespace}, + provider_config_name=f"{name}-ctp", + ) + + # Every ProviderConfig is ready as soon as it exists: none has a Ready condition for + # function-auto-ready to read. + for key, res in desired: + if res.kind == "ProviderConfig": + ready[key] = fnv1.READY_TRUE + + aws = params.aws + if aws is not None: + aws_pc = r.aws_provider_config( + env_name=group, + role_arn=aws.roleArn, + creds_secret_ref={ + "namespace": aws.credsSecretRef.namespace, + "name": aws.credsSecretRef.name, + "key": "credentials", + } if aws.credsSecretRef else None, + ) + desired += aws_pc + ready[aws_pc[0][0]] = fnv1.READY_TRUE + + if aws.providerRole is not None: + desired += r.crossplane_role( + account_id=aws.accountId, + deletion_policy=params.deletionPolicy, + env_name=group, + ctp_name=name, + name_prefix=aws_name_prefix, + oidc_provider_arn=aws.providerRole.oidcProviderArn, + upbound_org=st.org, + ) + + if aws.sharedSecret is not None: + desired.append(("sharedAWSSecret", self._shared_secret(xr, group, aws_name_prefix))) + + return ready + + @staticmethod + def _shared_secret(xr, group: str, aws_name_prefix: str) -> sasv1.SharedAWSSecret: + """The nested SharedAWSSecret XR, carrying only the settings the caller gave.""" + params = xr.spec.parameters + aws = params.aws + shared = aws.sharedSecret + aws_params = { + "accountId": aws.accountId, + "region": aws.region, + "namePrefix": aws_name_prefix, + "providerConfigRef": {"name": group}, + } + sms = shared.secretsManagerSecret + # Presence, not content: the KCL version tested a typed schema instance, which is + # truthy even when empty. + if sms is not None: + aws_params["secretsManagerSecret"] = { + k: v for k, v in { + "arn": sms.arn or None, + "name": sms.name or None, + # `is not None`, not truthiness: 0 is the value that matters. + "recoveryWindowInDays": sms.recoveryWindowInDays, + "create": sms.create, + }.items() if v is not None + } + spec_params = { + "deletionPolicy": params.deletionPolicy, + "aws": aws_params, + "upbound": { + "group": group, + "controlPlane": xr.metadata.name, + "providerConfigRef": {"name": f"{group}-group"}, + }, + } + # Always present, even for sharedSecret: {} - same typed-instance truthiness as above. + ext = shared.externalSecret + # namespace is always present: KCL's typed model materialised its "default". + external = {"namespace": (ext.namespace if ext is not None and ext.namespace else "default")} + if ext is not None: + if ext.name: + external["name"] = ext.name + if ext.spec is not None: + external["spec"] = _external_secret_spec(ext.spec) + spec_params["externalSecret"] = external + return sasv1.SharedAWSSecret.model_validate({ + "metadata": {"name": f"{xr.metadata.name}-shared-secret"}, + "spec": {"parameters": spec_params}, + }) diff --git a/functions/environments/function/main.py b/functions/environments/function/main.py new file mode 100644 index 0000000..26c8806 --- /dev/null +++ b/functions/environments/function/main.py @@ -0,0 +1,51 @@ +"""The composition function's main CLI.""" + +import click +from crossplane.function import logging, runtime + +from function import fn + + +@click.command() +@click.option( + "--debug", + "-d", + is_flag=True, + help="Emit debug logs.", +) +@click.option( + "--address", + default="0.0.0.0:9443", + show_default=True, + help="Address at which to listen for gRPC connections", +) +@click.option( + "--tls-certs-dir", + help="Serve using mTLS certificates.", + envvar="TLS_SERVER_CERTS_DIR", +) +@click.option( + "--insecure", + is_flag=True, + help="Run without mTLS credentials. " + "If you supply this flag --tls-certs-dir will be ignored.", +) +def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 + """A Crossplane composition function.""" + try: + level = logging.Level.INFO + if debug: + level = logging.Level.DEBUG + logging.configure(level=level) + runtime.serve( + fn.FunctionRunner(), + address, + creds=runtime.load_credentials(tls_certs_dir), + insecure=insecure, + ) + except Exception as e: + click.echo(f"Cannot run function: {e}") + + +if __name__ == "__main__": + cli() diff --git a/functions/environments/function/resources.py b/functions/environments/function/resources.py new file mode 100644 index 0000000..c3a3587 --- /dev/null +++ b/functions/environments/function/resources.py @@ -0,0 +1,408 @@ +"""Builders for the resources an Environment composes. + +Each returns a list of (composition key, resource) pairs; fn.py decides which apply. The +split mirrors the KCL modules this replaced - kubeconfigs and ProviderConfigs, the Argo CD +secret, Team and Robot, secret sync, and AWS - so a reader can hold the two side by side. + +Composition keys matter beyond this file. A changed key makes Crossplane delete the resource +under the old one and create another, so every key here is the one the KCL version actually +produced - including the handful where KCL fell back to the resource's name because merging +metadata had replaced the annotation carrying the intended key. Those are marked. +""" + +import base64 +import json + +from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 +from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 +from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 +from models.io.upbound.m.aws.iam.openidconnectprovider import v1beta1 as oidcv1beta1 +from models.io.upbound.m.aws.iam.role import v1beta1 as rolev1beta1 +from models.io.upbound.m.aws.iam.rolepolicyattachment import v1beta1 as rpav1beta1 +from models.io.upbound.m.aws.providerconfig import v1beta1 as awspcv1beta1 +from models.io.upbound.m.iam.robot import v1alpha1 as robotv1alpha1 +from models.io.upbound.m.iam.robotteammembership import v1alpha1 as rtmv1alpha1 +from models.io.upbound.m.iam.team import v1alpha1 as teamv1alpha1 +from models.io.upbound.m.iam.token import v1alpha1 as tokenv1alpha1 +from models.io.upbound.m.providerconfig import v1alpha1 as upbpcv1alpha1 + +from .compat import kcl_str, object_spec + +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +IAM_NAME_MAX = 64 +OBJECT_API = "kubernetes.m.crossplane.io/v1alpha1" + +TRUST_POLICY = """{{ + "Version": "2012-10-17", + "Statement": [ + {{ + "Effect": "Allow", + "Principal": {{ + "Federated": "arn:aws:iam::{account_id}:oidc-provider/proidc.upbound.io" + }}, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": {{ + "StringEquals": {{ + "proidc.upbound.io:sub": "mcp:{org}/{ctp}:provider:provider-aws", + "proidc.upbound.io:aud": "sts.amazonaws.com" + }} + }} + }} + ] +}}""" + + +def management_policies(deletion_policy: str) -> list[str]: + """Translate the XR's Delete/Orphan parameter into managementPolicies. + + Namespaced (.m.) managed resources have no deletionPolicy; managementPolicies is the only + way to say "do not delete the external resource". + """ + return ["*"] if deletion_policy == "Delete" else ORPHAN + + +def pc_ref(name: str) -> dict: + return {"kind": "ProviderConfig", "name": name} + + +def _simple_hash(s: str) -> str: + # Must stay identical to functions/sharedawssecret: its output is part of AWS names. + return str(abs(len(s) * 31 + sum(ord(c) * (i + 1) for i, c in enumerate(s))))[:8] + + +def truncate_iam_name(name: str, suffix: str) -> str: + """Fit an IAM name into 64 characters, keeping the suffix and hashing the prefix.""" + if len(name) <= IAM_NAME_MAX: + return name + base = name[: len(name) - len(suffix)] + prefix_space = IAM_NAME_MAX - len(suffix) - 8 - 1 + if prefix_space <= 0: + return f"{_simple_hash(base)}{suffix}" + return f"{base[:prefix_space].rstrip('-')}-{_simple_hash(base)}{suffix}" + + +def k8s_object(name: str | None, spec: dict, annotations: dict | None = None) -> objectv1alpha1.Object: + metadata = {} + if name is not None: + metadata["name"] = name + if annotations: + metadata["annotations"] = annotations + return objectv1alpha1.Object.model_validate({"metadata": metadata, "spec": object_spec(spec)}) + + +# --- kubeconfigs and provider-kubernetes ProviderConfigs ---------------------------------- + + +def upbound_kubeconfig(space_host: str, org: str, group: str, ctp: str) -> dict: + """A kubeconfig for the Space (ctp == "") or for one control plane in it. + + Authenticates by running `up organization token`, which provider-kubernetes supplies + with the Upbound token through the ProviderConfig's UpboundTokens identity. + """ + server = ( + f"https://{space_host}" + if ctp == "" + else f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s" + ) + return { + "apiVersion": "v1", + "clusters": [{"cluster": {"insecure-skip-tls-verify": True, "server": server}, "name": "upbound"}], + "contexts": [{ + "context": { + "cluster": "upbound", + "extensions": [{ + "extension": { + "apiVersion": "upbound.io/v1alpha1", + "kind": "SpaceExtension", + "spec": {"cloud": {"organization": org}}, + }, + "name": "spaces.upbound.io/space", + }], + "namespace": group if ctp == "" else "default", + "user": "upbound", + }, + "name": "upbound", + }], + "current-context": "upbound", + "kind": "Config", + "preferences": {}, + "users": [{ + "name": "upbound", + "user": {"exec": { + "apiVersion": "client.authentication.k8s.io/v1", + "args": ["organization", "token"], + "command": "up", + "env": [{"name": "ORGANIZATION", "value": org}, {"name": "UP_PROFILE", "value": "default"}], + "interactiveMode": "IfAvailable", + "provideClusterInfo": False, + }}, + }], + } + + +def upbound_provider_config(*, space_host, org, provider_config_name, secret_namespace, token_ref, + group=None, ctp=None, prefix=None) -> list: + """A provider-kubernetes ProviderConfig for the Space, a group, or a control plane. + + Three resources: the kubeconfig Secret (applied through an Object), the ProviderConfig + that reads it, and a Usage that keeps the Secret until the ProviderConfig is gone. + """ + config_name = f"{ctp}-ctp" if ctp else (f"{group}-group" if group else f"{prefix}-space") + scope = "envCtp" if ctp else ("envGroup" if group else "space") + secret_name = f"{config_name}-kubeconfig" + kubeconfig = kcl_str(upbound_kubeconfig(space_host, org, group or "default", ctp or "")) + return [ + (f"{scope}Kubeconfig", k8s_object(secret_name, { + # The API default, set explicitly: KCL materialised it. + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": secret_name, "namespace": secret_namespace}, + # base64 `data`, not `stringData`: provider-kubernetes records ownership of the + # fields it writes, and stringData is never stored, so the next observe fails. + "data": {"kubeconfig": base64.b64encode(kubeconfig.encode()).decode()}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + })), + # Keyed by name: see the module docstring. + (config_name, k8spcv1alpha1.ProviderConfig.model_validate({ + "metadata": {"name": config_name}, + "spec": { + "credentials": { + "source": "Secret", + "secretRef": {"name": secret_name, "namespace": secret_namespace, "key": "kubeconfig"}, + }, + "identity": { + "type": "UpboundTokens", + "source": "Secret", + "secretRef": token_ref, + }, + }, + })), + (f"{scope}Usage", usagev1beta1.Usage.model_validate({ + "metadata": {"name": secret_name}, + "spec": { + "replayDeletion": True, + "of": {"apiVersion": OBJECT_API, "kind": "Object", "resourceRef": {"name": secret_name}}, + "by": {"apiVersion": OBJECT_API, "kind": "ProviderConfig", "resourceRef": {"name": config_name}}, + }, + })), + ] + + +def observe_secret(*, ctp, name, namespace, provider_config_name, resource_name) -> list: + """An observe-only Object that reads a Secret off the bootstrap control plane.""" + return [(resource_name, k8s_object(f"{ctp}-{resource_name}-observed", { + "managementPolicies": ["Observe"], + "forProvider": {"manifest": { + "apiVersion": "v1", "kind": "Secret", "metadata": {"name": name, "namespace": namespace}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + }))] + + +# --- Argo CD -------------------------------------------------------------------------------- + + +def argo_server_secret(*, access_token, org, group, ctp, provider_config_name, server_ca_data, space_host) -> list: + """Register the environment's control plane as a cluster with Argo CD.""" + cluster = f"{group}-{ctp}" + config = { + "execProviderConfig": { + "apiVersion": "client.authentication.k8s.io/v1", + "command": "up", + "args": ["org", "token"], + "env": {"ORGANIZATION": org, "UP_TOKEN": access_token}, + }, + "tlsClientConfig": {"insecure": False}, + } + # KCL dropped a key whose value was Undefined; the CA is absent when the bootstrap + # kubeconfig carries none. + if server_ca_data is not None: + config["tlsClientConfig"]["caData"] = server_ca_data + b64 = lambda s: base64.b64encode(s.encode()).decode() + return [("ctp-argocd", k8s_object(f"{ctp}-ctp-argocd-secret", { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": cluster, + "namespace": "argocd", + "labels": {"argocd.argoproj.io/secret-type": "cluster"}, + }, + "type": "Opaque", + "data": { + "name": b64(cluster), + "server": b64(f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s"), + "config": b64(json.dumps(config)), + }, + }}, + "providerConfigRef": pc_ref(provider_config_name), + }))] + + +# --- Team and Robot ------------------------------------------------------------------------- + + +def team_with_robot(*, group, org, token_ref, observed_team_external_name, space_provider_config_name, + team_name_override=None, team_external_name=None, create_group_admin_binding=False) -> list: + """A Team, a Robot in it with a Token, and optionally admin rights for the Team on the group.""" + upbound_pc = pc_ref(f"{group}-upbound") + team_meta = {"name": f"{group}-team"} + if team_external_name: + team_meta["annotations"] = {"crossplane.io/external-name": team_external_name} + items = [ + # Keyed by name: see the module docstring. + (f"{group}-upbound", upbpcv1alpha1.ProviderConfig.model_validate({ + "metadata": {"name": f"{group}-upbound"}, + "spec": { + "credentials": {"secretRef": token_ref, "source": "Secret"}, + "organization": org, + }, + })), + ("envRobot", robotv1alpha1.Robot.model_validate({ + "metadata": {"name": f"{group}-robot"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"description": f"Robot for {group}", "name": f"{group}-bot", "owner": {"name": org}}, + "providerConfigRef": upbound_pc, + }, + })), + ("envRobotToken", tokenv1alpha1.Token.model_validate({ + "metadata": {"name": f"{group}-robot-token"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"name": group, "owner": {"idRef": {"name": f"{group}-robot"}, "type": "robots"}}, + "providerConfigRef": upbound_pc, + "writeConnectionSecretToRef": {"name": f"{group}-robot-token"}, + }, + })), + ("envTeam", teamv1alpha1.Team.model_validate({ + "metadata": team_meta, + "spec": { + "managementPolicies": ORPHAN, + "forProvider": {"name": team_name_override or f"{group}-team", "organizationName": org}, + "providerConfigRef": upbound_pc, + }, + })), + ("envRobotTeamMembership", rtmv1alpha1.RobotTeamMembership.model_validate({ + "metadata": {"name": f"{group}-robot-team-membership"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"robotIdRef": {"name": f"{group}-robot"}, "teamIdRef": {"name": f"{group}-team"}}, + "providerConfigRef": upbound_pc, + }, + })), + ] + if create_group_admin_binding: + subject = {"kind": "UpboundTeam", "role": "admin"} + # The Team's Upbound ID exists only once the Team has been created; until then the + # subject has no name and the binding cannot apply yet. KCL behaved the same way. + if observed_team_external_name: + subject["name"] = observed_team_external_name + items.append(("teamAdminBinding", k8s_object(f"{group}-admin-binding", { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "authorization.spaces.upbound.io/v1alpha1", + "kind": "ObjectRoleBinding", + "metadata": {"name": f"{group}-admin-binding", "namespace": group}, + "spec": { + "object": {"apiGroup": "core", "resource": "namespaces", "name": group}, + "subjects": [subject], + }, + }}, + "providerConfigRef": pc_ref(space_provider_config_name), + }))) + return items + + +# --- secret sync ---------------------------------------------------------------------------- + + +def synced_secret(*, source_ref, dest_ref, provider_config_name) -> list: + """Copy a Secret from the bootstrap control plane into the environment's control plane.""" + key = f"{source_ref['namespace']}-{source_ref['name']}-to-{dest_ref['namespace']}-{dest_ref['name']}-syncedSecret" + return [(key, k8s_object(None, { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": dest_ref["name"], "namespace": dest_ref["namespace"]}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + "references": [{ + "patchesFrom": { + "apiVersion": "v1", + "kind": "Secret", + "name": source_ref["name"], + "namespace": source_ref["namespace"], + "fieldPath": "data", + }, + "toFieldPath": "data", + }], + }))] + + +# --- AWS ------------------------------------------------------------------------------------ + + +def aws_provider_config(*, env_name, role_arn=None, creds_secret_ref=None) -> list: + if role_arn: + credentials = {"source": "Upbound", "upbound": {"webIdentity": {"roleARN": role_arn}}} + else: + credentials = {"source": "Secret"} + if creds_secret_ref: + credentials["secretRef"] = creds_secret_ref + # Keyed by name: see the module docstring. + return [(env_name, awspcv1beta1.ProviderConfig.model_validate({ + "metadata": {"name": env_name}, + "spec": {"credentials": credentials}, + }))] + + +def crossplane_role(*, account_id, deletion_policy, env_name, ctp_name, name_prefix, oidc_provider_arn, + upbound_org) -> list: + """An admin IAM role the environment's provider-aws assumes through Upbound's OIDC provider.""" + mgmt = management_policies(deletion_policy) + role_name = truncate_iam_name(f"{name_prefix}-admin", "-admin") + oidc_name = f"{name_prefix}-oidc-provider" + return [ + ("iamAdminRole", rolev1beta1.Role.model_validate({ + "metadata": {"name": role_name}, + "spec": { + "managementPolicies": mgmt, + "forProvider": { + "assumeRolePolicy": TRUST_POLICY.format(account_id=account_id, org=upbound_org, ctp=ctp_name), + }, + "providerConfigRef": pc_ref(env_name), + }, + })), + ("iamAdminRoleAttach", rpav1beta1.RolePolicyAttachment.model_validate({ + "metadata": {"name": role_name}, + "spec": { + "managementPolicies": mgmt, + "forProvider": { + "roleSelector": {"matchControllerRef": True}, + "policyArn": "arn:aws:iam::aws:policy/AdministratorAccess", + }, + "providerConfigRef": pc_ref(env_name), + }, + })), + # Keyed by name: see the module docstring. + (oidc_name, oidcv1beta1.OpenIDConnectProvider.model_validate({ + "metadata": { + "name": oidc_name, + "annotations": {"crossplane.io/external-name": oidc_provider_arn} if oidc_provider_arn else {}, + }, + "spec": { + # Adoption implies orphaning, whatever deletionPolicy says: AWS allows one OIDC + # provider per URL per account, and proidc.upbound.io is shared by every Upbound + # integration in it. Deleting an adopted one would break all of them. + "managementPolicies": ORPHAN if oidc_provider_arn else mgmt, + "forProvider": {"clientIdList": ["sts.amazonaws.com"], "url": "https://proidc.upbound.io"}, + "providerConfigRef": pc_ref(env_name), + }, + })), + ] diff --git a/functions/environments/pyproject.toml b/functions/environments/pyproject.toml new file mode 100644 index 0000000..dbb77b5 --- /dev/null +++ b/functions/environments/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "function" +description = "A Crossplane composition function." +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "Apache-2.0" +dependencies = [ + "crossplane-function-sdk-python==0.15.1", + "click==8.3.2", + "grpcio>=1.73.1", + "pyyaml>=6.0", + "crossplane-models @ file:./../../.up/python", +] +dynamic = ["version"] + +[project.scripts] +function = "function.main:cli" + +[tool.hatch.build.targets.wheel] +packages = ["function"] + +[tool.hatch.version] +path = "function/__version__.py" +validate-bump = false + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/functions/sharedawssecret/README.md b/functions/sharedawssecret/README.md new file mode 100644 index 0000000..7bdbd95 --- /dev/null +++ b/functions/sharedawssecret/README.md @@ -0,0 +1,4 @@ +# Composition Function + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition function here. diff --git a/functions/sharedawssecret/function/__init__.py b/functions/sharedawssecret/function/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/sharedawssecret/function/__version__.py b/functions/sharedawssecret/function/__version__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/functions/sharedawssecret/function/__version__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/functions/sharedawssecret/function/fn.py b/functions/sharedawssecret/function/fn.py new file mode 100644 index 0000000..a34c700 --- /dev/null +++ b/functions/sharedawssecret/function/fn.py @@ -0,0 +1,327 @@ +"""SharedAWSSecret composition function. + +Makes an AWS Secrets Manager secret readable from an Upbound control plane. For a +SharedAWSSecret it composes: + +- the Secrets Manager secret itself, unless creation is switched off +- an IAM user, a read-only policy scoped to that secret, the attachment between them, and + an access key - SharedSecretStore cannot assume an IAM role yet, so it needs static keys +- a copy of that access key into the environment's group, where the store can read it +- a SharedSecretStore pointing at Secrets Manager, and a SharedExternalSecret that syncs + the secret into the environment's control plane +""" + +import json + +import grpc +from crossplane.function import logging, resource, response +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 + +from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.m.aws.iam.accesskey import v1beta1 as accesskeyv1beta1 +from models.io.upbound.m.aws.iam.policy import v1beta1 as policyv1beta1 +from models.io.upbound.m.aws.iam.user import v1beta1 as userv1beta1 +from models.io.upbound.m.aws.iam.userpolicyattachment import v1beta1 as upav1beta1 +from models.io.upbound.m.aws.secretsmanager.secret import v1beta1 as smsecretv1beta1 +from models.io.upbound.sa.sharedawssecret import v1 as sasv1 + +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +IAM_NAME_MAX = 64 + + +def _simple_hash(s: str) -> str: + """Position-weighted character sum, truncated to 8 digits. + + Not a cryptographic hash, and it does not need to be: it only has to be stable, because + its output becomes part of an AWS resource name. It must stay identical to the KCL + original it replaces - a different value renames, and so replaces, the IAM user. + """ + return str(abs(len(s) * 31 + sum(ord(c) * (i + 1) for i, c in enumerate(s))))[:8] + + +def truncate_iam_name(name: str, suffix: str) -> str: + """Fit an IAM name into 64 characters, keeping the suffix and hashing the prefix.""" + if len(name) <= IAM_NAME_MAX: + return name + base = name[: len(name) - len(suffix)] + prefix_space = IAM_NAME_MAX - len(suffix) - 8 - 1 + if prefix_space <= 0: + return f"{_simple_hash(base)}{suffix}" + return f"{base[:prefix_space].rstrip('-')}-{_simple_hash(base)}{suffix}" + + +def _dig(d: dict, *path): + """Walk nested dicts, returning None at the first missing or empty level.""" + for key in path: + if not isinstance(d, dict): + return None + d = d.get(key) + return d + + +# Defaults the KCL implementation emitted without setting them - its typed models +# materialise every schema default into the output. They are the provider's and +# External Secrets Operator's own defaults, so they change nothing on a cluster, but they are +# part of the rendered desired state, and the port keeps that output identical. +OBJECT_DEFAULTS = {"deletionPropagationPolicy": "Background"} +REMOTE_REF_DEFAULTS = {"conversionStrategy": "Default", "decodingStrategy": "None", "metadataPolicy": "None"} +TARGET_DEFAULTS = {"creationPolicy": "Owner", "deletionPolicy": "Retain"} +TEMPLATE_DEFAULTS = {"engineVersion": "v2", "mergePolicy": "Replace"} + + +def _with_defaults(d: dict, defaults: dict) -> dict: + """Fill in defaults under the caller's values: anything explicitly set wins.""" + return {**defaults, **d} + + +def _object_spec(**kwargs) -> objectv1alpha1.Spec: + """An Object spec carrying the two provider-kubernetes defaults KCL materialised.""" + kwargs["forProvider"] = objectv1alpha1.ForProvider(**OBJECT_DEFAULTS, **kwargs["forProvider"]) + return objectv1alpha1.Spec(watch=False, **kwargs) + + +class FunctionRunner(grpcv1.FunctionRunnerService): + """A FunctionRunner handles gRPC RunFunctionRequests.""" + + def __init__(self): + """Create a new FunctionRunner.""" + self.log = logging.get_logger() + + async def RunFunction( + self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext + ) -> fnv1.RunFunctionResponse: + """Run the function.""" + log = self.log.bind(tag=req.meta.tag) + rsp = response.to(req) + + raw = resource.struct_to_dict(req.observed.composite.resource) + xr = sasv1.SharedAWSSecret(**raw) + params = xr.spec.parameters + aws = params.aws + sms = aws.secretsManagerSecret or sasv1.SecretsManagerSecret() + + deletion_policy = params.deletionPolicy or "Orphan" + mgmt = ["*"] if deletion_policy == "Delete" else ORPHAN + # Opt-out, not opt-in: only an explicit false disables creation. The block itself is + # optional, and Environment omits it whenever sharedSecret carries no settings. + create_secret = sms.create is not False + + group = params.upbound.group + ctp = params.upbound.controlPlane + aws_secret_name = sms.name or f"{aws.namePrefix}-config" + aws_pc = {"kind": "ProviderConfig", "name": aws.providerConfigRef.name} + upbound_pc = {"kind": "ProviderConfig", "name": params.upbound.providerConfigRef.name} + iam_name = truncate_iam_name(f"{xr.metadata.name}-{aws_secret_name}-secrets-read", "-secrets-read") + key_secret_name = f"{group}-secrets-read-access-key" + + # User-supplied pass-through values come from the raw request, not the typed model, + # so they reach the manifest exactly as written - no defaults added, nothing reordered. + raw_ext = _dig(raw, "spec", "parameters", "externalSecret") or {} + secret_labels = _dig(raw_ext, "spec", "target", "template", "metadata", "labels") or {} + secret_template_data = _dig(raw_ext, "spec", "target", "template", "data") + secret_data = _dig(raw_ext, "spec", "data") + secret_namespace = raw_ext.get("namespace") or "default" + external_secret_name = raw_ext.get("name") or ctp + + # --- IAM user workaround: needed until SharedSecretStore supports IAM roles --- + resource.update( + rsp.desired.resources["iamUserSecretRead"], + userv1beta1.User( + metadata=k8s.ObjectMeta(name=iam_name), + spec=userv1beta1.Spec( + managementPolicies=mgmt, + forProvider=userv1beta1.ForProvider(), + providerConfigRef=aws_pc, + ), + ), + ) + resource.update( + rsp.desired.resources["iamPolicySecretRead"], + policyv1beta1.Policy( + metadata=k8s.ObjectMeta(name=iam_name), + spec=policyv1beta1.Spec( + managementPolicies=mgmt, + forProvider=policyv1beta1.ForProvider( + policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecretVersionIds", + ], + "Resource": [ + f"arn:aws:secretsmanager:{aws.region}:{aws.accountId}:secret:{aws_secret_name}-*", + ], + }], + }), + ), + providerConfigRef=aws_pc, + ), + ), + ) + resource.update( + rsp.desired.resources["iamPolicySecretReadAttach"], + upav1beta1.UserPolicyAttachment( + metadata=k8s.ObjectMeta(name=iam_name), + spec=upav1beta1.Spec( + managementPolicies=mgmt, + forProvider=upav1beta1.ForProvider( + policyArnSelector=upav1beta1.PolicyArnSelector(matchControllerRef=True), + userSelector=upav1beta1.UserSelector(matchControllerRef=True), + ), + providerConfigRef=aws_pc, + ), + ), + ) + resource.update( + rsp.desired.resources["iamUserAccessKey"], + accesskeyv1beta1.AccessKey( + metadata=k8s.ObjectMeta(name=iam_name), + spec=accesskeyv1beta1.Spec( + managementPolicies=mgmt, + forProvider=accesskeyv1beta1.ForProvider( + userSelector=accesskeyv1beta1.UserSelector(matchControllerRef=True), + ), + providerConfigRef=aws_pc, + writeConnectionSecretToRef=accesskeyv1beta1.WriteConnectionSecretToRef( + name=key_secret_name, + ), + ), + ), + ) + # Copy the access key's connection secret into the environment's group. + resource.update( + rsp.desired.resources["envIamUserKeySecret"], + objectv1alpha1.Object( + metadata=k8s.ObjectMeta(name=key_secret_name), + spec=_object_spec( + managementPolicies=mgmt, + forProvider=dict(manifest={ + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": key_secret_name, "namespace": group}, + }), + providerConfigRef=upbound_pc, + references=[objectv1alpha1.Reference( + patchesFrom=objectv1alpha1.PatchesFrom( + apiVersion="v1", + kind="Secret", + name=key_secret_name, + # AccessKey writes its connection secret into its own namespace - + # v2 dropped writeConnectionSecretToRef.namespace - which is the + # XR's namespace, so that is where the copy reads it from. + namespace=xr.metadata.namespace, + fieldPath="data", + ), + toFieldPath="data", + )], + ), + ), + ) + + if create_secret: + for_provider = smsecretv1beta1.ForProvider(name=aws_secret_name, region=aws.region) + # `is not None`, not truthiness: 0 - delete immediately, no recovery window - is + # the value that matters, and it is falsy. + if sms.recoveryWindowInDays is not None: + for_provider.recoveryWindowInDays = sms.recoveryWindowInDays + if deletion_policy == "Delete": + for_provider.forceOverwriteReplicaSecret = True + # Keyed by its name, not "secretsmanagerSecret": the KCL version meant to use that + # key, but merging annotations over its metadata replaced the annotation that + # carried it, and function-kcl then fell back to the resource name. Changing a + # composition key makes Crossplane delete the old resource and create a new one - + # for a Secrets Manager secret under deletionPolicy: Delete, that deletes the + # secret's contents - so the key the KCL version actually used is the one kept. + secret_key = f"{aws_secret_name}-secretsmanager-secret" + resource.update( + rsp.desired.resources[secret_key], + smsecretv1beta1.Secret( + metadata=k8s.ObjectMeta( + name=f"{aws_secret_name}-secretsmanager-secret", + annotations={"crossplane.io/external-name": sms.arn} if sms.arn else {}, + ), + spec=smsecretv1beta1.Spec( + managementPolicies=mgmt, + forProvider=for_provider, + providerConfigRef=aws_pc, + ), + ), + ) + + # Secret store backed by Secrets Manager, readable from the environment's control plane. + resource.update( + rsp.desired.resources["sharedSecretsStore"], + objectv1alpha1.Object( + metadata=k8s.ObjectMeta(name=f"{ctp}-sss"), + spec=_object_spec( + managementPolicies=mgmt, + forProvider=dict(manifest={ + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedSecretStore", + "metadata": {"name": ctp, "namespace": group}, + "spec": { + "controlPlaneSelector": {"names": [ctp]}, + "namespaceSelector": {"names": [secret_namespace]}, + "provider": {"aws": { + "service": "SecretsManager", + "region": aws.region, + "auth": {"secretRef": { + "accessKeyIDSecretRef": {"name": key_secret_name, "key": "username"}, + "secretAccessKeySecretRef": {"name": key_secret_name, "key": "password"}, + }}, + }}, + }, + }), + providerConfigRef=upbound_pc, + ), + ), + ) + + # External secret that syncs the secret into the environment's control plane. + template = _with_defaults( + {"metadata": {"labels": secret_labels} if secret_labels else {}}, TEMPLATE_DEFAULTS + ) + if secret_template_data is not None: + template["data"] = secret_template_data + external_secret_spec = { + "refreshInterval": "1m", + "secretStoreRef": {"name": ctp, "kind": "ClusterSecretStore"}, + "target": _with_defaults({"name": external_secret_name, "template": template}, TARGET_DEFAULTS), + } + if secret_data is not None: + external_secret_spec["data"] = [ + {**item, "remoteRef": _with_defaults(item["remoteRef"], REMOTE_REF_DEFAULTS)} + for item in secret_data + ] + else: + external_secret_spec["dataFrom"] = [ + {"extract": _with_defaults({"key": aws_secret_name}, REMOTE_REF_DEFAULTS)} + ] + resource.update( + rsp.desired.resources["sharedExternalSecret"], + objectv1alpha1.Object( + metadata=k8s.ObjectMeta(name=f"{ctp}-ses"), + spec=_object_spec( + managementPolicies=mgmt, + forProvider=dict(manifest={ + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedExternalSecret", + "metadata": {"name": external_secret_name, "namespace": group}, + "spec": { + "controlPlaneSelector": {"names": [ctp]}, + "namespaceSelector": {"names": [secret_namespace]}, + "externalSecretSpec": external_secret_spec, + }, + }), + providerConfigRef=upbound_pc, + ), + ), + ) + + log.info("Composed SharedAWSSecret", secret=aws_secret_name, create=create_secret) + return rsp diff --git a/functions/sharedawssecret/function/main.py b/functions/sharedawssecret/function/main.py new file mode 100644 index 0000000..26c8806 --- /dev/null +++ b/functions/sharedawssecret/function/main.py @@ -0,0 +1,51 @@ +"""The composition function's main CLI.""" + +import click +from crossplane.function import logging, runtime + +from function import fn + + +@click.command() +@click.option( + "--debug", + "-d", + is_flag=True, + help="Emit debug logs.", +) +@click.option( + "--address", + default="0.0.0.0:9443", + show_default=True, + help="Address at which to listen for gRPC connections", +) +@click.option( + "--tls-certs-dir", + help="Serve using mTLS certificates.", + envvar="TLS_SERVER_CERTS_DIR", +) +@click.option( + "--insecure", + is_flag=True, + help="Run without mTLS credentials. " + "If you supply this flag --tls-certs-dir will be ignored.", +) +def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 + """A Crossplane composition function.""" + try: + level = logging.Level.INFO + if debug: + level = logging.Level.DEBUG + logging.configure(level=level) + runtime.serve( + fn.FunctionRunner(), + address, + creds=runtime.load_credentials(tls_certs_dir), + insecure=insecure, + ) + except Exception as e: + click.echo(f"Cannot run function: {e}") + + +if __name__ == "__main__": + cli() diff --git a/functions/sharedawssecret/pyproject.toml b/functions/sharedawssecret/pyproject.toml new file mode 100644 index 0000000..f59b85c --- /dev/null +++ b/functions/sharedawssecret/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "function" +description = "A Crossplane composition function." +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "Apache-2.0" +dependencies = [ + "crossplane-function-sdk-python==0.15.1", + "click==8.3.2", + "grpcio>=1.73.1", + "crossplane-models @ file:./../../.up/python", +] +dynamic = ["version"] + +[project.scripts] +function = "function.main:cli" + +[tool.hatch.build.targets.wheel] +packages = ["function"] + +[tool.hatch.version] +path = "function/__version__.py" +validate-bump = false + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/functions/upboundreposet/README.md b/functions/upboundreposet/README.md new file mode 100644 index 0000000..7bdbd95 --- /dev/null +++ b/functions/upboundreposet/README.md @@ -0,0 +1,4 @@ +# Composition Function + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition function here. diff --git a/functions/upboundreposet/function/__init__.py b/functions/upboundreposet/function/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/upboundreposet/function/__version__.py b/functions/upboundreposet/function/__version__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/functions/upboundreposet/function/__version__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/functions/upboundreposet/function/fn.py b/functions/upboundreposet/function/fn.py new file mode 100644 index 0000000..655ac17 --- /dev/null +++ b/functions/upboundreposet/function/fn.py @@ -0,0 +1,121 @@ +"""UpboundRepoSet composition function. + +Manages Upbound repositories and who may use them. For an UpboundRepoSet it composes: + +- one Repository per entry in spec.parameters.repositories +- one Permission per (repository, team) pair in spec.parameters.permissions.teams +- the provider-upbound ProviderConfig both of those authenticate through, built from + spec.parameters.tokenSecretRef +""" + +import grpc +from crossplane.function import logging, resource, response +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 + +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.m.providerconfig import v1alpha1 as pcv1alpha1 +from models.io.upbound.m.repository import v1alpha1 as repov1alpha1 +from models.io.upbound.m.repository.permission import v1alpha1 as permv1alpha1 +from models.io.upbound.sa.upboundreposet import v1 as reposetv1 + +# Orphan on delete: a repository outlives the UpboundRepoSet that created it. Namespaced +# MRs have no deletionPolicy; this is the managementPolicies equivalent. +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] + + +class FunctionRunner(grpcv1.FunctionRunnerService): + """A FunctionRunner handles gRPC RunFunctionRequests.""" + + def __init__(self): + """Create a new FunctionRunner.""" + self.log = logging.get_logger() + + async def RunFunction( + self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext + ) -> fnv1.RunFunctionResponse: + """Run the function.""" + log = self.log.bind(tag=req.meta.tag) + rsp = response.to(req) + + xr = reposetv1.UpboundRepoSet( + **resource.struct_to_dict(req.observed.composite.resource) + ) + params = xr.spec.parameters + org = params.organization + pc_name = f"{xr.metadata.name}-{org}-reposet" + pc_ref = {"kind": "ProviderConfig", "name": pc_name} + repositories = params.repositories or {} + teams = (params.permissions.teams or {}) if params.permissions else {} + + for repo, opts in repositories.items(): + resource.update( + rsp.desired.resources[f"{org}-{repo}"], + repov1alpha1.Repository( + metadata=k8s.ObjectMeta( + # The misspelling is inherited from the KCL function, whose output + # this port reproduces exactly: correcting it changes how + # provider-upbound identifies existing repositories, which is a + # behaviour change to make deliberately, on its own. + annotations={"crosslane.io/external-name": repo}, + ), + spec=repov1alpha1.Spec( + managementPolicies=ORPHAN, + forProvider=repov1alpha1.ForProvider( + name=repo, + organizationName=org, + # A per-repository setting wins over the set-wide default. + public=opts.public if opts.public is not None else params.settings.public, + publish=opts.publish if opts.publish is not None else params.settings.publish, + ), + providerConfigRef=pc_ref, + ), + ), + ) + + for repo in repositories: + for team, grant in teams.items(): + resource.update( + rsp.desired.resources[f"{org}-{repo}-{team}"], + permv1alpha1.Permission( + spec=permv1alpha1.Spec( + # The API default, set explicitly: the KCL function emitted it + # because KCL models materialise defaults, and the rendered + # output is kept identical across the port. + managementPolicies=["*"], + forProvider=permv1alpha1.ForProvider( + organizationName=org, + repository=repo, + teamIdRef=permv1alpha1.TeamIdRef(name=team), + permission=grant.permission, + ), + providerConfigRef=pc_ref, + ), + ), + ) + + resource.update( + rsp.desired.resources["providerConfigUpbound"], + pcv1alpha1.ProviderConfig( + metadata=k8s.ObjectMeta(name=pc_name), + spec=pcv1alpha1.Spec( + credentials=pcv1alpha1.Credentials( + # Set explicitly: `source` is a Literal default, and update() + # serializes with exclude_unset, so leaving it to the default would + # drop it from the desired resource altogether. + source="Secret", + secretRef=pcv1alpha1.SecretRef( + name=params.tokenSecretRef.name, + namespace=params.tokenSecretRef.namespace, + key=params.tokenSecretRef.key, + ), + ), + organization=org, + ), + ), + ) + # A ProviderConfig has no Ready condition of its own for function-auto-ready to read. + rsp.desired.resources["providerConfigUpbound"].ready = fnv1.READY_TRUE + + log.info("Composed UpboundRepoSet", repositories=len(repositories), teams=len(teams)) + return rsp diff --git a/functions/upboundreposet/function/main.py b/functions/upboundreposet/function/main.py new file mode 100644 index 0000000..26c8806 --- /dev/null +++ b/functions/upboundreposet/function/main.py @@ -0,0 +1,51 @@ +"""The composition function's main CLI.""" + +import click +from crossplane.function import logging, runtime + +from function import fn + + +@click.command() +@click.option( + "--debug", + "-d", + is_flag=True, + help="Emit debug logs.", +) +@click.option( + "--address", + default="0.0.0.0:9443", + show_default=True, + help="Address at which to listen for gRPC connections", +) +@click.option( + "--tls-certs-dir", + help="Serve using mTLS certificates.", + envvar="TLS_SERVER_CERTS_DIR", +) +@click.option( + "--insecure", + is_flag=True, + help="Run without mTLS credentials. " + "If you supply this flag --tls-certs-dir will be ignored.", +) +def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 + """A Crossplane composition function.""" + try: + level = logging.Level.INFO + if debug: + level = logging.Level.DEBUG + logging.configure(level=level) + runtime.serve( + fn.FunctionRunner(), + address, + creds=runtime.load_credentials(tls_certs_dir), + insecure=insecure, + ) + except Exception as e: + click.echo(f"Cannot run function: {e}") + + +if __name__ == "__main__": + cli() diff --git a/functions/upboundreposet/pyproject.toml b/functions/upboundreposet/pyproject.toml new file mode 100644 index 0000000..f59b85c --- /dev/null +++ b/functions/upboundreposet/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "function" +description = "A Crossplane composition function." +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "Apache-2.0" +dependencies = [ + "crossplane-function-sdk-python==0.15.1", + "click==8.3.2", + "grpcio>=1.73.1", + "crossplane-models @ file:./../../.up/python", +] +dynamic = ["version"] + +[project.scripts] +function = "function.main:cli" + +[tool.hatch.build.targets.wheel] +packages = ["function"] + +[tool.hatch.version] +path = "function/__version__.py" +validate-bump = false + +[tool.hatch.metadata] +allow-direct-references = true From 1bed978a2780ffd88b78c5d88c2f0f800abe7889 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Fri, 25 Sep 2026 18:23:04 +0200 Subject: [PATCH 03/10] ci: build functions one at a time Every Python function build mounts the same up-python-sdk-pip-cache Docker volume. On a fresh runner that volume starts empty, and concurrent builds race creating its directories: "mkdir ...: file exists". Reproduced locally by removing the volume first - one of two builds failed at the default concurrency, three of three passed at UP_MAX_CONCURRENCY=1. e2e.yaml runs on pull_request_target, so its copy takes effect only once this reaches main. --- .github/workflows/ci.yaml | 5 +++++ .github/workflows/composition-tests.yaml | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 190a5a7..6c0782b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,11 @@ on: required: false env: + # Build functions one at a time. Every Python function build mounts the same + # up-python-sdk-pip-cache Docker volume, and on a fresh runner - where that volume starts + # empty - concurrent builds race creating its directories and fail with + # "mkdir ...: file exists". + UP_MAX_CONCURRENCY: "1" UP_API_TOKEN: ${{ secrets.UP_API_TOKEN }} UP_ROBOT_ID: ${{ secrets.UP_ROBOT_ID }} UP_ORG: ${{ secrets.UP_ORG }} diff --git a/.github/workflows/composition-tests.yaml b/.github/workflows/composition-tests.yaml index 104bcf7..08ab377 100644 --- a/.github/workflows/composition-tests.yaml +++ b/.github/workflows/composition-tests.yaml @@ -6,6 +6,13 @@ on: - main pull_request: {} +env: + # Build functions one at a time. Every Python function build mounts the same + # up-python-sdk-pip-cache Docker volume, and on a fresh runner - where that volume starts + # empty - concurrent builds race creating its directories and fail with + # "mkdir ...: file exists". + UP_MAX_CONCURRENCY: "1" + jobs: composition-tests: runs-on: ubuntu-latest From fde8cb6752978a1d1741ecc56c50a14cc9149249 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Fri, 25 Sep 2026 18:35:49 +0200 Subject: [PATCH 04/10] test: rewrite tests in Python Converts all twelve test modules - eleven composition suites and the e2e test - from KCL to the Python SDK layout (`test/__main__.py`, printing the CompositionTest or E2ETest as YAML). Each Python module generates a manifest identical to the one its KCL module generated: every assertion, input, observed resource and setting, compared field by field. That includes values KCL's typed models had been materialising without the source ever writing them - Object defaults, XRD defaults on inline XRs, `spec.parameters: {}` - which are assertions the suite really made, so they are now spelled out. Run against the Python functions, all 20 composition tests pass, and the full render matches the original KCL-functions-and-KCL-tests baseline except for the two deviations recorded in the function rewrite. The README's development section now describes the Python toolchain. --- README.md | 27 +- tests/e2etest-environment/README.md | 4 + tests/e2etest-environment/kcl.mod | 6 - tests/e2etest-environment/main.k | 271 ----- tests/e2etest-environment/model | 1 - tests/e2etest-environment/pyproject.toml | 21 + tests/e2etest-environment/test/__init__.py | 0 tests/e2etest-environment/test/__main__.py | 294 +++++ .../README.md | 4 + .../kcl.mod | 6 - .../kcl.mod.lock | 5 - .../main.k | 201 ---- .../model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 172 +++ .../test-environment-existing-group/README.md | 4 + tests/test-environment-existing-group/kcl.mod | 6 - .../kcl.mod.lock | 5 - tests/test-environment-existing-group/main.k | 105 -- tests/test-environment-existing-group/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 133 +++ .../README.md | 4 + .../test-environment-namespaced-names/kcl.mod | 6 - .../kcl.mod.lock | 5 - .../test-environment-namespaced-names/main.k | 123 -- tests/test-environment-namespaced-names/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 144 +++ .../README.md | 4 + .../kcl.mod | 6 - .../kcl.mod.lock | 5 - .../main.k | 508 --------- .../model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 268 +++++ .../README.md | 4 + .../test-environment-team-with-robot/kcl.mod | 6 - .../kcl.mod.lock | 5 - tests/test-environment-team-with-robot/main.k | 158 --- tests/test-environment-team-with-robot/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 183 +++ .../test-environment-uninitialized/README.md | 4 + tests/test-environment-uninitialized/kcl.mod | 6 - tests/test-environment-uninitialized/main.k | 68 -- tests/test-environment-uninitialized/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 71 ++ tests/test-environment/README.md | 4 + tests/test-environment/kcl.mod | 6 - tests/test-environment/kcl.mod.lock | 5 - tests/test-environment/main.k | 778 ------------- tests/test-environment/model | 1 - tests/test-environment/pyproject.toml | 21 + tests/test-environment/test/__init__.py | 0 tests/test-environment/test/__main__.py | 498 ++++++++ .../test-sharedawssecret-with-data/README.md | 4 + tests/test-sharedawssecret-with-data/kcl.mod | 6 - .../kcl.mod.lock | 5 - tests/test-sharedawssecret-with-data/main.k | 308 ----- tests/test-sharedawssecret-with-data/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 216 ++++ tests/test-sharedawssecret/README.md | 4 + tests/test-sharedawssecret/kcl.mod | 7 - tests/test-sharedawssecret/kcl.mod.lock | 12 - tests/test-sharedawssecret/main.k | 1012 ----------------- tests/test-sharedawssecret/model | 1 - tests/test-sharedawssecret/pyproject.toml | 21 + tests/test-sharedawssecret/test/__init__.py | 0 tests/test-sharedawssecret/test/__main__.py | 345 ++++++ .../test-upboundreposet-repo-config/README.md | 4 + tests/test-upboundreposet-repo-config/kcl.mod | 6 - .../kcl.mod.lock | 5 - tests/test-upboundreposet-repo-config/main.k | 160 --- tests/test-upboundreposet-repo-config/model | 1 - .../pyproject.toml | 21 + .../test/__init__.py | 0 .../test/__main__.py | 67 ++ tests/test-upboundreposet/README.md | 4 + tests/test-upboundreposet/kcl.mod | 6 - tests/test-upboundreposet/kcl.mod.lock | 5 - tests/test-upboundreposet/main.k | 175 --- tests/test-upboundreposet/model | 1 - tests/test-upboundreposet/pyproject.toml | 21 + tests/test-upboundreposet/test/__init__.py | 0 tests/test-upboundreposet/test/__main__.py | 101 ++ 95 files changed, 2818 insertions(+), 4010 deletions(-) create mode 100644 tests/e2etest-environment/README.md delete mode 100644 tests/e2etest-environment/kcl.mod delete mode 100644 tests/e2etest-environment/main.k delete mode 120000 tests/e2etest-environment/model create mode 100644 tests/e2etest-environment/pyproject.toml create mode 100644 tests/e2etest-environment/test/__init__.py create mode 100644 tests/e2etest-environment/test/__main__.py create mode 100644 tests/test-environment-deletion-policy-delete/README.md delete mode 100644 tests/test-environment-deletion-policy-delete/kcl.mod delete mode 100644 tests/test-environment-deletion-policy-delete/kcl.mod.lock delete mode 100644 tests/test-environment-deletion-policy-delete/main.k delete mode 120000 tests/test-environment-deletion-policy-delete/model create mode 100644 tests/test-environment-deletion-policy-delete/pyproject.toml create mode 100644 tests/test-environment-deletion-policy-delete/test/__init__.py create mode 100644 tests/test-environment-deletion-policy-delete/test/__main__.py create mode 100644 tests/test-environment-existing-group/README.md delete mode 100644 tests/test-environment-existing-group/kcl.mod delete mode 100644 tests/test-environment-existing-group/kcl.mod.lock delete mode 100644 tests/test-environment-existing-group/main.k delete mode 120000 tests/test-environment-existing-group/model create mode 100644 tests/test-environment-existing-group/pyproject.toml create mode 100644 tests/test-environment-existing-group/test/__init__.py create mode 100644 tests/test-environment-existing-group/test/__main__.py create mode 100644 tests/test-environment-namespaced-names/README.md delete mode 100644 tests/test-environment-namespaced-names/kcl.mod delete mode 100644 tests/test-environment-namespaced-names/kcl.mod.lock delete mode 100644 tests/test-environment-namespaced-names/main.k delete mode 120000 tests/test-environment-namespaced-names/model create mode 100644 tests/test-environment-namespaced-names/pyproject.toml create mode 100644 tests/test-environment-namespaced-names/test/__init__.py create mode 100644 tests/test-environment-namespaced-names/test/__main__.py create mode 100644 tests/test-environment-no-cloudprovider-resource/README.md delete mode 100644 tests/test-environment-no-cloudprovider-resource/kcl.mod delete mode 100644 tests/test-environment-no-cloudprovider-resource/kcl.mod.lock delete mode 100644 tests/test-environment-no-cloudprovider-resource/main.k delete mode 120000 tests/test-environment-no-cloudprovider-resource/model create mode 100644 tests/test-environment-no-cloudprovider-resource/pyproject.toml create mode 100644 tests/test-environment-no-cloudprovider-resource/test/__init__.py create mode 100644 tests/test-environment-no-cloudprovider-resource/test/__main__.py create mode 100644 tests/test-environment-team-with-robot/README.md delete mode 100644 tests/test-environment-team-with-robot/kcl.mod delete mode 100644 tests/test-environment-team-with-robot/kcl.mod.lock delete mode 100644 tests/test-environment-team-with-robot/main.k delete mode 120000 tests/test-environment-team-with-robot/model create mode 100644 tests/test-environment-team-with-robot/pyproject.toml create mode 100644 tests/test-environment-team-with-robot/test/__init__.py create mode 100644 tests/test-environment-team-with-robot/test/__main__.py create mode 100644 tests/test-environment-uninitialized/README.md delete mode 100644 tests/test-environment-uninitialized/kcl.mod delete mode 100644 tests/test-environment-uninitialized/main.k delete mode 120000 tests/test-environment-uninitialized/model create mode 100644 tests/test-environment-uninitialized/pyproject.toml create mode 100644 tests/test-environment-uninitialized/test/__init__.py create mode 100644 tests/test-environment-uninitialized/test/__main__.py create mode 100644 tests/test-environment/README.md delete mode 100644 tests/test-environment/kcl.mod delete mode 100644 tests/test-environment/kcl.mod.lock delete mode 100644 tests/test-environment/main.k delete mode 120000 tests/test-environment/model create mode 100644 tests/test-environment/pyproject.toml create mode 100644 tests/test-environment/test/__init__.py create mode 100644 tests/test-environment/test/__main__.py create mode 100644 tests/test-sharedawssecret-with-data/README.md delete mode 100644 tests/test-sharedawssecret-with-data/kcl.mod delete mode 100644 tests/test-sharedawssecret-with-data/kcl.mod.lock delete mode 100644 tests/test-sharedawssecret-with-data/main.k delete mode 120000 tests/test-sharedawssecret-with-data/model create mode 100644 tests/test-sharedawssecret-with-data/pyproject.toml create mode 100644 tests/test-sharedawssecret-with-data/test/__init__.py create mode 100644 tests/test-sharedawssecret-with-data/test/__main__.py create mode 100644 tests/test-sharedawssecret/README.md delete mode 100644 tests/test-sharedawssecret/kcl.mod delete mode 100644 tests/test-sharedawssecret/kcl.mod.lock delete mode 100644 tests/test-sharedawssecret/main.k delete mode 120000 tests/test-sharedawssecret/model create mode 100644 tests/test-sharedawssecret/pyproject.toml create mode 100644 tests/test-sharedawssecret/test/__init__.py create mode 100644 tests/test-sharedawssecret/test/__main__.py create mode 100644 tests/test-upboundreposet-repo-config/README.md delete mode 100644 tests/test-upboundreposet-repo-config/kcl.mod delete mode 100644 tests/test-upboundreposet-repo-config/kcl.mod.lock delete mode 100644 tests/test-upboundreposet-repo-config/main.k delete mode 120000 tests/test-upboundreposet-repo-config/model create mode 100644 tests/test-upboundreposet-repo-config/pyproject.toml create mode 100644 tests/test-upboundreposet-repo-config/test/__init__.py create mode 100644 tests/test-upboundreposet-repo-config/test/__main__.py create mode 100644 tests/test-upboundreposet/README.md delete mode 100644 tests/test-upboundreposet/kcl.mod delete mode 100644 tests/test-upboundreposet/kcl.mod.lock delete mode 100644 tests/test-upboundreposet/main.k delete mode 120000 tests/test-upboundreposet/model create mode 100644 tests/test-upboundreposet/pyproject.toml create mode 100644 tests/test-upboundreposet/test/__init__.py create mode 100644 tests/test-upboundreposet/test/__main__.py diff --git a/README.md b/README.md index e1354a6..94ecf2b 100644 --- a/README.md +++ b/README.md @@ -505,12 +505,37 @@ delete the repository or its published packages. ## Development +The composition functions and the tests are Python, on the +[function SDK](https://github.com/crossplane/function-sdk-python). Each function is a +`FunctionRunner` in `functions//function/fn.py`; each test is a module under +`tests//test/` that prints its `CompositionTest` (or `E2ETest`) as YAML. + ```bash -up project build +up project build # also generates the Python models under .up/python up test run "tests/test-*" # composition tests up test run "tests/*" --e2e # end-to-end, against a real control plane ``` +Functions and tests run in containers, so none of this needs Python on your machine. An +editor does: without the generated models and the SDK on its interpreter path, every +`from models.io...` import shows as unresolved on correct code. Build a venv once, after the +first `up project build`, from the project's own pins: + +```bash +python3.13 -m venv .venv && .venv/bin/pip install --upgrade pip +# The functions' pins cover the tests too (SDK, pydantic, PyYAML). The `cd` matters: pip +# resolves each pyproject's relative path to .up/python from the current directory. +for d in functions/*; do (cd "$d" && ../../.venv/bin/pip install -q -e .); done +.venv/bin/pip install -e .up/python # last, and editable, so regenerated models need no reinstall +``` + +> Function directory names are the published package paths +> (`xpkg.upbound.io//platform-ref-upbound_`) — renaming one publishes a new package. + +> CI builds functions one at a time (`UP_MAX_CONCURRENCY=1`). Every Python function build +> mounts the same pip-cache Docker volume, and on a fresh runner concurrent builds race creating +> its directories. + > The composition glob is `tests/test-*`, not `tests/*`. `up test run` generates manifests for > every directory it matches, even ones it will not execute, and `tests/e2etest-environment` > deliberately fails generation when its variables are unset — better than provisioning a diff --git a/tests/e2etest-environment/README.md b/tests/e2etest-environment/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/e2etest-environment/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/e2etest-environment/kcl.mod b/tests/e2etest-environment/kcl.mod deleted file mode 100644 index cf70285..0000000 --- a/tests/e2etest-environment/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "e2etest-environment" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/tests/e2etest-environment/main.k b/tests/e2etest-environment/main.k deleted file mode 100644 index e6408fb..0000000 --- a/tests/e2etest-environment/main.k +++ /dev/null @@ -1,271 +0,0 @@ -""" -End-to-end test for the Environment API. - -Provisions a real Upbound group and control plane through the Spaces API, and real AWS IAM -and Secrets Manager resources, then asserts the Environment XR reaches Ready. - -Run it with: - - export UP_API_TOKEN=... # Upbound token; needs group/control-plane create rights - export UP_ORG=solutions - export UP_GROUP=default - export UP_SPACE=upbound-aws-us-east-1 - -These are the names .github/workflows/e2e.yaml already exports, so a local run and a CI run -read the same thing. - up test run tests/e2etest-environment --e2e - -`file.read_env` is how a value reaches a KCL test module - manifest generation runs in a -container and only UP_-prefixed variables are forwarded into it. Every read below fails loudly -on an unset variable rather than generating a manifest with an empty credential in it. -""" - -import file -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.crossplane.pkg.v1 as pkgv1 -import models.io.crossplane.pkg.v1beta1 as pkgv1beta1 -import models.io.k8s.api.core.v1 as corev1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - -_mustEnv = lambda name: str -> str { - """Read a UP_-prefixed variable, failing generation rather than emitting an empty value.""" - _v = file.read_env(name) - assert _v != "" and _v != Undefined and _v != None, "${name} must be set to run this e2e test" - _v -} - -# Credentials must be present or the run is pointless, so these assert. -_token = _mustEnv("UP_API_TOKEN") -_org = _mustEnv("UP_ORG") -_group = _mustEnv("UP_GROUP") - -# The space is different: there is a correct default, and it only has to agree with the space -# the workflow's `up ctx` step switches to. It also cannot be required, because e2e.yaml runs -# on pull_request_target - the workflow definition comes from the base branch while the code -# comes from the PR head, so a variable added to the workflow in a PR is not set when that -# same PR is tested. Asserting on it would make the suite unrunnable until after merge. -_space = file.read_env("UP_SPACE") -if _space == "" or _space == Undefined or _space == None: - _space = "upbound-gcp-us-central-1" -_spaceHost = "{}.spaces.upbound.io".format(_space) - -# The composition learns the Space it lives in by observing this Secret and regex-parsing the -# server URL, so the URL shape is load-bearing: the 5th path segment becomes bootstrapGroup and -# the 7th becomes bootstrapCtp. "bootstrap" here is arbitrary - it only has to match the -# ProviderConfig named "-ctp" that we create below. -_bootstrapKubeconfig = """apiVersion: v1 -kind: Config -current-context: upbound -preferences: {} -clusters: -- name: upbound - cluster: - insecure-skip-tls-verify: true - server: https://${_spaceHost}/apis/spaces.upbound.io/v1beta1/namespaces/${_group}/controlplanes/bootstrap/k8s -contexts: -- name: upbound - context: - cluster: upbound - namespace: default - user: upbound - extensions: - - name: spaces.upbound.io/space - extension: - apiVersion: upbound.io/v1alpha1 - kind: SpaceExtension - spec: - cloud: - organization: ${_org} -users: -- name: upbound - user: - exec: - apiVersion: client.authentication.k8s.io/v1 - command: up - args: [organization, token] - interactiveMode: IfAvailable - provideClusterInfo: false - env: - - name: ORGANIZATION - value: ${_org} -""" - -_items = [ - metav1alpha1.E2ETest{ - metadata.name: "environment" - spec = { - crossplane = { - # Pinned: v2 is required for the namespaced XRDs, and Stable can still - # resolve to the v1 line. - autoUpgrade.channel = "None" - version = "2.4.1-up.1" - } - defaultConditions = ["Ready"] - - # Applied before the package installs. Both providers are declared here rather - # than left to dependency resolution, so that each is bound to its - # DeploymentRuntimeConfig from the moment it starts - neither default works - # against Upbound Spaces. See README step 5 for why. - # - # enable-management-policies is temporary: upbound/provider-upbound#41 flips that - # default and is merged, but unreleased as of v1.1.1. Drop it, and the Provider - # override with it, once a release containing the fix is pinned below. - # disable-server-side-apply is permanent - SSA is the right default for - # provider-kubernetes, and the Spaces API gateway is the exception. - initResources = [ - pkgv1beta1.DeploymentRuntimeConfig{ - metadata.name = "enable-management-policies" - spec.deploymentTemplate.spec = { - selector = {} - template.spec.containers = [{ - name = "package-runtime" - env = [{name = "ENABLE_MANAGEMENT_POLICIES", value = "true"}] - }] - } - } - pkgv1beta1.DeploymentRuntimeConfig{ - metadata.name = "disable-server-side-apply" - spec.deploymentTemplate.spec = { - selector = {} - template.spec.containers = [{ - name = "package-runtime" - env = [{name = "ENABLE_SERVER_SIDE_APPLY", value = "false"}] - }] - } - } - pkgv1.Provider{ - metadata.name = "upbound-provider-upbound" - spec = { - package = "xpkg.upbound.io/upbound/provider-upbound:v1.1.1" - runtimeConfigRef = { - apiVersion = "pkg.crossplane.io/v1beta1" - kind = "DeploymentRuntimeConfig" - name = "enable-management-policies" - } - } - } - pkgv1.Provider{ - metadata.name = "upbound-provider-kubernetes" - spec = { - package = "xpkg.upbound.io/upbound/provider-kubernetes:v1.3.3" - runtimeConfigRef = { - apiVersion = "pkg.crossplane.io/v1beta1" - kind = "DeploymentRuntimeConfig" - name = "disable-server-side-apply" - } - } - } - ] - - extraResources = [ - corev1.Secret{ - metadata = {name = "bootstrap-token", namespace = "default"} - type = "Opaque" - stringData = {token = _token} - } - corev1.Secret{ - metadata = {name = "bootstrap-kubeconfig", namespace = "default"} - type = "Opaque" - stringData = {kubeconfig = _bootstrapKubeconfig} - } - # InjectedIdentity: this ProviderConfig only has to read the Secret above off - # the test's own control plane, so it needs no kubeconfig - which is just as - # well, since that control plane's name is not known when this is generated. - kubernetesm.ProviderConfig{ - metadata = {name = "bootstrap-ctp", namespace = "default"} - spec.credentials.source = "InjectedIdentity" - } - ] - - manifests = [ - sav1.Environment{ - metadata = { - name = "e2e" - namespace = "default" - annotations = { - # uptest asserts PER RESOURCE with a 30 second default, and - # spec.timeoutSeconds below does not reach it - only this - # annotation does. Keep the two numbers the same. - # - # It has to outlast provider installation, not just provisioning. - # `up test run` starts asserting once the *configuration* package - # is ready, which is not the same as its dependency providers - # being ready; provider images are large, so on a fresh control - # plane there is a multi-minute window where the managed resource - # CRDs do not exist and the composition cannot succeed. A run that - # happens to get fast provider installs passes, one that does not - # fails - which is exactly the flake this avoids. - "uptest.upbound.io/timeout" = "3600" - } - } - spec.parameters = { - # Delete, not Orphan: a CI suite has to clean up after itself. - deletionPolicy = "Delete" - aws = { - accountId = "609897127049" - region = "eu-central-1" - # Web identity - no AWS credential is stored anywhere. The role - # trust must allow this test's control plane OIDC subject, - # mcp:/-uptest-environment:provider:provider-aws. - roleArn = "arn:aws:iam::609897127049:role/solutions-e2e-provider-aws" - providerRole = { - # Adopt the account-wide provider rather than trying to create - # a second one, which AWS forbids. The composition orphans an - # adopted provider regardless of deletionPolicy, so teardown - # deletes the Role and its attachment but leaves this alone - - # see test-environment-adopted-oidc-is-orphaned. - oidcProviderArn = "arn:aws:iam::609897127049:oidc-provider/proidc.upbound.io" - } - sharedSecret = { - secretsManagerSecret = { - # Purge on teardown instead of scheduling. AWS keeps a - # deleted Secrets Manager secret recoverable for 30 days by - # default and holds its name reserved the whole time, so the - # run after a teardown fails with "already scheduled for - # deletion". A suite has to be re-runnable the minute it - # finishes, and nothing here is worth recovering. - recoveryWindowInDays = 0 - } - } - } - upbound = { - initKubeconfigSecretRef = {name = "bootstrap-kubeconfig", namespace = "default"} - tokenSecretRef = {name = "bootstrap-token", namespace = "default"} - # Deploy into a group that already exists, rather than creating one. - # - # Upbound grants RBAC per group: a team is bound to one group with an - # ObjectRoleBinding, and nothing grants "create any group" short of an - # organization owner. CI authenticates as a team-scoped robot, so it - # cannot create the --e2e group this environment would - # otherwise provision - every Object landing inside it came back - # `forbidden`. - # - # So the group and its ObjectRoleBinding are provisioned once, by - # hand, and outlive any single run: teardown removes the control - # plane, the secret stores and the AWS resources, but leaves the group - # (and therefore the binding, which lives inside it) in place. See the - # e2e prerequisites in README.md for the two manifests. - # - # What this costs in coverage: the Namespace Object and the - # space-level ProviderConfig that applies it. Everything the - # environment puts *inside* the group is still exercised. - createGroup = False - # No Argo CD on an ephemeral control plane, and the registration - # Secret targets an `argocd` namespace that will not exist. - createArgoSecret = False - } - } - } - ] - - # Provisioning a control plane plus IAM and Secrets Manager takes a while, and - # teardown has to delete them all again. - timeoutSeconds = 3600 - cleanupTimeoutSeconds = 1800 - skipDelete = False - } - } -] - -items = _items diff --git a/tests/e2etest-environment/model b/tests/e2etest-environment/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/e2etest-environment/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/e2etest-environment/pyproject.toml b/tests/e2etest-environment/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/e2etest-environment/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/e2etest-environment/test/__init__.py b/tests/e2etest-environment/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2etest-environment/test/__main__.py b/tests/e2etest-environment/test/__main__.py new file mode 100644 index 0000000..d86321a --- /dev/null +++ b/tests/e2etest-environment/test/__main__.py @@ -0,0 +1,294 @@ +"""End-to-end test for the Environment API. + +Provisions a real Upbound group and control plane through the Spaces API, and real AWS IAM +and Secrets Manager resources, then asserts the Environment XR reaches Ready. + +Run it with: + + export UP_API_TOKEN=... # Upbound token; needs group/control-plane create rights + export UP_ORG=solutions + export UP_GROUP=default + export UP_SPACE=upbound-aws-us-east-1 + +These are the names .github/workflows/e2e.yaml already exports, so a local run and a CI run +read the same thing. + up test run tests/e2etest-environment --e2e + +The environment is how a value reaches a test module - manifest generation runs in a +container and only UP_-prefixed variables are forwarded into it. Every read below fails loudly +on an unset variable rather than generating a manifest with an empty credential in it. +""" + +import os + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.e2etest import v1alpha1 as e2etest + + +def must_env(name: str) -> str: + """Read a UP_-prefixed variable, failing generation rather than emitting an empty value.""" + value = os.environ.get(name) + if not value: + raise SystemExit(f"{name} must be set to run this e2e test") + return value + + +# Credentials must be present or the run is pointless, so these assert. +TOKEN = must_env("UP_API_TOKEN") +ORG = must_env("UP_ORG") +GROUP = must_env("UP_GROUP") + +# The space is different: there is a correct default, and it only has to agree with the space +# the workflow's `up ctx` step switches to. It also cannot be required, because e2e.yaml runs +# on pull_request_target - the workflow definition comes from the base branch while the code +# comes from the PR head, so a variable added to the workflow in a PR is not set when that +# same PR is tested. Asserting on it would make the suite unrunnable until after merge. +SPACE = os.environ.get("UP_SPACE") or "upbound-gcp-us-central-1" +SPACE_HOST = f"{SPACE}.spaces.upbound.io" + +# The composition learns the Space it lives in by observing this Secret and regex-parsing the +# server URL, so the URL shape is load-bearing: the 5th path segment becomes bootstrapGroup and +# the 7th becomes bootstrapCtp. "bootstrap" here is arbitrary - it only has to match the +# ProviderConfig named "-ctp" that we create below. +BOOTSTRAP_KUBECONFIG = f"""apiVersion: v1 +kind: Config +current-context: upbound +preferences: {{}} +clusters: +- name: upbound + cluster: + insecure-skip-tls-verify: true + server: https://{SPACE_HOST}/apis/spaces.upbound.io/v1beta1/namespaces/{GROUP}/controlplanes/bootstrap/k8s +contexts: +- name: upbound + context: + cluster: upbound + namespace: default + user: upbound + extensions: + - name: spaces.upbound.io/space + extension: + apiVersion: upbound.io/v1alpha1 + kind: SpaceExtension + spec: + cloud: + organization: {ORG} +users: +- name: upbound + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: up + args: [organization, token] + interactiveMode: IfAvailable + provideClusterInfo: false + env: + - name: ORGANIZATION + value: {ORG} +""" + + +def runtime_config(name: str, env_name: str, env_value: str) -> dict: + """A DeploymentRuntimeConfig setting one environment variable on the provider container.""" + return { + "apiVersion": "pkg.crossplane.io/v1beta1", + "kind": "DeploymentRuntimeConfig", + "metadata": {"name": name}, + "spec": { + "deploymentTemplate": { + "spec": { + "selector": {}, + "template": { + "spec": { + "containers": [ + {"name": "package-runtime", "env": [{"name": env_name, "value": env_value}]} + ] + } + }, + } + } + }, + } + + +def provider(name: str, package: str, runtime_config_name: str) -> dict: + """A Provider bound to a DeploymentRuntimeConfig, with the pkg.crossplane.io/v1 defaults.""" + return { + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Provider", + "metadata": {"name": name}, + "spec": { + "ignoreCrossplaneConstraints": False, + "package": package, + "packagePullPolicy": "IfNotPresent", + "revisionActivationPolicy": "Automatic", + "revisionHistoryLimit": 1, + "runtimeConfigRef": { + "apiVersion": "pkg.crossplane.io/v1beta1", + "kind": "DeploymentRuntimeConfig", + "name": runtime_config_name, + }, + "skipDependencyResolution": False, + }, + } + + +def secret(name: str, string_data: dict) -> dict: + return { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": "default"}, + "type": "Opaque", + "stringData": string_data, + } + + +test = e2etest.E2ETest( + metadata=k8s.ObjectMeta(name="environment"), + spec=e2etest.Spec( + crossplane=e2etest.Crossplane( + # Pinned: v2 is required for the namespaced XRDs, and Stable can still + # resolve to the v1 line. + autoUpgrade=e2etest.AutoUpgrade(channel="None"), + version="2.4.1-up.1", + ), + defaultConditions=["Ready"], + # Applied before the package installs. Both providers are declared here rather + # than left to dependency resolution, so that each is bound to its + # DeploymentRuntimeConfig from the moment it starts - neither default works + # against Upbound Spaces. See README step 5 for why. + # + # enable-management-policies is temporary: upbound/provider-upbound#41 flips that + # default and is merged, but unreleased as of v1.1.1. Drop it, and the Provider + # override with it, once a release containing the fix is pinned below. + # disable-server-side-apply is permanent - SSA is the right default for + # provider-kubernetes, and the Spaces API gateway is the exception. + initResources=[ + runtime_config("enable-management-policies", "ENABLE_MANAGEMENT_POLICIES", "true"), + runtime_config("disable-server-side-apply", "ENABLE_SERVER_SIDE_APPLY", "false"), + provider( + "upbound-provider-upbound", + "xpkg.upbound.io/upbound/provider-upbound:v1.1.1", + "enable-management-policies", + ), + provider( + "upbound-provider-kubernetes", + "xpkg.upbound.io/upbound/provider-kubernetes:v1.3.3", + "disable-server-side-apply", + ), + ], + extraResources=[ + secret("bootstrap-token", {"token": TOKEN}), + secret("bootstrap-kubeconfig", {"kubeconfig": BOOTSTRAP_KUBECONFIG}), + # InjectedIdentity: this ProviderConfig only has to read the Secret above off + # the test's own control plane, so it needs no kubeconfig - which is just as + # well, since that control plane's name is not known when this is generated. + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": {"name": "bootstrap-ctp", "namespace": "default"}, + "spec": {"credentials": {"source": "InjectedIdentity"}}, + }, + ], + manifests=[ + { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": { + "name": "e2e", + "namespace": "default", + "annotations": { + # uptest asserts PER RESOURCE with a 30 second default, and + # spec.timeoutSeconds below does not reach it - only this + # annotation does. Keep the two numbers the same. + # + # It has to outlast provider installation, not just provisioning. + # `up test run` starts asserting once the *configuration* package + # is ready, which is not the same as its dependency providers + # being ready; provider images are large, so on a fresh control + # plane there is a multi-minute window where the managed resource + # CRDs do not exist and the composition cannot succeed. A run that + # happens to get fast provider installs passes, one that does not + # fails - which is exactly the flake this avoids. + "uptest.upbound.io/timeout": "3600", + }, + }, + "spec": { + "parameters": { + # Delete, not Orphan: a CI suite has to clean up after itself. + "deletionPolicy": "Delete", + "aws": { + "accountId": "609897127049", + "region": "eu-central-1", + # Web identity - no AWS credential is stored anywhere. The role + # trust must allow this test's control plane OIDC subject, + # mcp:/-uptest-environment:provider:provider-aws. + "roleArn": "arn:aws:iam::609897127049:role/solutions-e2e-provider-aws", + "providerRole": { + # Adopt the account-wide provider rather than trying to create + # a second one, which AWS forbids. The composition orphans an + # adopted provider regardless of deletionPolicy, so teardown + # deletes the Role and its attachment but leaves this alone - + # see test-environment-adopted-oidc-is-orphaned. + "oidcProviderArn": "arn:aws:iam::609897127049:oidc-provider/proidc.upbound.io", + }, + "sharedSecret": { + "secretsManagerSecret": { + "create": True, + # Purge on teardown instead of scheduling. AWS keeps a + # deleted Secrets Manager secret recoverable for 30 days by + # default and holds its name reserved the whole time, so the + # run after a teardown fails with "already scheduled for + # deletion". A suite has to be re-runnable the minute it + # finishes, and nothing here is worth recovering. + "recoveryWindowInDays": 0, + }, + }, + }, + "upbound": { + "initKubeconfigSecretRef": { + "key": "kubeconfig", + "name": "bootstrap-kubeconfig", + "namespace": "default", + }, + "initProviderConfigName": "bootstrap-ctp", + "tokenSecretRef": {"key": "token", "name": "bootstrap-token", "namespace": "default"}, + "createCtp": True, + # Deploy into a group that already exists, rather than creating one. + # + # Upbound grants RBAC per group: a team is bound to one group with an + # ObjectRoleBinding, and nothing grants "create any group" short of an + # organization owner. CI authenticates as a team-scoped robot, so it + # cannot create the --e2e group this environment would + # otherwise provision - every Object landing inside it came back + # `forbidden`. + # + # So the group and its ObjectRoleBinding are provisioned once, by + # hand, and outlive any single run: teardown removes the control + # plane, the secret stores and the AWS resources, but leaves the group + # (and therefore the binding, which lives inside it) in place. See the + # e2e prerequisites in README.md for the two manifests. + # + # What this costs in coverage: the Namespace Object and the + # space-level ProviderConfig that applies it. Everything the + # environment puts *inside* the group is still exercised. + "createGroup": False, + # No Argo CD on an ephemeral control plane, and the registration + # Secret targets an `argocd` namespace that will not exist. + "createArgoSecret": False, + }, + }, + }, + }, + ], + # Provisioning a control plane plus IAM and Secrets Manager takes a while, and + # teardown has to delete them all again. + timeoutSeconds=3600, + cleanupTimeoutSeconds=1800, + skipDelete=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) diff --git a/tests/test-environment-deletion-policy-delete/README.md b/tests/test-environment-deletion-policy-delete/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-deletion-policy-delete/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-deletion-policy-delete/kcl.mod b/tests/test-environment-deletion-policy-delete/kcl.mod deleted file mode 100644 index eebd275..0000000 --- a/tests/test-environment-deletion-policy-delete/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-deletion-policy-delete" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-environment-deletion-policy-delete/kcl.mod.lock b/tests/test-environment-deletion-policy-delete/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment-deletion-policy-delete/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment-deletion-policy-delete/main.k b/tests/test-environment-deletion-policy-delete/main.k deleted file mode 100644 index 1dc2241..0000000 --- a/tests/test-environment-deletion-policy-delete/main.k +++ /dev/null @@ -1,201 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-deletion-policy-delete" - spec= { - assertResources = [ - sav1.Environment{ - metadata.name = "example" - metadata.namespace = "default" - spec.parameters = { - deletionPolicy = "Delete" - } - } - kubernetesm.Object{ - metadata = { - name = "example-ctp-kubeconfig" - } - spec = { - forProvider.manifest = {} - managementPolicies = ["*"] - } - } - kubernetesm.Object{ - metadata = { - name = "example-ctp" - } - spec = { - forProvider.manifest = {} - managementPolicies = ["*"] - } - } - kubernetesm.Object{ - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - spec = { - forProvider.manifest = {} - managementPolicies = ["*"] - } - } - ### AWS ### - iamv1beta1.Role{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-admin" - } - spec = { - forProvider = {} - managementPolicies = ["*"] - } - } - iamv1beta1.RolePolicyAttachment{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-admin" - } - spec = { - forProvider = {} - managementPolicies = ["*"] - } - } - sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - } - spec = { - parameters = { - deletionPolicy = "Delete" - aws = { - accountId = "12345678912" - region = "us-east-1" - namePrefix = "upbound-solutions-non-prod-default-example-example" - providerConfigRef = { - name = "solutions-non-prod-default-example" - } - } - upbound = { - group = "solutions-non-prod-default-example" - controlPlane = "example" - providerConfigRef = { - name = "solutions-non-prod-default-example-group" - } - } - } - } - } - iamv1beta1.OpenIDConnectProvider{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-oidc-provider" - } - spec = { - forProvider = {} - managementPolicies = ["*"] - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrPath = "examples/environment/example-deletion-policy-delete.yaml" - xrdPath = "apis/environments/definition.yaml" - context = {} - extraResources = [] - observedResources = [ - kubernetesm.Object{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "observedCtpKubeconfig" - } - name = "observed-bootstrap-ctp-kubeconfig" - - namespace = "default" - } - spec = { - forProvider = { - manifest = {} - } - managementPolicies = [ - "Observe" - ] - } - status = { - atProvider = { - manifest = { - data = { - kubeconfig = "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGluc2VjdXJlLXNraXAtdGxzLXZlcmlmeTogdHJ1ZQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" - } - } - } - } - } - ] - timeoutSeconds = 60 - validate = False - } - } - # Adoption must override deletionPolicy. With an oidcProviderArn supplied the composition - # is adopting a provider it did not create, and AWS allows only one per URL per account - - # proidc.upbound.io is shared by every Upbound integration there. Deleting it on teardown - # would break all of them, so it stays orphaned even though the XR says Delete. The Role - # beside it is created by us and must still honour Delete. - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-adopted-oidc-is-orphaned" - spec = { - assertResources = [ - iamv1beta1.OpenIDConnectProvider{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-oidc-provider" - annotations = { - "crossplane.io/external-name" = "arn:aws:iam::12345678912:oidc-provider/proidc.upbound.io" - } - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - } - } - iamv1beta1.Role{ - metadata.name = "upbound-solutions-non-prod-default-example-example-admin" - spec = { - forProvider = {} - managementPolicies = ["*"] - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: sav1.Environment{ - metadata = { - name = "example" - namespace = "default" - } - spec.parameters = { - deletionPolicy = "Delete" - aws = { - accountId = "12345678912" - region = "us-east-1" - credsSecretRef = {name = "aws-creds-example", namespace = "default"} - providerRole = { - oidcProviderArn = "arn:aws:iam::12345678912:oidc-provider/proidc.upbound.io" - } - } - upbound = { - initKubeconfigSecretRef = {name = "init-kubeconfig"} - tokenSecretRef = {name = "upbound-token"} - } - } - status.upbound = { - bootstrapCtp = "bootstrap" - bootstrapGroup = "solutions-non-prod" - org = "upbound" - spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" - } - } - timeoutSeconds = 60 - validate = False - } - } -] -items = _items diff --git a/tests/test-environment-deletion-policy-delete/model b/tests/test-environment-deletion-policy-delete/model deleted file mode 120000 index 1b8c82f..0000000 --- a/tests/test-environment-deletion-policy-delete/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models/ \ No newline at end of file diff --git a/tests/test-environment-deletion-policy-delete/pyproject.toml b/tests/test-environment-deletion-policy-delete/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-deletion-policy-delete/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-deletion-policy-delete/test/__init__.py b/tests/test-environment-deletion-policy-delete/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-deletion-policy-delete/test/__main__.py b/tests/test-environment-deletion-policy-delete/test/__main__.py new file mode 100644 index 0000000..10b9021 --- /dev/null +++ b/tests/test-environment-deletion-policy-delete/test/__main__.py @@ -0,0 +1,172 @@ +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +COMPOSITION_PATH = "apis/environments/composition.yaml" +XRD_PATH = "apis/environments/definition.yaml" +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +OIDC_PROVIDER_ARN = "arn:aws:iam::12345678912:oidc-provider/proidc.upbound.io" + + +def _object(name: str) -> dict: + """A provider-kubernetes Object, with the defaults the typed KCL schema filled in.""" + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": name}, + "spec": { + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": {}}, + "managementPolicies": ["*"], + "watch": False, + }, + } + + +def _aws(kind: str, name: str, management_policies: list[str] | None = None, annotations: dict | None = None) -> dict: + metadata = {"name": name} + if annotations: + metadata["annotations"] = annotations + return { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": kind, + "metadata": metadata, + "spec": {"forProvider": {}, "managementPolicies": management_policies or ["*"]}, + } + + +OBSERVED_BOOTSTRAP_CTP_KUBECONFIG = { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "observedCtpKubeconfig"}, + "name": "observed-bootstrap-ctp-kubeconfig", + "namespace": "default", + }, + "spec": { + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": {}}, + "managementPolicies": ["Observe"], + "watch": False, + }, + "status": { + "atProvider": { + "manifest": { + "data": { + "kubeconfig": "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGluc2VjdXJlLXNraXAtdGxzLXZlcmlmeTogdHJ1ZQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" + } + } + } + }, +} + +tests = [ + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-deletion-policy-delete"), + spec=compositiontest.Spec( + assertResources=[ + { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "example", "namespace": "default"}, + "spec": {"parameters": {"deletionPolicy": "Delete"}}, + }, + _object("example-ctp-kubeconfig"), + _object("example-ctp"), + _object("solutions-non-prod-default-example-group-kubeconfig"), + ### AWS ### + _aws("Role", "upbound-solutions-non-prod-default-example-example-admin"), + _aws("RolePolicyAttachment", "upbound-solutions-non-prod-default-example-example-admin"), + { + "apiVersion": "sa.upbound.io/v1", + "kind": "SharedAWSSecret", + "metadata": {"name": "example-shared-secret"}, + "spec": { + "parameters": { + "deletionPolicy": "Delete", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "namePrefix": "upbound-solutions-non-prod-default-example-example", + "providerConfigRef": {"name": "solutions-non-prod-default-example"}, + }, + "upbound": { + "group": "solutions-non-prod-default-example", + "controlPlane": "example", + "providerConfigRef": {"name": "solutions-non-prod-default-example-group"}, + }, + } + }, + }, + _aws("OpenIDConnectProvider", "upbound-solutions-non-prod-default-example-example-oidc-provider"), + ], + compositionPath=COMPOSITION_PATH, + xrPath="examples/environment/example-deletion-policy-delete.yaml", + xrdPath=XRD_PATH, + context={}, + extraResources=[], + observedResources=[OBSERVED_BOOTSTRAP_CTP_KUBECONFIG], + timeoutSeconds=60, + validate=False, + ), + ), + # Adoption must override deletionPolicy. With an oidcProviderArn supplied the composition + # is adopting a provider it did not create, and AWS allows only one per URL per account - + # proidc.upbound.io is shared by every Upbound integration there. Deleting it on teardown + # would break all of them, so it stays orphaned even though the XR says Delete. The Role + # beside it is created by us and must still honour Delete. + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-adopted-oidc-is-orphaned"), + spec=compositiontest.Spec( + assertResources=[ + _aws( + "OpenIDConnectProvider", + "upbound-solutions-non-prod-default-example-example-oidc-provider", + management_policies=ORPHAN, + annotations={"crossplane.io/external-name": OIDC_PROVIDER_ARN}, + ), + _aws("Role", "upbound-solutions-non-prod-default-example-example-admin"), + ], + compositionPath=COMPOSITION_PATH, + xrdPath=XRD_PATH, + xr={ + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "example", "namespace": "default"}, + "spec": { + "parameters": { + "deletionPolicy": "Delete", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "credsSecretRef": {"name": "aws-creds-example", "namespace": "default"}, + "providerRole": {"oidcProviderArn": OIDC_PROVIDER_ARN}, + }, + "upbound": { + # createArgoSecret, createCtp, createGroup, initProviderConfigName and + # the secret keys and namespaces are XRD defaults the typed KCL + # Environment filled in. + "createArgoSecret": True, + "createCtp": True, + "createGroup": True, + "initKubeconfigSecretRef": {"key": "kubeconfig", "name": "init-kubeconfig", "namespace": "default"}, + "initProviderConfigName": "bootstrap-ctp", + "tokenSecretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + }, + } + }, + "status": { + "upbound": { + "bootstrapCtp": "bootstrap", + "bootstrapGroup": "solutions-non-prod", + "org": "upbound", + "spaceHost": "upbound-aws-us-east-1.space.mxe.upbound.io", + } + }, + }, + timeoutSeconds=60, + validate=False, + ), + ), +] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-environment-existing-group/README.md b/tests/test-environment-existing-group/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-existing-group/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-existing-group/kcl.mod b/tests/test-environment-existing-group/kcl.mod deleted file mode 100644 index 79e2d12..0000000 --- a/tests/test-environment-existing-group/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-existing-group" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/tests/test-environment-existing-group/kcl.mod.lock b/tests/test-environment-existing-group/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment-existing-group/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment-existing-group/main.k b/tests/test-environment-existing-group/main.k deleted file mode 100644 index 73f24a0..0000000 --- a/tests/test-environment-existing-group/main.k +++ /dev/null @@ -1,105 +0,0 @@ -""" -`createGroup: false` - deploy an environment into a group that already exists. - -Not every principal that runs an Environment is allowed to create groups. Upbound RBAC is -granted per group: a team is bound to one group with an ObjectRoleBinding, and nothing grants -"create any group" short of an organization owner. An operator running under a team-scoped -robot therefore has to be handed a group that someone else created, and `createGroup: false` -is the switch for that. - -Everything the composition puts *inside* the group still needs the group-level ProviderConfig, -whether or not the composition created the group - the ControlPlane references it, and so does -the nested SharedAWSSecret. Gating that ProviderConfig on `createGroup` leaves both pointing at -a ProviderConfig that is never composed, which is what this suite pins down. -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - -_xr = sav1.Environment{ - metadata = { - name = "example" - namespace = "default" - } - spec.parameters = { - # Delete keeps managementPolicies at ["*"] so the assertions below read plainly; the - # deletion-policy translation itself is covered by test-environment-deletion-policy-delete. - deletionPolicy = "Delete" - aws = { - accountId = "12345678912" - region = "us-east-1" - credsSecretRef = {name = "aws-creds-example", namespace = "default"} - # Present so the nested SharedAWSSecret is composed - it is the second consumer - # of the group-level ProviderConfig. - sharedSecret = {} - } - upbound = { - createGroup = False - initKubeconfigSecretRef = {name = "init-kubeconfig"} - tokenSecretRef = {name = "upbound-token"} - } - } - status.upbound = { - bootstrapCtp = "bootstrap" - bootstrapGroup = "solutions-non-prod" - org = "upbound" - spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" - } -} - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-existing-group" - spec = { - assertResources = [ - # The group-level ProviderConfig and its kubeconfig Secret must still be - # composed. Without them the two resources below reference nothing. - kubernetesm.ProviderConfig{ - metadata.name = "solutions-non-prod-default-example-group" - spec.credentials = { - source = "Secret" - secretRef = { - name = "solutions-non-prod-default-example-group-kubeconfig" - namespace = "default" - key = "kubeconfig" - } - } - } - kubernetesm.Object{ - metadata.name = "solutions-non-prod-default-example-group-kubeconfig" - spec = { - forProvider.manifest = {} - } - } - # The ControlPlane goes into the pre-existing group through that ProviderConfig. - kubernetesm.Object{ - metadata.name = "example-ctp" - spec = { - forProvider.manifest.metadata.namespace = "solutions-non-prod-default-example" - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example-group" - } - } - } - # So does the shared secret, via the same ProviderConfig. - sav1.SharedAWSSecret{ - metadata.name = "example-shared-secret" - spec.parameters.upbound = { - group = "solutions-non-prod-default-example" - controlPlane = "example" - providerConfigRef.name = "solutions-non-prod-default-example-group" - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: _xr - timeoutSeconds = 60 - validate = False - } - } -] - -items = _items diff --git a/tests/test-environment-existing-group/model b/tests/test-environment-existing-group/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-environment-existing-group/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-environment-existing-group/pyproject.toml b/tests/test-environment-existing-group/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-existing-group/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-existing-group/test/__init__.py b/tests/test-environment-existing-group/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-existing-group/test/__main__.py b/tests/test-environment-existing-group/test/__main__.py new file mode 100644 index 0000000..9ae728e --- /dev/null +++ b/tests/test-environment-existing-group/test/__main__.py @@ -0,0 +1,133 @@ +""" +`createGroup: false` - deploy an environment into a group that already exists. + +Not every principal that runs an Environment is allowed to create groups. Upbound RBAC is +granted per group: a team is bound to one group with an ObjectRoleBinding, and nothing grants +"create any group" short of an organization owner. An operator running under a team-scoped +robot therefore has to be handed a group that someone else created, and `createGroup: false` +is the switch for that. + +Everything the composition puts *inside* the group still needs the group-level ProviderConfig, +whether or not the composition created the group - the ControlPlane references it, and so does +the nested SharedAWSSecret. Gating that ProviderConfig on `createGroup` leaves both pointing at +a ProviderConfig that is never composed, which is what this suite pins down. +""" + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +GROUP = "solutions-non-prod-default-example" +GROUP_PROVIDER_CONFIG = f"{GROUP}-group" + +XR = { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "example", "namespace": "default"}, + "spec": { + "parameters": { + # Delete keeps managementPolicies at ["*"] so the assertions below read plainly; the + # deletion-policy translation itself is covered by test-environment-deletion-policy-delete. + "deletionPolicy": "Delete", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "credsSecretRef": {"name": "aws-creds-example", "namespace": "default"}, + # Present so the nested SharedAWSSecret is composed - it is the second consumer + # of the group-level ProviderConfig. + "sharedSecret": {}, + }, + "upbound": { + "createGroup": False, + # Environment schema defaults. + "createArgoSecret": True, + "createCtp": True, + "initProviderConfigName": "bootstrap-ctp", + "initKubeconfigSecretRef": {"name": "init-kubeconfig", "namespace": "default", "key": "kubeconfig"}, + "tokenSecretRef": {"name": "upbound-token", "namespace": "default", "key": "token"}, + }, + }, + }, + "status": { + "upbound": { + "bootstrapCtp": "bootstrap", + "bootstrapGroup": "solutions-non-prod", + "org": "upbound", + "spaceHost": "upbound-aws-us-east-1.space.mxe.upbound.io", + }, + }, +} + +test = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-existing-group"), + spec=compositiontest.Spec( + assertResources=[ + # The group-level ProviderConfig and its kubeconfig Secret must still be + # composed. Without them the two resources below reference nothing. + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": {"name": GROUP_PROVIDER_CONFIG}, + "spec": { + "credentials": { + "source": "Secret", + "secretRef": { + "name": f"{GROUP}-group-kubeconfig", + "namespace": "default", + "key": "kubeconfig", + }, + }, + }, + }, + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": f"{GROUP}-group-kubeconfig"}, + "spec": { + # Object schema defaults: deletionPropagationPolicy, managementPolicies, watch. + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": {}}, + "managementPolicies": ["*"], + "watch": False, + }, + }, + # The ControlPlane goes into the pre-existing group through that ProviderConfig. + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": "example-ctp"}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", + "manifest": {"metadata": {"namespace": GROUP}}, + }, + "managementPolicies": ["*"], + "providerConfigRef": {"kind": "ProviderConfig", "name": GROUP_PROVIDER_CONFIG}, + "watch": False, + }, + }, + # So does the shared secret, via the same ProviderConfig. + { + "apiVersion": "sa.upbound.io/v1", + "kind": "SharedAWSSecret", + "metadata": {"name": "example-shared-secret"}, + "spec": { + "parameters": { + "upbound": { + "group": GROUP, + "controlPlane": "example", + "providerConfigRef": {"name": GROUP_PROVIDER_CONFIG}, + }, + }, + }, + }, + ], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr=XR, + timeoutSeconds=60, + validate=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) diff --git a/tests/test-environment-namespaced-names/README.md b/tests/test-environment-namespaced-names/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-namespaced-names/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-namespaced-names/kcl.mod b/tests/test-environment-namespaced-names/kcl.mod deleted file mode 100644 index d546070..0000000 --- a/tests/test-environment-namespaced-names/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-namespaced-names" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/tests/test-environment-namespaced-names/kcl.mod.lock b/tests/test-environment-namespaced-names/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment-namespaced-names/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment-namespaced-names/main.k b/tests/test-environment-namespaced-names/main.k deleted file mode 100644 index b01c2fe..0000000 --- a/tests/test-environment-namespaced-names/main.k +++ /dev/null @@ -1,123 +0,0 @@ -""" -Environment names must not collide across namespaces. - -With a Namespaced XRD, team-a/prod and team-b/prod are both valid, and everything the -composition creates *outside* the XR's namespace has to tell them apart: the Upbound group -(and so the ControlPlane, Team, Robot and Argo secret inside or named after it), the AWS -resource names, and the kubeconfig Secrets on the bootstrap control plane. Built from -metadata.name alone, the two map to the same objects - and with deletionPolicy: Delete, -deleting one tears down the other. -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - -_xr = lambda namespace: str, name: str -> sav1.Environment { - sav1.Environment{ - metadata = { - name = name - namespace = namespace - } - spec.parameters = { - deletionPolicy = "Delete" - aws = { - accountId = "12345678912" - region = "us-east-1" - credsSecretRef = {name = "aws-creds-example", namespace = namespace} - providerRole = {} - } - upbound = { - initKubeconfigSecretRef = {name = "init-kubeconfig", namespace = namespace} - tokenSecretRef = {name = "upbound-token", namespace = namespace} - } - } - status.upbound = { - bootstrapCtp = "bootstrap" - bootstrapGroup = "solutions-non-prod" - org = "upbound" - spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" - } - } -} - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-namespaced-names" - spec = { - assertResources = [ - # The group carries the namespace, so team-b/example gets a different one. - kubernetesm.Object{ - metadata.name = "solutions-non-prod-team-a-example" - spec.forProvider.manifest.metadata.name = "solutions-non-prod-team-a-example" - } - kubernetesm.Object{ - metadata.name = "example-ctp" - spec.forProvider.manifest.metadata.namespace = "solutions-non-prod-team-a-example" - } - # Kubeconfig Secrets land in the XR's own namespace rather than a shared - # `default`, and the ProviderConfig reads them from there. - kubernetesm.Object{ - metadata.name = "solutions-non-prod-team-a-example-group-kubeconfig" - spec.forProvider.manifest.metadata = { - name = "solutions-non-prod-team-a-example-group-kubeconfig" - namespace = "team-a" - } - } - kubernetesm.ProviderConfig{ - metadata.name = "solutions-non-prod-team-a-example-group" - spec.credentials = { - source = "Secret" - secretRef = { - name = "solutions-non-prod-team-a-example-group-kubeconfig" - namespace = "team-a" - key = "kubeconfig" - } - } - } - kubernetesm.Object{ - metadata.name = "example-ctp-kubeconfig" - spec.forProvider.manifest.metadata.namespace = "team-a" - } - kubernetesm.Object{ - metadata.name = "example-space-kubeconfig" - spec.forProvider.manifest.metadata.namespace = "team-a" - } - # AWS names follow the group, so they are distinct too. - iamv1beta1.Role{ - metadata.name = "upbound-solutions-non-prod-team-a-example-example-admin" - spec.forProvider = {} - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: _xr("team-a", "example") - timeoutSeconds = 60 - validate = False - } - } - # Adding the namespace lengthens every AWS name. IAM caps role names at 64 characters, - # and a real org/group/namespace/name combination passes that easily - AWS would then - # reject the Role outright. It gets the same truncation SharedAWSSecret already applies - # to its IAM user and policy: keep the suffix, replace the tail of the prefix with a hash. - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-long-role-name" - spec = { - assertResources = [ - iamv1beta1.Role{ - # 81 characters untruncated. - metadata.name = "upbound-solutions-non-prod-platform-engineering-p-289801-admin" - spec.forProvider = {} - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: _xr("platform-engineering", "production-eu") - timeoutSeconds = 60 - validate = False - } - } -] - -items = _items diff --git a/tests/test-environment-namespaced-names/model b/tests/test-environment-namespaced-names/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-environment-namespaced-names/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-environment-namespaced-names/pyproject.toml b/tests/test-environment-namespaced-names/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-namespaced-names/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-namespaced-names/test/__init__.py b/tests/test-environment-namespaced-names/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-namespaced-names/test/__main__.py b/tests/test-environment-namespaced-names/test/__main__.py new file mode 100644 index 0000000..be584ca --- /dev/null +++ b/tests/test-environment-namespaced-names/test/__main__.py @@ -0,0 +1,144 @@ +"""Environment names must not collide across namespaces. + +With a Namespaced XRD, team-a/prod and team-b/prod are both valid, and everything the +composition creates *outside* the XR's namespace has to tell them apart: the Upbound group +(and so the ControlPlane, Team, Robot and Argo secret inside or named after it), the AWS +resource names, and the kubeconfig Secrets on the bootstrap control plane. Built from +metadata.name alone, the two map to the same objects - and with deletionPolicy: Delete, +deleting one tears down the other. +""" + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +COMPOSITION_PATH = "apis/environments/composition.yaml" +XRD_PATH = "apis/environments/definition.yaml" + + +def _xr(namespace: str, name: str) -> dict: + return { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": name, "namespace": namespace}, + "spec": { + "parameters": { + "deletionPolicy": "Delete", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "credsSecretRef": {"name": "aws-creds-example", "namespace": namespace}, + "providerRole": {}, + }, + "upbound": { + # createArgoSecret, createCtp, createGroup, initProviderConfigName and the + # secret keys are XRD defaults the typed KCL Environment filled in. + "createArgoSecret": True, + "createCtp": True, + "createGroup": True, + "initKubeconfigSecretRef": {"key": "kubeconfig", "name": "init-kubeconfig", "namespace": namespace}, + "initProviderConfigName": "bootstrap-ctp", + "tokenSecretRef": {"key": "token", "name": "upbound-token", "namespace": namespace}, + }, + } + }, + "status": { + "upbound": { + "bootstrapCtp": "bootstrap", + "bootstrapGroup": "solutions-non-prod", + "org": "upbound", + "spaceHost": "upbound-aws-us-east-1.space.mxe.upbound.io", + } + }, + } + + +def _object(name: str, manifest_metadata: dict) -> dict: + """A provider-kubernetes Object, with the defaults the typed KCL schema filled in.""" + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": name}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", + "manifest": {"metadata": manifest_metadata}, + }, + "managementPolicies": ["*"], + "watch": False, + }, + } + + +def _role(name: str) -> dict: + return { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": "Role", + "metadata": {"name": name}, + "spec": {"forProvider": {}, "managementPolicies": ["*"]}, + } + + +tests = [ + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-namespaced-names"), + spec=compositiontest.Spec( + assertResources=[ + # The group carries the namespace, so team-b/example gets a different one. + _object("solutions-non-prod-team-a-example", {"name": "solutions-non-prod-team-a-example"}), + _object("example-ctp", {"namespace": "solutions-non-prod-team-a-example"}), + # Kubeconfig Secrets land in the XR's own namespace rather than a shared + # `default`, and the ProviderConfig reads them from there. + _object( + "solutions-non-prod-team-a-example-group-kubeconfig", + {"name": "solutions-non-prod-team-a-example-group-kubeconfig", "namespace": "team-a"}, + ), + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": {"name": "solutions-non-prod-team-a-example-group"}, + "spec": { + "credentials": { + "source": "Secret", + "secretRef": { + "name": "solutions-non-prod-team-a-example-group-kubeconfig", + "namespace": "team-a", + "key": "kubeconfig", + }, + } + }, + }, + _object("example-ctp-kubeconfig", {"namespace": "team-a"}), + _object("example-space-kubeconfig", {"namespace": "team-a"}), + # AWS names follow the group, so they are distinct too. + _role("upbound-solutions-non-prod-team-a-example-example-admin"), + ], + compositionPath=COMPOSITION_PATH, + xrdPath=XRD_PATH, + xr=_xr("team-a", "example"), + timeoutSeconds=60, + validate=False, + ), + ), + # Adding the namespace lengthens every AWS name. IAM caps role names at 64 characters, + # and a real org/group/namespace/name combination passes that easily - AWS would then + # reject the Role outright. It gets the same truncation SharedAWSSecret already applies + # to its IAM user and policy: keep the suffix, replace the tail of the prefix with a hash. + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-long-role-name"), + spec=compositiontest.Spec( + assertResources=[ + # 81 characters untruncated. + _role("upbound-solutions-non-prod-platform-engineering-p-289801-admin"), + ], + compositionPath=COMPOSITION_PATH, + xrdPath=XRD_PATH, + xr=_xr("platform-engineering", "production-eu"), + timeoutSeconds=60, + validate=False, + ), + ), +] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-environment-no-cloudprovider-resource/README.md b/tests/test-environment-no-cloudprovider-resource/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-no-cloudprovider-resource/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-no-cloudprovider-resource/kcl.mod b/tests/test-environment-no-cloudprovider-resource/kcl.mod deleted file mode 100644 index 3a69236..0000000 --- a/tests/test-environment-no-cloudprovider-resource/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-no-cloudprovider-resource" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-environment-no-cloudprovider-resource/kcl.mod.lock b/tests/test-environment-no-cloudprovider-resource/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment-no-cloudprovider-resource/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment-no-cloudprovider-resource/main.k b/tests/test-environment-no-cloudprovider-resource/main.k deleted file mode 100644 index 44df1cb..0000000 --- a/tests/test-environment-no-cloudprovider-resource/main.k +++ /dev/null @@ -1,508 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import base64 -import models.io.upbound.sa.v1 as sav1 - - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-no-cloudprovider-resource" - spec = { - assertResources: [ - kubernetesm.Object{ - metadata: { - name: "example-ctp-kubeconfig" - } - spec: { - managementPolicies: ["*"] - forProvider: { - manifest: { - apiVersion: "v1" - kind: "Secret" - metadata: { - name: "example-ctp-kubeconfig" - namespace: "default" - } - data : { - kubeconfig : base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - providerConfigRef: { - kind: "ProviderConfig" - name: "bootstrap-ctp" - } - watch: False - } - } - kubernetesm.Object{ - metadata: { - name: "example-ctp" - } - spec: { - readiness: { - policy: "DeriveFromObject" - } - managementPolicies: ["Create", "Observe", "Update", "LateInitialize"] - forProvider: { - manifest: { - apiVersion: "spaces.upbound.io/v1beta1" - kind: "ControlPlane" - metadata: { - name: "example" - namespace: "solutions-non-prod-default-example" - } - spec: { - class: "default" - crossplane: { - autoUpgrade: { - channel: "Rapid" - } - } - } - } - } - watch: False - } - } - kubernetesm.Object{ - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - namespace = "default" - } - data = { - kubeconfig = base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - watch = False - } - } - kubernetesm.ProviderConfig{ - metadata = { - name = "example-ctp" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "example-ctp-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - kubernetesm.ProviderConfig{ - metadata = { - name = "solutions-non-prod-default-example-group" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "solutions-non-prod-default-example-group-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "example-space-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "example-space-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "example-space" - } - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "solutions-non-prod-default-example-group" - } - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "example-ctp-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "example-ctp-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "example-ctp" - } - } - } - } - kubernetesm.Object{ - metadata = { - name = "example-space-kubeconfig" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "example-space-kubeconfig" - namespace = "default" - } - data = { - kubeconfig = base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - watch = False - } - } - - kubernetesm.ProviderConfig{ - metadata = { - name = "example-space" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "example-space-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - ] - compositionPath: "apis/environments/composition.yaml" - xrPath: "examples/environment/example-no-cloudprovider-resources.yaml" - xrdPath: "apis/environments/definition.yaml" - context: {} - extraResources: [ - ] - observedResources = [ - kubernetesm.Object{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "observedCtpKubeconfig" - } - name = "observed-bootstrap-ctp-kubeconfig" - - namespace = "default" - } - spec = { - forProvider = { - manifest = {} - } - managementPolicies = [ - "Observe" - ] - } - status = { - atProvider = { - manifest = { - data = { - kubeconfig = "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGluc2VjdXJlLXNraXAtdGxzLXZlcmlmeTogdHJ1ZQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" - } - } - } - } - } - # Team and robot objects - { - apiVersion = "m.upbound.io/v1alpha1" - kind = "ProviderConfig" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "providerConfigUpbound" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example" - - namespace = "default" - } - spec = { - credentials = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - } - organization = "upbound" - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Team" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envTeam" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-team" - - namespace = "default" - } - spec = { - forProvider = { - name = "solutions-non-prod-default-example" - organizationName = "upbound" - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Token" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobotToken" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot-token" - - namespace = "default" - } - spec = { - forProvider = { - name = "solutions-non-prod-default-example" - owner = { - idRef = { - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - $type = "robots" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - writeConnectionSecretToRef = { - name = "solutions-non-prod-default-example-robot-token" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Robot" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobot" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - spec = { - forProvider = { - description = "Robot for solutions-non-prod-default-example" - name = "solutions-non-prod-default-example-bot" - owner = { - name = "upbound" - - namespace = "default" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "RobotTeamMembership" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobotTeamMembership" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot-team-membership" - - namespace = "default" - } - spec = { - forProvider = { - robotIdRef = { - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - teamIdRef = { - name = "solutions-non-prod-default-example-team" - - namespace = "default" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - kubernetesm.Object{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "robotTokenEnvCtpSecret" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-rt-secret" - - namespace = "default" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - namespace = "default" - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-ctp" - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - fieldPath = "data.token" - kind = "Secret" - name = "solutions-non-prod-default-example-robot-token" - } - toFieldPath = "data.token" - } - ] - watch = False - } - } - ] - timeoutSeconds = 60 - validate = False - } - } -] - -items = _items diff --git a/tests/test-environment-no-cloudprovider-resource/model b/tests/test-environment-no-cloudprovider-resource/model deleted file mode 120000 index 1b8c82f..0000000 --- a/tests/test-environment-no-cloudprovider-resource/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models/ \ No newline at end of file diff --git a/tests/test-environment-no-cloudprovider-resource/pyproject.toml b/tests/test-environment-no-cloudprovider-resource/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-no-cloudprovider-resource/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-no-cloudprovider-resource/test/__init__.py b/tests/test-environment-no-cloudprovider-resource/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-no-cloudprovider-resource/test/__main__.py b/tests/test-environment-no-cloudprovider-resource/test/__main__.py new file mode 100644 index 0000000..44cc87d --- /dev/null +++ b/tests/test-environment-no-cloudprovider-resource/test/__main__.py @@ -0,0 +1,268 @@ +import base64 + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +K8S_API = "kubernetes.m.crossplane.io/v1alpha1" +UPBOUND_PROVIDER_CONFIG = "solutions-non-prod-default-example" +COMPOSITE_LABEL = {"crossplane.io/composite": "example"} +BOOTSTRAP_PROVIDER_CONFIG_REF = {"kind": "ProviderConfig", "name": "bootstrap-ctp"} + + +def _b64(s: str) -> str: + return base64.b64encode(s.encode()).decode() + + +def _kubeconfig_secret(name: str, kubeconfig: str) -> dict: + """A kubeconfig Secret written to the bootstrap control plane through a provider-kubernetes Object.""" + return { + "apiVersion": K8S_API, + "kind": "Object", + "metadata": {"name": name}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", # typed KCL schema default + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": "default"}, + "data": {"kubeconfig": _b64(kubeconfig)}, + }, + }, + "managementPolicies": ["*"], + "providerConfigRef": BOOTSTRAP_PROVIDER_CONFIG_REF, + "watch": False, + }, + } + + +def _provider_config(name: str) -> dict: + """A provider-kubernetes ProviderConfig reading the `-kubeconfig` Secret, authenticated by Upbound token.""" + return { + "apiVersion": K8S_API, + "kind": "ProviderConfig", + "metadata": {"name": name}, + "spec": { + "credentials": { + "secretRef": {"key": "kubeconfig", "name": f"{name}-kubeconfig", "namespace": "default"}, + "source": "Secret", + }, + "identity": { + "secretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + "source": "Secret", + "type": "UpboundTokens", + }, + }, + } + + +def _usage(provider_config: str) -> dict: + """A Usage keeping the `-kubeconfig` Object alive while its ProviderConfig uses it.""" + secret = f"{provider_config}-kubeconfig" + return { + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": {"name": secret}, + "spec": { + "replayDeletion": True, + "of": {"apiVersion": K8S_API, "kind": "Object", "resourceRef": {"name": secret}}, + "by": {"apiVersion": K8S_API, "kind": "ProviderConfig", "resourceRef": {"name": provider_config}}, + }, + } + + +def _observed(kind: str, resource_name: str, name: str, spec: dict, api_version: str = "iam.m.upbound.io/v1alpha1") -> dict: + """An observed composed resource of the Upbound team and robot set.""" + return { + "apiVersion": api_version, + "kind": kind, + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": resource_name}, + "generateName": "example-", + "labels": COMPOSITE_LABEL, + "name": name, + "namespace": "default", + }, + "spec": spec, + } + + +UPBOUND_PROVIDER_CONFIG_REF = {"kind": "ProviderConfig", "name": UPBOUND_PROVIDER_CONFIG} + +tests = [ + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-no-cloudprovider-resource"), + spec=compositiontest.Spec( + assertResources=[ + _kubeconfig_secret( + "example-ctp-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + { + "apiVersion": K8S_API, + "kind": "Object", + "metadata": {"name": "example-ctp"}, + "spec": { + "readiness": {"policy": "DeriveFromObject"}, + "managementPolicies": ["Create", "Observe", "Update", "LateInitialize"], + "forProvider": { + "deletionPropagationPolicy": "Background", # typed KCL schema default + "manifest": { + "apiVersion": "spaces.upbound.io/v1beta1", + "kind": "ControlPlane", + "metadata": {"name": "example", "namespace": "solutions-non-prod-default-example"}, + "spec": {"class": "default", "crossplane": {"autoUpgrade": {"channel": "Rapid"}}}, + }, + }, + "watch": False, + }, + }, + _kubeconfig_secret( + "solutions-non-prod-default-example-group-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + _provider_config("example-ctp"), + _provider_config("solutions-non-prod-default-example-group"), + _usage("example-space"), + _usage("solutions-non-prod-default-example-group"), + _usage("example-ctp"), + _kubeconfig_secret( + "example-space-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + _provider_config("example-space"), + ], + compositionPath="apis/environments/composition.yaml", + xrPath="examples/environment/example-no-cloudprovider-resources.yaml", + xrdPath="apis/environments/definition.yaml", + context={}, + extraResources=[], + observedResources=[ + { + "apiVersion": K8S_API, + "kind": "Object", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "observedCtpKubeconfig"}, + "name": "observed-bootstrap-ctp-kubeconfig", + "namespace": "default", + }, + "spec": { + # deletionPropagationPolicy and watch are typed KCL schema defaults. + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": {}}, + "managementPolicies": ["Observe"], + "watch": False, + }, + "status": { + "atProvider": { + "manifest": { + "data": { + "kubeconfig": "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGluc2VjdXJlLXNraXAtdGxzLXZlcmlmeTogdHJ1ZQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" + } + } + } + }, + }, + # Team and robot objects + _observed( + "ProviderConfig", + "providerConfigUpbound", + UPBOUND_PROVIDER_CONFIG, + { + "credentials": { + "secretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + "source": "Secret", + }, + "organization": "upbound", + }, + api_version="m.upbound.io/v1alpha1", + ), + _observed( + "Team", + "envTeam", + "solutions-non-prod-default-example-team", + { + "forProvider": {"name": "solutions-non-prod-default-example", "organizationName": "upbound"}, + "managementPolicies": ["*"], + "providerConfigRef": UPBOUND_PROVIDER_CONFIG_REF, + }, + ), + _observed( + "Token", + "envRobotToken", + "solutions-non-prod-default-example-robot-token", + { + "forProvider": { + "name": "solutions-non-prod-default-example", + "owner": { + "idRef": {"name": "solutions-non-prod-default-example-robot", "namespace": "default"}, + "type": "robots", + }, + }, + "managementPolicies": ["*"], + "providerConfigRef": UPBOUND_PROVIDER_CONFIG_REF, + "writeConnectionSecretToRef": {"name": "solutions-non-prod-default-example-robot-token"}, + }, + ), + _observed( + "Robot", + "envRobot", + "solutions-non-prod-default-example-robot", + { + "forProvider": { + "description": "Robot for solutions-non-prod-default-example", + "name": "solutions-non-prod-default-example-bot", + "owner": {"name": "upbound", "namespace": "default"}, + }, + "managementPolicies": ["*"], + "providerConfigRef": UPBOUND_PROVIDER_CONFIG_REF, + }, + ), + _observed( + "RobotTeamMembership", + "envRobotTeamMembership", + "solutions-non-prod-default-example-robot-team-membership", + { + "forProvider": { + "robotIdRef": {"name": "solutions-non-prod-default-example-robot", "namespace": "default"}, + "teamIdRef": {"name": "solutions-non-prod-default-example-team", "namespace": "default"}, + }, + "managementPolicies": ["*"], + "providerConfigRef": UPBOUND_PROVIDER_CONFIG_REF, + }, + ), + _observed( + "Object", + "robotTokenEnvCtpSecret", + "solutions-non-prod-default-example-rt-secret", + { + "forProvider": { + "deletionPropagationPolicy": "Background", # typed KCL schema default + "manifest": {"apiVersion": "v1", "kind": "Secret", "metadata": {"namespace": "default"}}, + }, + "managementPolicies": ["*"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "example-ctp"}, + "references": [ + { + "patchesFrom": { + "apiVersion": "v1", + "fieldPath": "data.token", + "kind": "Secret", + "name": "solutions-non-prod-default-example-robot-token", + }, + "toFieldPath": "data.token", + } + ], + "watch": False, + }, + api_version=K8S_API, + ), + ], + timeoutSeconds=60, + validate=False, + ), + ), +] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-environment-team-with-robot/README.md b/tests/test-environment-team-with-robot/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-team-with-robot/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-team-with-robot/kcl.mod b/tests/test-environment-team-with-robot/kcl.mod deleted file mode 100644 index c58894c..0000000 --- a/tests/test-environment-team-with-robot/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-team-with-robot" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/tests/test-environment-team-with-robot/kcl.mod.lock b/tests/test-environment-team-with-robot/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment-team-with-robot/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment-team-with-robot/main.k b/tests/test-environment-team-with-robot/main.k deleted file mode 100644 index ce1948f..0000000 --- a/tests/test-environment-team-with-robot/main.k +++ /dev/null @@ -1,158 +0,0 @@ -""" -teamWithRobot - a Team, a Robot in it, a Token for the Robot, and, when the composition also -creates the group, an ObjectRoleBinding making the Team admin of that group. - -This is the path solutions-gitops-prod's `ci` Environment runs, and it had no test at all. -The binding's subject is the Team's Upbound ID, which only exists once the Team has been -created, so the composition reads it off the observed Team's external name. The test -supplies that observed Team, which is what makes the binding's subject renderable. -""" - -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.iamm.v1alpha1 as iamv1alpha1 -import models.io.upbound.sa.v1 as sav1 - -_status = { - bootstrapCtp = "bootstrap" - bootstrapGroup = "solutions-non-prod" - org = "upbound" - spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" -} - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-team-with-robot" - spec = { - assertResources = [ - iamv1alpha1.Robot{ - metadata.name = "solutions-non-prod-default-example-robot" - spec.forProvider = { - description = "Robot for solutions-non-prod-default-example" - name = "solutions-non-prod-default-example-bot" - owner.name = "upbound" - } - } - iamv1alpha1.Token{ - metadata.name = "solutions-non-prod-default-example-robot-token" - spec.forProvider = { - name = "solutions-non-prod-default-example" - owner.type = "robots" - } - } - iamv1alpha1.Team{ - metadata.name = "solutions-non-prod-default-example-team" - spec = { - # Teams are orphaned regardless of deletionPolicy. - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - forProvider = { - name = "solutions-non-prod-default-example-team" - organizationName = "upbound" - } - } - } - iamv1alpha1.RobotTeamMembership{ - metadata.name = "solutions-non-prod-default-example-robot-team-membership" - spec.forProvider = { - robotIdRef.name = "solutions-non-prod-default-example-robot" - teamIdRef.name = "solutions-non-prod-default-example-team" - } - } - # The binding's subject is the observed Team's Upbound ID. - kubernetesm.Object{ - metadata.name = "solutions-non-prod-default-example-admin-binding" - spec.forProvider.manifest = { - apiVersion = "authorization.spaces.upbound.io/v1alpha1" - kind = "ObjectRoleBinding" - spec.subjects = [{ - kind = "UpboundTeam" - role = "admin" - name = "11111111-2222-3333-4444-555555555555" - }] - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: sav1.Environment{ - metadata = {name = "example", namespace = "default"} - spec.parameters.upbound = { - initKubeconfigSecretRef = {name = "init-kubeconfig"} - tokenSecretRef = {name = "upbound-token"} - createArgoSecret = False - teamWithRobot = {} - } - status.upbound = _status - } - observedResources = [ - iamv1alpha1.Team{ - metadata = { - name = "solutions-non-prod-default-example-team" - namespace = "default" - annotations = { - "crossplane.io/composition-resource-name" = "envTeam" - "crossplane.io/external-name" = "11111111-2222-3333-4444-555555555555" - } - } - spec.forProvider = { - name = "solutions-non-prod-default-example-team" - organizationName = "upbound" - } - } - ] - timeoutSeconds = 60 - validate = False - } - } - # The shape of solutions-gitops-prod's `production-upbound-deploy`: adopt an existing Team - # by ID and add a new Robot to it, inside a group somebody else manages. - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-team-external-name" - spec = { - assertResources = [ - iamv1alpha1.Team{ - metadata = { - name = "solutions-non-prod-default-example-team" - annotations = {"crossplane.io/external-name" = "ae0e38df-fd52-4724-9c98-b8cd455d3d38"} - } - spec = { - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - forProvider = { - name = "CI" - organizationName = "upbound" - } - } - } - iamv1alpha1.Robot{ - metadata.name = "solutions-non-prod-default-example-robot" - spec.forProvider = { - description = "Robot for solutions-non-prod-default-example" - name = "solutions-non-prod-default-example-bot" - owner.name = "upbound" - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: sav1.Environment{ - metadata = {name = "example", namespace = "default"} - spec.parameters.upbound = { - initKubeconfigSecretRef = {name = "init-kubeconfig"} - tokenSecretRef = {name = "upbound-token"} - createArgoSecret = False - createGroup = False - createCtp = False - teamWithRobot = { - teamNameOverride = "CI" - teamExternalName = "ae0e38df-fd52-4724-9c98-b8cd455d3d38" - } - } - status.upbound = _status - } - timeoutSeconds = 60 - validate = False - } - } -] - -items = _items diff --git a/tests/test-environment-team-with-robot/model b/tests/test-environment-team-with-robot/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-environment-team-with-robot/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-environment-team-with-robot/pyproject.toml b/tests/test-environment-team-with-robot/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-team-with-robot/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-team-with-robot/test/__init__.py b/tests/test-environment-team-with-robot/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-team-with-robot/test/__main__.py b/tests/test-environment-team-with-robot/test/__main__.py new file mode 100644 index 0000000..23c1008 --- /dev/null +++ b/tests/test-environment-team-with-robot/test/__main__.py @@ -0,0 +1,183 @@ +"""teamWithRobot - a Team, a Robot in it, a Token for the Robot, and, when the composition also +creates the group, an ObjectRoleBinding making the Team admin of that group. + +This is the path solutions-gitops-prod's `ci` Environment runs, and it had no test at all. +The binding's subject is the Team's Upbound ID, which only exists once the Team has been +created, so the composition reads it off the observed Team's external name. The test +supplies that observed Team, which is what makes the binding's subject renderable. +""" + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +PREFIX = "solutions-non-prod-default-example" +TEAM = f"{PREFIX}-team" +ROBOT = f"{PREFIX}-robot" +TEAM_ID = "11111111-2222-3333-4444-555555555555" +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] + +STATUS = { + "bootstrapCtp": "bootstrap", + "bootstrapGroup": "solutions-non-prod", + "org": "upbound", + "spaceHost": "upbound-aws-us-east-1.space.mxe.upbound.io", +} + +ROBOT_RESOURCE = { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Robot", + "metadata": {"name": ROBOT}, + "spec": { + "forProvider": { + "description": f"Robot for {PREFIX}", + "name": f"{PREFIX}-bot", + "owner": {"name": "upbound"}, + }, + "managementPolicies": ["*"], + }, +} + + +def environment(upbound: dict) -> dict: + """The Environment XR under test; `upbound` is spec.parameters.upbound.""" + return { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "example", "namespace": "default"}, + "spec": { + "parameters": { + "deletionPolicy": "Orphan", + "upbound": { + "initKubeconfigSecretRef": {"key": "kubeconfig", "name": "init-kubeconfig", "namespace": "default"}, + "tokenSecretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + "createArgoSecret": False, + "createCtp": True, + "createGroup": True, + "initProviderConfigName": "bootstrap-ctp", + **upbound, + }, + }, + }, + "status": {"upbound": STATUS}, + } + + +tests = [ + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-team-with-robot"), + spec=compositiontest.Spec( + assertResources=[ + ROBOT_RESOURCE, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Token", + "metadata": {"name": f"{ROBOT}-token"}, + "spec": { + "forProvider": {"name": PREFIX, "owner": {"type": "robots"}}, + "managementPolicies": ["*"], + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Team", + "metadata": {"name": TEAM}, + "spec": { + # Teams are orphaned regardless of deletionPolicy. + "managementPolicies": ORPHAN, + "forProvider": {"name": TEAM, "organizationName": "upbound"}, + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "RobotTeamMembership", + "metadata": {"name": f"{ROBOT}-team-membership"}, + "spec": { + "forProvider": {"robotIdRef": {"name": ROBOT}, "teamIdRef": {"name": TEAM}}, + "managementPolicies": ["*"], + }, + }, + # The binding's subject is the observed Team's Upbound ID. + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": f"{PREFIX}-admin-binding"}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", + "manifest": { + "apiVersion": "authorization.spaces.upbound.io/v1alpha1", + "kind": "ObjectRoleBinding", + "spec": {"subjects": [{"kind": "UpboundTeam", "role": "admin", "name": TEAM_ID}]}, + }, + }, + "managementPolicies": ["*"], + "watch": False, + }, + }, + ], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr=environment({"teamWithRobot": {}}), + observedResources=[ + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Team", + "metadata": { + "name": TEAM, + "namespace": "default", + "annotations": { + "crossplane.io/composition-resource-name": "envTeam", + "crossplane.io/external-name": TEAM_ID, + }, + }, + "spec": { + "forProvider": {"name": TEAM, "organizationName": "upbound"}, + "managementPolicies": ["*"], + }, + }, + ], + timeoutSeconds=60, + validate=False, + ), + ), + # The shape of solutions-gitops-prod's `production-upbound-deploy`: adopt an existing Team + # by ID and add a new Robot to it, inside a group somebody else manages. + compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-team-external-name"), + spec=compositiontest.Spec( + assertResources=[ + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Team", + "metadata": { + "name": TEAM, + "annotations": {"crossplane.io/external-name": "ae0e38df-fd52-4724-9c98-b8cd455d3d38"}, + }, + "spec": { + "managementPolicies": ORPHAN, + "forProvider": {"name": "CI", "organizationName": "upbound"}, + }, + }, + ROBOT_RESOURCE, + ], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr=environment( + { + "createGroup": False, + "createCtp": False, + "teamWithRobot": { + "teamNameOverride": "CI", + "teamExternalName": "ae0e38df-fd52-4724-9c98-b8cd455d3d38", + }, + } + ), + timeoutSeconds=60, + validate=False, + ), + ), +] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-environment-uninitialized/README.md b/tests/test-environment-uninitialized/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment-uninitialized/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment-uninitialized/kcl.mod b/tests/test-environment-uninitialized/kcl.mod deleted file mode 100644 index 3c74ff9..0000000 --- a/tests/test-environment-uninitialized/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment-uninitialized" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/tests/test-environment-uninitialized/main.k b/tests/test-environment-uninitialized/main.k deleted file mode 100644 index e629802..0000000 --- a/tests/test-environment-uninitialized/main.k +++ /dev/null @@ -1,68 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - -# The first reconcile of any Environment happens before status.upbound exists. The function -# must emit only the bootstrap-kubeconfig observer and wait, rather than aborting the -# pipeline. Nothing else covers this: every other suite supplies a populated status, either -# in its example or inline, so the uninitialised branch was never rendered. -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-uninitialized" - spec = { - assertResources: [ - kubernetesm.Object{ - metadata.name = "fresh-bootstrap-ctp-kubeconfig-observed" - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "bootstrap-kubeconfig" - namespace = "default" - } - } - } - managementPolicies = ["Observe"] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - } - } - ] - compositionPath: "apis/environments/composition.yaml" - xrdPath: "apis/environments/definition.yaml" - xr: sav1.Environment{ - metadata = { - name = "fresh" - namespace = "default" - } - # `upbound = None`, not an absent status: this is the exact shape a brand new - # XR has on a live control plane, and it is what distinguishes a correct guard - # from one written against Undefined. - status = { - upbound = None - } - spec = { - parameters = { - upbound = { - initKubeconfigSecretRef = { - name = "bootstrap-kubeconfig" - namespace = "default" - } - tokenSecretRef = { - name = "bootstrap-token" - namespace = "default" - } - } - } - } - } - timeoutSeconds: 60 - validate: False - } - } -] -items = _items diff --git a/tests/test-environment-uninitialized/model b/tests/test-environment-uninitialized/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-environment-uninitialized/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-environment-uninitialized/pyproject.toml b/tests/test-environment-uninitialized/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment-uninitialized/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment-uninitialized/test/__init__.py b/tests/test-environment-uninitialized/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment-uninitialized/test/__main__.py b/tests/test-environment-uninitialized/test/__main__.py new file mode 100644 index 0000000..cd78c97 --- /dev/null +++ b/tests/test-environment-uninitialized/test/__main__.py @@ -0,0 +1,71 @@ +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +# The first reconcile of any Environment happens before status.upbound exists. The function +# must emit only the bootstrap-kubeconfig observer and wait, rather than aborting the +# pipeline. Nothing else covers this: every other suite supplies a populated status, either +# in its example or inline, so the uninitialised branch was never rendered. +test = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-uninitialized"), + spec=compositiontest.Spec( + assertResources=[ + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": "fresh-bootstrap-ctp-kubeconfig-observed"}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", # Object schema default + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "bootstrap-kubeconfig", "namespace": "default"}, + }, + }, + "managementPolicies": ["Observe"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "bootstrap-ctp"}, + "watch": False, # Object schema default + }, + }, + ], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr={ + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "fresh", "namespace": "default"}, + # `upbound = None`, not an absent status: this is the exact shape a brand new + # XR has on a live control plane, and it is what distinguishes a correct guard + # from one written against Undefined. + "status": {"upbound": None}, + "spec": { + "parameters": { + # Environment schema defaults. + "deletionPolicy": "Orphan", + "upbound": { + "createArgoSecret": True, + "createCtp": True, + "createGroup": True, + "initKubeconfigSecretRef": { + "key": "kubeconfig", # schema default + "name": "bootstrap-kubeconfig", + "namespace": "default", + }, + "initProviderConfigName": "bootstrap-ctp", # schema default + "tokenSecretRef": { + "key": "token", # schema default + "name": "bootstrap-token", + "namespace": "default", + }, + }, + }, + }, + }, + timeoutSeconds=60, + validate=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) diff --git a/tests/test-environment/README.md b/tests/test-environment/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-environment/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-environment/kcl.mod b/tests/test-environment/kcl.mod deleted file mode 100644 index 797e511..0000000 --- a/tests/test-environment/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-environment" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-environment/kcl.mod.lock b/tests/test-environment/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-environment/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-environment/main.k b/tests/test-environment/main.k deleted file mode 100644 index b10b268..0000000 --- a/tests/test-environment/main.k +++ /dev/null @@ -1,778 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.awsm.v1beta1 as awsv1beta1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 -import json -import base64 - - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-environment" - spec = { - assertResources: [ - kubernetesm.Object{ - metadata: { - name: "example-ctp-kubeconfig" - } - spec: { - managementPolicies: ["*"] - forProvider: { - manifest: { - apiVersion: "v1" - kind: "Secret" - metadata: { - name: "example-ctp-kubeconfig" - namespace: "default" - } - data : { - kubeconfig : base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - providerConfigRef: { - kind: "ProviderConfig" - name: "bootstrap-ctp" - } - watch: False - } - } - kubernetesm.Object{ - metadata: { - name: "example-ctp" - } - spec: { - readiness: { - policy: "DeriveFromObject" - } - managementPolicies: ["Create", "Observe", "Update", "LateInitialize"] - forProvider: { - manifest: { - apiVersion: "spaces.upbound.io/v1beta1" - kind: "ControlPlane" - metadata: { - name: "example" - namespace: "solutions-non-prod-default-example" - } - spec: { - class: "default" - crossplane: { - autoUpgrade: { - channel: "Rapid" - } - } - } - } - } - watch: False - } - } - kubernetesm.Object{ - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - namespace = "default" - } - data = { - kubeconfig = base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - watch = False - } - } - kubernetesm.ProviderConfig{ - metadata = { - name = "example-ctp" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "example-ctp-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - ### AWS ### - iamv1beta1.Role{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-admin" - } - spec = { - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - forProvider = { - assumeRolePolicy = r"""{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Federated": "arn:aws:iam::12345678912:oidc-provider/proidc.upbound.io" - }, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": { - "StringEquals": { - "proidc.upbound.io:sub": "mcp:upbound/example:provider:provider-aws", - "proidc.upbound.io:aud": "sts.amazonaws.com" - } - } - } - ] -}""" - } - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - iamv1beta1.RolePolicyAttachment{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-admin" - } - spec = { - forProvider = { - policyArn = "arn:aws:iam::aws:policy/AdministratorAccess" - roleSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - } - spec = { - parameters = { - deletionPolicy = "Orphan" - aws = { - accountId = "12345678912" - region = "us-east-1" - namePrefix = "upbound-solutions-non-prod-default-example-example" - providerConfigRef = { - name = "solutions-non-prod-default-example" - } - } - upbound = { - group = "solutions-non-prod-default-example" - controlPlane = "example" - providerConfigRef = { - name = "solutions-non-prod-default-example-group" - } - } - } - } - } - awsv1beta1.ProviderConfig{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "solutions-non-prod-default-example" - } - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example" - } - spec = { - credentials = { - secretRef = { - key = "credentials" - name = "aws-creds-example" - namespace = "default" - } - source = "Secret" - } - } - } - kubernetesm.ProviderConfig{ - metadata = { - name = "solutions-non-prod-default-example-group" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "solutions-non-prod-default-example-group-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - iamv1beta1.OpenIDConnectProvider{ - metadata = { - name = "upbound-solutions-non-prod-default-example-example-oidc-provider" - } - spec = { - forProvider = { - clientIdList = [ - "sts.amazonaws.com" - ] - url = "https://proidc.upbound.io" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "example-space-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "example-space-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "example-space" - } - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "solutions-non-prod-default-example-group-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "solutions-non-prod-default-example-group" - } - } - } - } - { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "example-ctp-kubeconfig" - } - spec = { - replayDeletion = True - of = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - resourceRef = { - name = "example-ctp-kubeconfig" - } - } - by = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "ProviderConfig" - resourceRef = { - name = "example-ctp" - } - } - } - } - kubernetesm.Object{ - metadata = { - name = "example-space-kubeconfig" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "example-space-kubeconfig" - namespace = "default" - } - data = { - kubeconfig = base64.encode("{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}") - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - watch = False - } - } - - kubernetesm.Object{ - metadata = { - name = "example-observed-access-token-observed" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "upbound-token" - namespace = "default" - } - } - } - managementPolicies = [ - "Observe" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - } - } - - kubernetesm.Object{ - metadata = { - name = "example-ctp-argocd-secret" - } - spec = { - forProvider = { - manifest = { - apiVersion: "v1" - kind: "Secret" - metadata: { - name: "solutions-non-prod-default-example-example" - namespace: "argocd" - labels: { - "argocd.argoproj.io/secret-type": "cluster" - } - } - type: "Opaque" - data : { - name : base64.encode("solutions-non-prod-default-example-example") - server : base64.encode("https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s") - config : base64.encode(json.encode({ - execProviderConfig: { - apiVersion: "client.authentication.k8s.io/v1" - command: "up" - args: [ - "org" - "token" - ] - env: { - "ORGANIZATION": "upbound" - "UP_TOKEN": "uptest-token" - } - } - tlsClientConfig: { - insecure: False - caData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ6ekNDQVhTZ0F3SUJBZ0lSQUtuaU1IS1lkN01oQUJDbzMxRHI3cDh3Q2dZSUtvWkl6ajBFQXdJd056RUwKTUFrR0ExVUVCaE1DVlZNeEVEQU9CZ05WQkFvVEIzVndZbTkxYm1ReEZqQVVCZ05WQkFNVERWVndZbTkxYm1RcwpJRWx1WXk0d0hoY05NalV3TXpBeU1EQXpPVE0wV2hjTk1qWXdNekF5TURBek9UTTBXakEzTVFzd0NRWURWUVFHCkV3SlZVekVRTUE0R0ExVUVDaE1IZFhCaWIzVnVaREVXTUJRR0ExVUVBeE1OVlhCaWIzVnVaQ3dnU1c1akxqQloKTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCTTUyUE5BWFNuQ0pHNzdWbmU2K01VVWllSW5SdmR4YQpOaDlqeW5NS3RMM2QrdWNTMTQ0R3ZLbFpiS3l1dXZzZDhrSkJyZWg3V1A3Sk9pcDFyRmU1T2d5allUQmZNQTRHCkExVWREd0VCL3dRRUF3SUJwakFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WUQKVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVUydmJKZTBVbzJjMmlsODdXOGhISWRUdHZVeWd3Q2dZSQpLb1pJemowRUF3SURTUUF3UmdJaEFLSDlLTWFHelVjcVo3NHR1aVI5VFd6S2tnakRMNWlTRWZmM0ZENktaZy9PCkFpRUFwVU8yMGZtRU9Ua0hsUHN2MTh2T1VVby8rRWJnSXo3M00waG5VQysySFJFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" - } - })) - } - } - } - } - } - - kubernetesm.ProviderConfig{ - metadata = { - name = "example-space" - } - spec = { - credentials = { - secretRef = { - key = "kubeconfig" - name = "example-space-kubeconfig" - namespace = "default" - } - source = "Secret" - } - identity = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - $type = "UpboundTokens" - } - } - } - ] - compositionPath: "apis/environments/composition.yaml" - xrPath: "examples/environment/example.yaml" - xrdPath: "apis/environments/definition.yaml" - observedResources = [ - kubernetesm.Object{ - metadata = { - namespace = "default" - name = "example-observed-access-token-observed" - annotations: { - "crossplane.io/composition-resource-name": "observed-access-token" - } - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "upbound-token" - namespace = "default" - } - } - } - managementPolicies = [ - "Observe" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "bootstrap-ctp" - } - } - status = { - atProvider = { - manifest = { - data = { - token = base64.encode("uptest-token") - } - } - } - } - } - kubernetesm.Object{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "observedCtpKubeconfig" - } - name = "observed-bootstrap-ctp-kubeconfig" - - namespace = "default" - } - spec = { - forProvider = { - manifest = {} - } - managementPolicies = [ - "Observe" - ] - } - status = { - atProvider = { - manifest = { - data = { - kubeconfig = "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGNlcnRpZmljYXRlLWF1dGhvcml0eS1kYXRhOiBMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VKNmVrTkRRVmhUWjBGM1NVSkJaMGxTUVV0dWFVMUlTMWxrTjAxb1FVSkRiek14UkhJM2NEaDNRMmRaU1V0dldrbDZhakJGUVhkSmQwNTZSVXdLVFVGclIwRXhWVVZDYUUxRFZsWk5lRVZFUVU5Q1owNVdRa0Z2VkVJelZuZFpiVGt4WW0xUmVFWnFRVlZDWjA1V1FrRk5WRVJXVm5kWmJUa3hZbTFSY3dwSlJXeDFXWGswZDBob1kwNU5hbFYzVFhwQmVVMUVRWHBQVkUwd1YyaGpUazFxV1hkTmVrRjVUVVJCZWs5VVRUQlhha0V6VFZGemQwTlJXVVJXVVZGSENrVjNTbFpWZWtWUlRVRTBSMEV4VlVWRGFFMUlaRmhDYVdJelZuVmFSRVZYVFVKUlIwRXhWVVZCZUUxT1ZsaENhV0l6Vm5WYVEzZG5VMWMxYWt4cVFsb0tUVUpOUjBKNWNVZFRUVFE1UVdkRlIwTkRjVWRUVFRRNVFYZEZTRUV3U1VGQ1RUVXlVRTVCV0ZOdVEwcEhOemRXYm1VMkswMVZWV2xsU1c1U2RtUjRZUXBPYURscWVXNU5TM1JNTTJRcmRXTlRNVFEwUjNaTGJGcGlTM2wxZFhaelpEaHJTa0p5WldnM1YxQTNTazlwY0RGeVJtVTFUMmQ1YWxsVVFtWk5RVFJIQ2tFeFZXUkVkMFZDTDNkUlJVRjNTVUp3YWtGa1FtZE9Wa2hUVlVWR2FrRlZRbWRuY2tKblJVWkNVV05FUVZGWlNVdDNXVUpDVVZWSVFYZEpkMFIzV1VRS1ZsSXdWRUZSU0M5Q1FWVjNRWGRGUWk5NlFXUkNaMDVXU0ZFMFJVWm5VVlV5ZG1KS1pUQlZiekpqTW1sc09EZFhPR2hJU1dSVWRIWlZlV2QzUTJkWlNRcExiMXBKZW1vd1JVRjNTVVJUVVVGM1VtZEphRUZMU0RsTFRXRkhlbFZqY1ZvM05IUjFhVkk1VkZkNlMydG5ha1JNTldsVFJXWm1NMFpFTmt0YVp5OVBDa0ZwUlVGd1ZVOHlNR1p0UlU5VWEwaHNVSE4yTVRoMlQxVlZieThyUldKblNYbzNNMDB3YUc1VlF5c3lTRkpGUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" - } - } - } - } - } - # Team and robot objects - { - apiVersion = "m.upbound.io/v1alpha1" - kind = "ProviderConfig" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "providerConfigUpbound" - } - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example <- that this test doesn't fail is a bug, leave it here to uncover when fixed" - - namespace = "default" - } - spec = { - credentials = { - secretRef = { - key = "token" - name = "upbound-token" - namespace = "default" - } - source = "Secret" - } - organization = "upbound" - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Team" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envTeam" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-team" - - namespace = "default" - } - spec = { - forProvider = { - name = "solutions-non-prod-default-example" - organizationName = "upbound" - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Token" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobotToken" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot-token" - - namespace = "default" - } - spec = { - forProvider = { - name = "solutions-non-prod-default-example" - owner = { - idRef = { - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - $type = "robots" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - writeConnectionSecretToRef = { - name = "solutions-non-prod-default-example-robot-token" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "Robot" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobot" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - spec = { - forProvider = { - description = "Robot for solutions-non-prod-default-example" - name = "solutions-non-prod-default-example-bot" - owner = { - name = "upbound" - - namespace = "default" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - { - apiVersion = "iam.m.upbound.io/v1alpha1" - kind = "RobotTeamMembership" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "envRobotTeamMembership" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-robot-team-membership" - - namespace = "default" - } - spec = { - forProvider = { - robotIdRef = { - name = "solutions-non-prod-default-example-robot" - - namespace = "default" - } - teamIdRef = { - name = "solutions-non-prod-default-example-team" - - namespace = "default" - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "solutions-non-prod-default-example" - } - } - } - kubernetesm.Object{ - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "robotTokenEnvCtpSecret" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - name = "solutions-non-prod-default-example-rt-secret" - - namespace = "default" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - namespace = "default" - } - } - } - managementPolicies = ["*"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-ctp" - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - fieldPath = "data.token" - kind = "Secret" - name = "solutions-non-prod-default-example-robot-token" - } - toFieldPath = "data.token" - } - ] - watch = False - } - } - ] - timeoutSeconds = 60 - validate = False - } - } - # secretsManagerSecret settings have to survive the hop into the nested SharedAWSSecret. - # recoveryWindowInDays is the one that bites: 0 is a meaningful value and a falsy one, so a - # truthy pass-through test drops it silently and teardown goes back to scheduling the secret - # for 30 days - which blocks the next run under the same name. - metav1alpha1.CompositionTest{ - metadata.name: "test-environment-secretsmanager-recovery-window" - spec = { - assertResources = [ - sav1.SharedAWSSecret{ - metadata.name = "example-shared-secret" - spec.parameters.aws.secretsManagerSecret = { - recoveryWindowInDays = 0 - } - } - ] - compositionPath = "apis/environments/composition.yaml" - xrdPath = "apis/environments/definition.yaml" - xr: sav1.Environment{ - metadata = { - name = "example" - namespace = "default" - } - spec.parameters = { - deletionPolicy = "Delete" - aws = { - accountId = "12345678912" - region = "us-east-1" - credsSecretRef = {name = "aws-creds-example", namespace = "default"} - sharedSecret = { - secretsManagerSecret = { - recoveryWindowInDays = 0 - } - } - } - upbound = { - initKubeconfigSecretRef = {name = "init-kubeconfig"} - tokenSecretRef = {name = "upbound-token"} - } - } - status.upbound = { - bootstrapCtp = "bootstrap" - bootstrapGroup = "solutions-non-prod" - org = "upbound" - spaceHost = "upbound-aws-us-east-1.space.mxe.upbound.io" - } - } - timeoutSeconds = 60 - validate = False - } - } -] - -items = _items diff --git a/tests/test-environment/model b/tests/test-environment/model deleted file mode 120000 index 1b8c82f..0000000 --- a/tests/test-environment/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models/ \ No newline at end of file diff --git a/tests/test-environment/pyproject.toml b/tests/test-environment/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-environment/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-environment/test/__init__.py b/tests/test-environment/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-environment/test/__main__.py b/tests/test-environment/test/__main__.py new file mode 100644 index 0000000..4475fbb --- /dev/null +++ b/tests/test-environment/test/__main__.py @@ -0,0 +1,498 @@ +import base64 +import json + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +ORCHESTRATE = ["Create", "Observe", "Update", "LateInitialize"] + + +def b64(s: str) -> str: + return base64.b64encode(s.encode()).decode() + + +def kubernetes_object(name: str, spec: dict, metadata: dict | None = None) -> dict: + """A kubernetes.m.crossplane.io Object, with the defaults its schema materialises.""" + spec = {"managementPolicies": ["*"], "watch": False, **spec} + spec["forProvider"] = {"deletionPropagationPolicy": "Background", **spec["forProvider"]} + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": name, **(metadata or {})}, + "spec": spec, + } + + +def kubeconfig_object(name: str, kubeconfig: str) -> dict: + """A kubeconfig Secret written through the bootstrap control plane.""" + return kubernetes_object( + name, + { + "forProvider": { + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": "default"}, + "data": {"kubeconfig": b64(kubeconfig)}, + }, + }, + "managementPolicies": ["*"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "bootstrap-ctp"}, + "watch": False, + }, + ) + + +def kubernetes_provider_config(name: str, secret: str) -> dict: + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": {"name": name}, + "spec": { + "credentials": { + "secretRef": {"key": "kubeconfig", "name": secret, "namespace": "default"}, + "source": "Secret", + }, + "identity": { + "secretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + "source": "Secret", + "type": "UpboundTokens", + }, + }, + } + + +def usage(name: str, by: str) -> dict: + return { + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": {"name": name}, + "spec": { + "replayDeletion": True, + "of": { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "resourceRef": {"name": name}, + }, + "by": { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "resourceRef": {"name": by}, + }, + }, + } + + +AWS_PROVIDER_CONFIG_REF = {"kind": "ProviderConfig", "name": "solutions-non-prod-default-example"} + +# The upbound-token Secret, observed through the bootstrap control plane. +OBSERVED_ACCESS_TOKEN_SPEC = { + "forProvider": { + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "upbound-token", "namespace": "default"}, + }, + }, + "managementPolicies": ["Observe"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "bootstrap-ctp"}, +} + +test_environment = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment"), + spec=compositiontest.Spec( + assertResources=[ + kubeconfig_object( + "example-ctp-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + kubernetes_object( + "example-ctp", + { + "readiness": {"policy": "DeriveFromObject"}, + "managementPolicies": ORCHESTRATE, + "forProvider": { + "manifest": { + "apiVersion": "spaces.upbound.io/v1beta1", + "kind": "ControlPlane", + "metadata": {"name": "example", "namespace": "solutions-non-prod-default-example"}, + "spec": {"class": "default", "crossplane": {"autoUpgrade": {"channel": "Rapid"}}}, + }, + }, + "watch": False, + }, + ), + kubeconfig_object( + "solutions-non-prod-default-example-group-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + kubernetes_provider_config("example-ctp", "example-ctp-kubeconfig"), + ### AWS ### + { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": "Role", + "metadata": {"name": "upbound-solutions-non-prod-default-example-example-admin"}, + "spec": { + "managementPolicies": ORCHESTRATE, + "forProvider": { + "assumeRolePolicy": r"""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::12345678912:oidc-provider/proidc.upbound.io" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "proidc.upbound.io:sub": "mcp:upbound/example:provider:provider-aws", + "proidc.upbound.io:aud": "sts.amazonaws.com" + } + } + } + ] +}""", + }, + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": "RolePolicyAttachment", + "metadata": {"name": "upbound-solutions-non-prod-default-example-example-admin"}, + "spec": { + "forProvider": { + "policyArn": "arn:aws:iam::aws:policy/AdministratorAccess", + "roleSelector": {"matchControllerRef": True}, + }, + "managementPolicies": ORCHESTRATE, + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + { + "apiVersion": "sa.upbound.io/v1", + "kind": "SharedAWSSecret", + "metadata": {"name": "example-shared-secret"}, + "spec": { + "parameters": { + "deletionPolicy": "Orphan", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "namePrefix": "upbound-solutions-non-prod-default-example-example", + "providerConfigRef": {"name": "solutions-non-prod-default-example"}, + }, + "upbound": { + "group": "solutions-non-prod-default-example", + "controlPlane": "example", + "providerConfigRef": {"name": "solutions-non-prod-default-example-group"}, + }, + }, + }, + }, + { + "apiVersion": "aws.m.upbound.io/v1beta1", + "kind": "ProviderConfig", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "solutions-non-prod-default-example"}, + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example", + }, + "spec": { + "credentials": { + "secretRef": {"key": "credentials", "name": "aws-creds-example", "namespace": "default"}, + "source": "Secret", + }, + }, + }, + kubernetes_provider_config( + "solutions-non-prod-default-example-group", "solutions-non-prod-default-example-group-kubeconfig" + ), + { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": "OpenIDConnectProvider", + "metadata": {"name": "upbound-solutions-non-prod-default-example-example-oidc-provider"}, + "spec": { + "forProvider": {"clientIdList": ["sts.amazonaws.com"], "url": "https://proidc.upbound.io"}, + "managementPolicies": ORCHESTRATE, + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + usage("example-space-kubeconfig", by="example-space"), + usage("solutions-non-prod-default-example-group-kubeconfig", by="solutions-non-prod-default-example-group"), + usage("example-ctp-kubeconfig", by="example-ctp"), + kubeconfig_object( + "example-space-kubeconfig", + "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + ), + kubernetes_object("example-observed-access-token-observed", OBSERVED_ACCESS_TOKEN_SPEC), + kubernetes_object( + "example-ctp-argocd-secret", + { + "forProvider": { + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "solutions-non-prod-default-example-example", + "namespace": "argocd", + "labels": {"argocd.argoproj.io/secret-type": "cluster"}, + }, + "type": "Opaque", + "data": { + "name": b64("solutions-non-prod-default-example-example"), + "server": b64( + "https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s" + ), + "config": b64( + json.dumps( + { + "execProviderConfig": { + "apiVersion": "client.authentication.k8s.io/v1", + "command": "up", + "args": ["org", "token"], + "env": {"ORGANIZATION": "upbound", "UP_TOKEN": "uptest-token"}, + }, + "tlsClientConfig": { + "insecure": False, + "caData": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ6ekNDQVhTZ0F3SUJBZ0lSQUtuaU1IS1lkN01oQUJDbzMxRHI3cDh3Q2dZSUtvWkl6ajBFQXdJd056RUwKTUFrR0ExVUVCaE1DVlZNeEVEQU9CZ05WQkFvVEIzVndZbTkxYm1ReEZqQVVCZ05WQkFNVERWVndZbTkxYm1RcwpJRWx1WXk0d0hoY05NalV3TXpBeU1EQXpPVE0wV2hjTk1qWXdNekF5TURBek9UTTBXakEzTVFzd0NRWURWUVFHCkV3SlZVekVRTUE0R0ExVUVDaE1IZFhCaWIzVnVaREVXTUJRR0ExVUVBeE1OVlhCaWIzVnVaQ3dnU1c1akxqQloKTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCTTUyUE5BWFNuQ0pHNzdWbmU2K01VVWllSW5SdmR4YQpOaDlqeW5NS3RMM2QrdWNTMTQ0R3ZLbFpiS3l1dXZzZDhrSkJyZWg3V1A3Sk9pcDFyRmU1T2d5allUQmZNQTRHCkExVWREd0VCL3dRRUF3SUJwakFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0R3WUQKVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVUydmJKZTBVbzJjMmlsODdXOGhISWRUdHZVeWd3Q2dZSQpLb1pJemowRUF3SURTUUF3UmdJaEFLSDlLTWFHelVjcVo3NHR1aVI5VFd6S2tnakRMNWlTRWZmM0ZENktaZy9PCkFpRUFwVU8yMGZtRU9Ua0hsUHN2MTh2T1VVby8rRWJnSXo3M00waG5VQysySFJFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==", + }, + } + ) + ), + }, + }, + }, + }, + ), + kubernetes_provider_config("example-space", "example-space-kubeconfig"), + ], + compositionPath="apis/environments/composition.yaml", + xrPath="examples/environment/example.yaml", + xrdPath="apis/environments/definition.yaml", + observedResources=[ + { + **kubernetes_object( + "example-observed-access-token-observed", + OBSERVED_ACCESS_TOKEN_SPEC, + metadata={ + "namespace": "default", + "annotations": {"crossplane.io/composition-resource-name": "observed-access-token"}, + }, + ), + "status": {"atProvider": {"manifest": {"data": {"token": b64("uptest-token")}}}}, + }, + { + **kubernetes_object( + "observed-bootstrap-ctp-kubeconfig", + {"forProvider": {"manifest": {}}, "managementPolicies": ["Observe"]}, + metadata={ + "annotations": {"crossplane.io/composition-resource-name": "observedCtpKubeconfig"}, + "namespace": "default", + }, + ), + "status": { + "atProvider": { + "manifest": { + "data": { + "kubeconfig": "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGNlcnRpZmljYXRlLWF1dGhvcml0eS1kYXRhOiBMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VKNmVrTkRRVmhUWjBGM1NVSkJaMGxTUVV0dWFVMUlTMWxrTjAxb1FVSkRiek14UkhJM2NEaDNRMmRaU1V0dldrbDZhakJGUVhkSmQwNTZSVXdLVFVGclIwRXhWVVZDYUUxRFZsWk5lRVZFUVU5Q1owNVdRa0Z2VkVJelZuZFpiVGt4WW0xUmVFWnFRVlZDWjA1V1FrRk5WRVJXVm5kWmJUa3hZbTFSY3dwSlJXeDFXWGswZDBob1kwNU5hbFYzVFhwQmVVMUVRWHBQVkUwd1YyaGpUazFxV1hkTmVrRjVUVVJCZWs5VVRUQlhha0V6VFZGemQwTlJXVVJXVVZGSENrVjNTbFpWZWtWUlRVRTBSMEV4VlVWRGFFMUlaRmhDYVdJelZuVmFSRVZYVFVKUlIwRXhWVVZCZUUxT1ZsaENhV0l6Vm5WYVEzZG5VMWMxYWt4cVFsb0tUVUpOUjBKNWNVZFRUVFE1UVdkRlIwTkRjVWRUVFRRNVFYZEZTRUV3U1VGQ1RUVXlVRTVCV0ZOdVEwcEhOemRXYm1VMkswMVZWV2xsU1c1U2RtUjRZUXBPYURscWVXNU5TM1JNTTJRcmRXTlRNVFEwUjNaTGJGcGlTM2wxZFhaelpEaHJTa0p5WldnM1YxQTNTazlwY0RGeVJtVTFUMmQ1YWxsVVFtWk5RVFJIQ2tFeFZXUkVkMFZDTDNkUlJVRjNTVUp3YWtGa1FtZE9Wa2hUVlVWR2FrRlZRbWRuY2tKblJVWkNVV05FUVZGWlNVdDNXVUpDVVZWSVFYZEpkMFIzV1VRS1ZsSXdWRUZSU0M5Q1FWVjNRWGRGUWk5NlFXUkNaMDVXU0ZFMFJVWm5VVlV5ZG1KS1pUQlZiekpqTW1sc09EZFhPR2hJU1dSVWRIWlZlV2QzUTJkWlNRcExiMXBKZW1vd1JVRjNTVVJUVVVGM1VtZEphRUZMU0RsTFRXRkhlbFZqY1ZvM05IUjFhVkk1VkZkNlMydG5ha1JNTldsVFJXWm1NMFpFTmt0YVp5OVBDa0ZwUlVGd1ZVOHlNR1p0UlU5VWEwaHNVSE4yTVRoMlQxVlZieThyUldKblNYbzNNMDB3YUc1VlF5c3lTRkpGUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PQogICAgc2VydmVyOiBodHRwczovL3VwYm91bmQtYXdzLXVzLWVhc3QtMS5zcGFjZS5teGUudXBib3VuZC5pby9hcGlzL3NwYWNlcy51cGJvdW5kLmlvL3YxYmV0YTEvbmFtZXNwYWNlcy9zb2x1dGlvbnMtbm9uLXByb2QvY29udHJvbHBsYW5lcy9ib290c3RyYXAvazhzCiAgbmFtZTogdXBib3VuZApjb250ZXh0czoKLSBjb250ZXh0OgogICAgY2x1c3RlcjogdXBib3VuZAogICAgZXh0ZW5zaW9uczoKICAgIC0gZXh0ZW5zaW9uOgogICAgICAgIGFwaVZlcnNpb246IHVwYm91bmQuaW8vdjFhbHBoYTEKICAgICAgICBraW5kOiBTcGFjZUV4dGVuc2lvbgogICAgICAgIHNwZWM6CiAgICAgICAgICBjbG91ZDoKICAgICAgICAgICAgb3JnYW5pemF0aW9uOiB1cGJvdW5kCiAgICAgIG5hbWU6IHNwYWNlcy51cGJvdW5kLmlvL3NwYWNlCiAgICBuYW1lc3BhY2U6IGRlZmF1bHQKICAgIHVzZXI6IHVwYm91bmQKICBuYW1lOiB1cGJvdW5kCmN1cnJlbnQtY29udGV4dDogdXBib3VuZApraW5kOiBDb25maWcKcHJlZmVyZW5jZXM6IHt9CnVzZXJzOgotIG5hbWU6IHVwYm91bmQKICB1c2VyOgogICAgZXhlYzoKICAgICAgYXBpVmVyc2lvbjogY2xpZW50LmF1dGhlbnRpY2F0aW9uLms4cy5pby92MQogICAgICBhcmdzOgogICAgICAtIG9yZ2FuaXphdGlvbgogICAgICAtIHRva2VuCiAgICAgIGNvbW1hbmQ6IHVwCiAgICAgIGVudjoKICAgICAgLSBuYW1lOiBPUkdBTklaQVRJT04KICAgICAgICB2YWx1ZTogdXBib3VuZAogICAgICAtIG5hbWU6IFVQX1BST0ZJTEUKICAgICAgICB2YWx1ZTogZGVmYXVsdAogICAgICBpbnRlcmFjdGl2ZU1vZGU6IElmQXZhaWxhYmxlCiAgICAgIHByb3ZpZGVDbHVzdGVySW5mbzogZmFsc2UK" + }, + }, + }, + }, + }, + # Team and robot objects + { + "apiVersion": "m.upbound.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "providerConfigUpbound"}, + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example <- that this test doesn't fail is a bug, leave it here to uncover when fixed", + "namespace": "default", + }, + "spec": { + "credentials": { + "secretRef": {"key": "token", "name": "upbound-token", "namespace": "default"}, + "source": "Secret", + }, + "organization": "upbound", + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Team", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "envTeam"}, + "generateName": "example-", + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example-team", + "namespace": "default", + }, + "spec": { + "forProvider": {"name": "solutions-non-prod-default-example", "organizationName": "upbound"}, + "managementPolicies": ["*"], + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Token", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "envRobotToken"}, + "generateName": "example-", + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example-robot-token", + "namespace": "default", + }, + "spec": { + "forProvider": { + "name": "solutions-non-prod-default-example", + "owner": { + "idRef": {"name": "solutions-non-prod-default-example-robot", "namespace": "default"}, + "type": "robots", + }, + }, + "managementPolicies": ["*"], + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + "writeConnectionSecretToRef": {"name": "solutions-non-prod-default-example-robot-token"}, + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "Robot", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "envRobot"}, + "generateName": "example-", + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example-robot", + "namespace": "default", + }, + "spec": { + "forProvider": { + "description": "Robot for solutions-non-prod-default-example", + "name": "solutions-non-prod-default-example-bot", + "owner": {"name": "upbound", "namespace": "default"}, + }, + "managementPolicies": ["*"], + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + { + "apiVersion": "iam.m.upbound.io/v1alpha1", + "kind": "RobotTeamMembership", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "envRobotTeamMembership"}, + "generateName": "example-", + "labels": {"crossplane.io/composite": "example"}, + "name": "solutions-non-prod-default-example-robot-team-membership", + "namespace": "default", + }, + "spec": { + "forProvider": { + "robotIdRef": {"name": "solutions-non-prod-default-example-robot", "namespace": "default"}, + "teamIdRef": {"name": "solutions-non-prod-default-example-team", "namespace": "default"}, + }, + "managementPolicies": ["*"], + "providerConfigRef": AWS_PROVIDER_CONFIG_REF, + }, + }, + kubernetes_object( + "solutions-non-prod-default-example-rt-secret", + { + "forProvider": { + "manifest": {"apiVersion": "v1", "kind": "Secret", "metadata": {"namespace": "default"}}, + }, + "managementPolicies": ["*"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "example-ctp"}, + "references": [ + { + "patchesFrom": { + "apiVersion": "v1", + "fieldPath": "data.token", + "kind": "Secret", + "name": "solutions-non-prod-default-example-robot-token", + }, + "toFieldPath": "data.token", + }, + ], + "watch": False, + }, + metadata={ + "annotations": {"crossplane.io/composition-resource-name": "robotTokenEnvCtpSecret"}, + "generateName": "example-", + "labels": {"crossplane.io/composite": "example"}, + "namespace": "default", + }, + ), + ], + timeoutSeconds=60, + validate=False, + ), +) + +# secretsManagerSecret settings have to survive the hop into the nested SharedAWSSecret. +# recoveryWindowInDays is the one that bites: 0 is a meaningful value and a falsy one, so a +# truthy pass-through test drops it silently and teardown goes back to scheduling the secret +# for 30 days - which blocks the next run under the same name. +test_recovery_window = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-environment-secretsmanager-recovery-window"), + spec=compositiontest.Spec( + assertResources=[ + { + "apiVersion": "sa.upbound.io/v1", + "kind": "SharedAWSSecret", + "metadata": {"name": "example-shared-secret"}, + # `create: True` is the SharedAWSSecret schema default. + "spec": {"parameters": {"aws": {"secretsManagerSecret": {"create": True, "recoveryWindowInDays": 0}}}}, + }, + ], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr={ + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "example", "namespace": "default"}, + "spec": { + "parameters": { + "deletionPolicy": "Delete", + "aws": { + "accountId": "12345678912", + "region": "us-east-1", + "credsSecretRef": {"name": "aws-creds-example", "namespace": "default"}, + "sharedSecret": { + # `create: True` is the Environment schema default. + "secretsManagerSecret": {"create": True, "recoveryWindowInDays": 0}, + }, + }, + "upbound": { + # Environment schema defaults. + "createArgoSecret": True, + "createCtp": True, + "createGroup": True, + "initProviderConfigName": "bootstrap-ctp", + "initKubeconfigSecretRef": {"name": "init-kubeconfig", "namespace": "default", "key": "kubeconfig"}, + "tokenSecretRef": {"name": "upbound-token", "namespace": "default", "key": "token"}, + }, + }, + }, + "status": { + "upbound": { + "bootstrapCtp": "bootstrap", + "bootstrapGroup": "solutions-non-prod", + "org": "upbound", + "spaceHost": "upbound-aws-us-east-1.space.mxe.upbound.io", + }, + }, + }, + timeoutSeconds=60, + validate=False, + ), +) + +tests = [test_environment, test_recovery_window] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-sharedawssecret-with-data/README.md b/tests/test-sharedawssecret-with-data/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-sharedawssecret-with-data/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-sharedawssecret-with-data/kcl.mod b/tests/test-sharedawssecret-with-data/kcl.mod deleted file mode 100644 index 5201d4c..0000000 --- a/tests/test-sharedawssecret-with-data/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-sharedawssecret-with-data" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-sharedawssecret-with-data/kcl.mod.lock b/tests/test-sharedawssecret-with-data/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-sharedawssecret-with-data/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-sharedawssecret-with-data/main.k b/tests/test-sharedawssecret-with-data/main.k deleted file mode 100644 index 387c413..0000000 --- a/tests/test-sharedawssecret-with-data/main.k +++ /dev/null @@ -1,308 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.awsm.secretsmanager.v1beta1 as secretsmanagerv1beta1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import json - - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-with-data" - spec= { - assertResources: [ - # Common resources (IAM user, policy, access key, secret copy) - kubernetesm.Object{ - metadata = { - name = "example-env-secrets-read-access-key" - } - spec = { - forProvider = { - manifest = { - apiVersion = "v1" - kind = "Secret" - metadata = { - name = "example-env-secrets-read-access-key" - namespace = "example-env" - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - fieldPath = "data" - kind = "Secret" - name = "example-env-secrets-read-access-key" - namespace = "default" - } - toFieldPath = "data" - } - ] - watch = False - } - } - # IAM resources - iamv1beta1.Policy{ - metadata = { - name = "example-shared-secret-with-data-example-config-secrets-read" - } - spec = { - forProvider = { - policy = json.encode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - Resource = [ - "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-config-*" - ] - } - ] - }) - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.UserPolicyAttachment{ - metadata = { - name = "example-shared-secret-with-data-example-config-secrets-read" - } - spec = { - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.AccessKey{ - metadata = { - name = "example-shared-secret-with-data-example-config-secrets-read" - } - spec = { - forProvider = { - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - writeConnectionSecretToRef = { - name = "example-env-secrets-read-access-key" - } - } - } - iamv1beta1.User{ - metadata = { - name = "example-shared-secret-with-data-example-config-secrets-read" - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - # Secrets Manager Secret - secretsmanagerv1beta1.Secret{ - metadata = { - name = "example-config-secretsmanager-secret" - annotations = { - "crossplane.io/external-name" = "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-config-AbCdEf" - } - } - spec = { - forProvider = { - name = "example-config" - region = "us-east-1" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - # Shared Secret Store - kubernetesm.Object{ - metadata = { - name = "example-ctp-sss" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedSecretStore" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - namespaceSelector = { - names = ["my-namespace"] - } - provider = { - aws = { - auth = { - secretRef = { - accessKeyIDSecretRef = { - key = "username" - name = "example-env-secrets-read-access-key" - } - secretAccessKeySecretRef = { - key = "password" - name = "example-env-secrets-read-access-key" - } - } - } - region = "us-east-1" - service = "SecretsManager" - } - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } - } - # Shared External Secret with data (individual key mappings) - # This is the key test: verifies that spec.data creates individual key mappings - kubernetesm.Object{ - metadata = { - name = "example-ctp-ses" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedExternalSecret" - metadata = { - name = "custom-external-secret" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - externalSecretSpec = { - # This is the key assertion: data should be used instead of dataFrom - data = [ - { - secretKey = "githubAppPrivateKey" - remoteRef = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "example-config" - metadataPolicy = "None" - property = "githubAppPrivateKey" - } - } - { - secretKey = "githubCreds" - remoteRef = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "example-config" - metadataPolicy = "None" - property = "githubCreds" - } - } - { - secretKey = "databaseUrl" - remoteRef = { - conversionStrategy = "Default" - decodingStrategy = "Base64" - key = "example-config" - metadataPolicy = "None" - property = "databaseUrl" - } - } - ] - refreshInterval = "1m" - secretStoreRef = { - kind = "ClusterSecretStore" - name = "example-ctp" - } - target = { - creationPolicy = "Owner" - deletionPolicy = "Retain" - name = "custom-external-secret" - template = { - data = { - githubAppID = "{{ $creds := .githubCreds | fromJson }}{{ index $creds.app_auth 0 \"id\" }}" - githubInstallationID = "{{ $creds := .githubCreds | fromJson }}{{ index $creds.app_auth 0 \"installation_id\" }}" - githubPrivateKey = "{{ $creds := .githubCreds | fromJson }}{{ index $creds.app_auth 0 \"pem_file\" | replace \"\\\\n\" \"\\n\" }}" - type = "git" - url = "{{ $creds := .githubCreds | fromJson }}https://github.com/{{ $creds.owner }}" - } - engineVersion = "v2" - mergePolicy = "Replace" - metadata = { - labels = { - app = "my-app" - "argocd.argoproj.io/secret-type" = "repo-creds" - environment = "production" - } - } - } - } - } - namespaceSelector = { - names = ["my-namespace"] - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } - } - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xrPath: "examples/sharedawssecret/example-with-data.yaml" - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } -] -items= _items diff --git a/tests/test-sharedawssecret-with-data/model b/tests/test-sharedawssecret-with-data/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-sharedawssecret-with-data/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-sharedawssecret-with-data/pyproject.toml b/tests/test-sharedawssecret-with-data/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-sharedawssecret-with-data/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-sharedawssecret-with-data/test/__init__.py b/tests/test-sharedawssecret-with-data/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-sharedawssecret-with-data/test/__main__.py b/tests/test-sharedawssecret-with-data/test/__main__.py new file mode 100644 index 0000000..25c805a --- /dev/null +++ b/tests/test-sharedawssecret-with-data/test/__main__.py @@ -0,0 +1,216 @@ +"""SharedAWSSecret with spec.data: individual key mappings instead of extracting the whole secret. + +Renders examples/sharedawssecret/example-with-data.yaml and asserts the full set of composed +resources. +""" + +import json + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +NAME = "example-shared-secret-with-data-example-config-secrets-read" +SECRET = "example-config" +ACCESS_KEY_SECRET = "example-env-secrets-read-access-key" +AWS_PROVIDER_CONFIG = {"kind": "ProviderConfig", "name": "example-env"} +GROUP_PROVIDER_CONFIG = {"kind": "ProviderConfig", "name": "example-env-group"} +CREDS = "{{ $creds := .githubCreds | fromJson }}" + + +def kubernetes_object(name: str, manifest: dict, **spec) -> dict: + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": name}, + "spec": { + # deletionPropagationPolicy is the schema default the KCL model materialised. + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": manifest}, + "managementPolicies": ORPHAN, + "providerConfigRef": GROUP_PROVIDER_CONFIG, + **spec, + "watch": False, + }, + } + + +def iam_resource(kind: str, for_provider: dict, **spec) -> dict: + return { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": kind, + "metadata": {"name": NAME}, + "spec": { + "forProvider": for_provider, + "managementPolicies": ORPHAN, + "providerConfigRef": AWS_PROVIDER_CONFIG, + **spec, + }, + } + + +def data_mapping(key: str, decoding_strategy: str = "None") -> dict: + return { + "secretKey": key, + "remoteRef": { + "conversionStrategy": "Default", + "decodingStrategy": decoding_strategy, + "key": SECRET, + "metadataPolicy": "None", + "property": key, + }, + } + + +POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecretVersionIds", + ], + "Resource": [f"arn:aws:secretsmanager:us-east-1:123456789012:secret:{SECRET}-*"], + } + ], +} + +test = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-sharedawssecret-with-data"), + spec=compositiontest.Spec( + assertResources=[ + # Common resources (IAM user, policy, access key, secret copy) + kubernetes_object( + ACCESS_KEY_SECRET, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": ACCESS_KEY_SECRET, "namespace": "example-env"}, + }, + references=[ + { + "patchesFrom": { + "apiVersion": "v1", + "fieldPath": "data", + "kind": "Secret", + "name": ACCESS_KEY_SECRET, + "namespace": "default", + }, + "toFieldPath": "data", + } + ], + ), + # IAM resources + iam_resource("Policy", {"policy": json.dumps(POLICY)}), + iam_resource( + "UserPolicyAttachment", + {"policyArnSelector": {"matchControllerRef": True}, "userSelector": {"matchControllerRef": True}}, + ), + iam_resource( + "AccessKey", + {"userSelector": {"matchControllerRef": True}}, + writeConnectionSecretToRef={"name": ACCESS_KEY_SECRET}, + ), + iam_resource("User", {}), + # Secrets Manager Secret + { + "apiVersion": "secretsmanager.aws.m.upbound.io/v1beta1", + "kind": "Secret", + "metadata": { + "name": f"{SECRET}-secretsmanager-secret", + "annotations": { + "crossplane.io/external-name": f"arn:aws:secretsmanager:us-east-1:123456789012:secret:{SECRET}-AbCdEf" + }, + }, + "spec": { + "forProvider": {"name": SECRET, "region": "us-east-1"}, + "managementPolicies": ORPHAN, + "providerConfigRef": AWS_PROVIDER_CONFIG, + }, + }, + # Shared Secret Store + kubernetes_object( + "example-ctp-sss", + { + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedSecretStore", + "metadata": {"name": "example-ctp", "namespace": "example-env"}, + "spec": { + "controlPlaneSelector": {"names": ["example-ctp"]}, + "namespaceSelector": {"names": ["my-namespace"]}, + "provider": { + "aws": { + "auth": { + "secretRef": { + "accessKeyIDSecretRef": {"key": "username", "name": ACCESS_KEY_SECRET}, + "secretAccessKeySecretRef": {"key": "password", "name": ACCESS_KEY_SECRET}, + } + }, + "region": "us-east-1", + "service": "SecretsManager", + } + }, + }, + }, + ), + # Shared External Secret with data (individual key mappings) + # This is the key test: verifies that spec.data creates individual key mappings + kubernetes_object( + "example-ctp-ses", + { + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedExternalSecret", + "metadata": {"name": "custom-external-secret", "namespace": "example-env"}, + "spec": { + "controlPlaneSelector": {"names": ["example-ctp"]}, + "externalSecretSpec": { + # This is the key assertion: data should be used instead of dataFrom + "data": [ + data_mapping("githubAppPrivateKey"), + data_mapping("githubCreds"), + data_mapping("databaseUrl", decoding_strategy="Base64"), + ], + "refreshInterval": "1m", + "secretStoreRef": {"kind": "ClusterSecretStore", "name": "example-ctp"}, + "target": { + "creationPolicy": "Owner", + "deletionPolicy": "Retain", + "name": "custom-external-secret", + "template": { + "data": { + "githubAppID": CREDS + '{{ index $creds.app_auth 0 "id" }}', + "githubInstallationID": CREDS + '{{ index $creds.app_auth 0 "installation_id" }}', + "githubPrivateKey": CREDS + + '{{ index $creds.app_auth 0 "pem_file" | replace "\\\\n" "\\n" }}', + "type": "git", + "url": CREDS + "https://github.com/{{ $creds.owner }}", + }, + "engineVersion": "v2", + "mergePolicy": "Replace", + "metadata": { + "labels": { + "app": "my-app", + "argocd.argoproj.io/secret-type": "repo-creds", + "environment": "production", + } + }, + }, + }, + }, + "namespaceSelector": {"names": ["my-namespace"]}, + }, + }, + ), + ], + compositionPath="apis/sharedawssecrets/composition.yaml", + xrPath="examples/sharedawssecret/example-with-data.yaml", + xrdPath="apis/sharedawssecrets/definition.yaml", + timeoutSeconds=60, + validate=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) diff --git a/tests/test-sharedawssecret/README.md b/tests/test-sharedawssecret/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-sharedawssecret/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-sharedawssecret/kcl.mod b/tests/test-sharedawssecret/kcl.mod deleted file mode 100644 index 12c783e..0000000 --- a/tests/test-sharedawssecret/kcl.mod +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "test-sharedawssecret" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } -kube = { oci = "oci://xpkg.upbound.io/upbound/kcl-modules_kube", tag = "1.32", package = "kcl-modules_kube", version = "1.32" } diff --git a/tests/test-sharedawssecret/kcl.mod.lock b/tests/test-sharedawssecret/kcl.mod.lock deleted file mode 100644 index 7c8b363..0000000 --- a/tests/test-sharedawssecret/kcl.mod.lock +++ /dev/null @@ -1,12 +0,0 @@ -[dependencies] - [dependencies.kube] - name = "kube" - full_name = "kube_1.31.2" - version = "1.31.2" - reg = "xpkg.upbound.io" - repo = "upbound/kcl-modules_kube" - oci_tag = "1.31.2" - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-sharedawssecret/main.k b/tests/test-sharedawssecret/main.k deleted file mode 100644 index 732a30a..0000000 --- a/tests/test-sharedawssecret/main.k +++ /dev/null @@ -1,1012 +0,0 @@ -import models.io.crossplane.kubernetesm.v1alpha1 as kubernetesm -import models.io.upbound.awsm.iam.v1beta1 as iamv1beta1 -import models.io.upbound.awsm.secretsmanager.v1beta1 as secretsmanagerv1beta1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 -import kube.api.core.v1 as v1 -import json - -# Common resources that are the same across all test scenarios (only the secret copy) -_commonResources = [ - kubernetesm.Object{ - metadata = { - name = "example-env-secrets-read-access-key" - } - spec = { - forProvider = { - manifest = v1.Secret{ - metadata = { - name = "example-env-secrets-read-access-key" - namespace = "example-env" - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - references = [ - { - patchesFrom = { - apiVersion = "v1" - fieldPath = "data" - kind = "Secret" - name = "example-env-secrets-read-access-key" - namespace = "default" - } - toFieldPath = "data" - } - ] - watch = False - } - } -] - -# Static IAM resources for different test cases -_iamResourcesDefault = [ - iamv1beta1.Policy{ - metadata = { - name = "example-shared-secret-example-env-config-secrets-read" - } - spec = { - forProvider = { - policy = json.encode({ - "Version": "2012-10-17" - "Statement": [ - { - "Effect": "Allow" - "Action": [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - "Resource": [ - "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-env-config-*" - ] - } - ] - }) - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.UserPolicyAttachment{ - metadata = { - name = "example-shared-secret-example-env-config-secrets-read" - } - spec = { - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.AccessKey{ - metadata = { - name = "example-shared-secret-example-env-config-secrets-read" - } - spec = { - forProvider = { - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - writeConnectionSecretToRef = { - name = "example-env-secrets-read-access-key" - } - } - } - iamv1beta1.User{ - metadata = { - name = "example-shared-secret-example-env-config-secrets-read" - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } -] - -_iamResourcesOverride = [ - iamv1beta1.Policy{ - metadata = { - name = "example-shared-secret-example-config-secrets-read" - } - spec = { - forProvider = { - policy = json.encode({ - "Version": "2012-10-17" - "Statement": [ - { - "Effect": "Allow" - "Action": [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - "Resource": [ - "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-config-*" - ] - } - ] - }) - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.UserPolicyAttachment{ - metadata = { - name = "example-shared-secret-example-config-secrets-read" - } - spec = { - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.AccessKey{ - metadata = { - name = "example-shared-secret-example-config-secrets-read" - } - spec = { - forProvider = { - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - writeConnectionSecretToRef = { - name = "example-env-secrets-read-access-key" - } - } - } - iamv1beta1.User{ - metadata = { - name = "example-shared-secret-example-config-secrets-read" - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } -] - -_iamResourcesNoCreate = [ - iamv1beta1.Policy{ - metadata = { - name = "example-shared-secret-existing-secret-secrets-read" - } - spec = { - forProvider = { - policy = json.encode({ - "Version": "2012-10-17" - "Statement": [ - { - "Effect": "Allow" - "Action": [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - "Resource": [ - "arn:aws:secretsmanager:us-east-1:123456789012:secret:existing-secret-*" - ] - } - ] - }) - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.UserPolicyAttachment{ - metadata = { - name = "example-shared-secret-existing-secret-secrets-read" - } - spec = { - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.AccessKey{ - metadata = { - name = "example-shared-secret-existing-secret-secrets-read" - } - spec = { - forProvider = { - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - writeConnectionSecretToRef = { - name = "example-env-secrets-read-access-key" - } - } - } - iamv1beta1.User{ - metadata = { - name = "example-shared-secret-existing-secret-secrets-read" - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } -] - -# Static shared resources -_sharedSecretStoreDefault = kubernetesm.Object{ - metadata = { - name = "example-ctp-sss" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedSecretStore" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - namespaceSelector = { - names = ["default"] - } - provider = { - aws = { - auth = { - secretRef = { - accessKeyIDSecretRef = { - key = "username" - name = "example-env-secrets-read-access-key" - } - secretAccessKeySecretRef = { - key = "password" - name = "example-env-secrets-read-access-key" - } - } - } - region = "us-east-1" - service = "SecretsManager" - } - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -_sharedSecretStoreNamespaceOverride = kubernetesm.Object{ - metadata = { - name = "example-ctp-sss" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedSecretStore" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - namespaceSelector = { - names = ["my-namespace"] - } - provider = { - aws = { - auth = { - secretRef = { - accessKeyIDSecretRef = { - key = "username" - name = "example-env-secrets-read-access-key" - } - secretAccessKeySecretRef = { - key = "password" - name = "example-env-secrets-read-access-key" - } - } - } - region = "us-east-1" - service = "SecretsManager" - } - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -_sharedExternalSecretDefault = kubernetesm.Object{ - metadata = { - name = "example-ctp-ses" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedExternalSecret" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - externalSecretSpec = { - dataFrom = [ - { - extract = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "example-env-config" - metadataPolicy = "None" - } - } - ] - refreshInterval = "1m" - secretStoreRef = { - kind = "ClusterSecretStore" - name = "example-ctp" - } - target = { - creationPolicy = "Owner" - deletionPolicy = "Retain" - name = "example-ctp" - } - } - namespaceSelector = { - names = ["default"] - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -_sharedExternalSecretOverride = kubernetesm.Object{ - metadata = { - name = "example-ctp-ses" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedExternalSecret" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - externalSecretSpec = { - dataFrom = [ - { - extract = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "example-config" - metadataPolicy = "None" - } - } - ] - refreshInterval = "1m" - secretStoreRef = { - kind = "ClusterSecretStore" - name = "example-ctp" - } - target = { - creationPolicy = "Owner" - deletionPolicy = "Retain" - name = "example-ctp" - template = { - metadata = { - labels = { - app = "my-app" - environment = "production" - } - } - } - } - } - namespaceSelector = { - names = ["my-namespace"] - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -_sharedExternalSecretNoCreate = kubernetesm.Object{ - metadata = { - name = "example-ctp-ses" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedExternalSecret" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - externalSecretSpec = { - dataFrom = [ - { - extract = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "existing-secret" - metadataPolicy = "None" - } - } - ] - refreshInterval = "1m" - secretStoreRef = { - kind = "ClusterSecretStore" - name = "example-ctp" - } - target = { - creationPolicy = "Owner" - deletionPolicy = "Retain" - name = "example-ctp" - } - } - namespaceSelector = { - names = ["default"] - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -# Static SecretsManager Secret resources for each test case -_secretsManagerSecretDefault = secretsmanagerv1beta1.Secret{ - metadata = { - name = "example-env-config-secretsmanager-secret" - annotations = { - "crossplane.io/external-name" = "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-env-config-AbCdEf" - } - } - spec = { - forProvider = { - name = "example-env-config" - region = "us-east-1" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } -} - -_secretsManagerSecretOverride = secretsmanagerv1beta1.Secret{ - metadata = { - name = "example-config-secretsmanager-secret" - annotations = { - "crossplane.io/external-name" = "arn:aws:secretsmanager:us-east-1:123456789012:secret:example-config-AbCdEf" - } - } - spec = { - forProvider = { - name = "example-config" - region = "us-east-1" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } -} - -# Long name resources with hashing -_iamResourcesLongName = [ - iamv1beta1.Policy{ - metadata = { - name = "example-shared-secret-this-is-a-very-long-1046060-secrets-read" - } - spec = { - forProvider = { - policy = json.encode({ - "Version": "2012-10-17" - "Statement": [ - { - "Effect": "Allow" - "Action": [ - "secretsmanager:GetSecretValue" - "secretsmanager:DescribeSecret" - "secretsmanager:ListSecretVersionIds" - ] - "Resource": [ - "arn:aws:secretsmanager:us-east-1:123456789012:secret:this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation-*" - ] - } - ] - }) - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.UserPolicyAttachment{ - metadata = { - name = "example-shared-secret-this-is-a-very-long-1046060-secrets-read" - } - spec = { - forProvider = { - policyArnSelector = { - matchControllerRef = True - } - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - iamv1beta1.AccessKey{ - metadata = { - name = "example-shared-secret-this-is-a-very-long-1046060-secrets-read" - } - spec = { - forProvider = { - userSelector = { - matchControllerRef = True - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - writeConnectionSecretToRef = { - name = "example-env-secrets-read-access-key" - } - } - } - iamv1beta1.User{ - metadata = { - name = "example-shared-secret-this-is-a-very-long-1046060-secrets-read" - } - spec = { - forProvider = {} - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } -] - -_secretsManagerSecretLongName = secretsmanagerv1beta1.Secret{ - metadata = { - name = "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation-secretsmanager-secret" - annotations = { - "crossplane.io/composition-resource-name" = "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation-secretsmanager-secret" - } - } - spec = { - forProvider = { - name = "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation" - region = "us-east-1" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } -} - -_sharedExternalSecretLongName = kubernetesm.Object{ - metadata = { - name = "example-ctp-ses" - } - spec = { - forProvider = { - manifest = { - apiVersion = "spaces.upbound.io/v1alpha1" - kind = "SharedExternalSecret" - metadata = { - name = "example-ctp" - namespace = "example-env" - } - spec = { - controlPlaneSelector = { - names = ["example-ctp"] - } - externalSecretSpec = { - dataFrom = [ - { - extract = { - conversionStrategy = "Default" - decodingStrategy = "None" - key = "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation" - metadataPolicy = "None" - } - } - ] - refreshInterval = "1m" - secretStoreRef = { - kind = "ClusterSecretStore" - name = "example-ctp" - } - target = { - creationPolicy = "Owner" - deletionPolicy = "Retain" - name = "example-ctp" - } - } - namespaceSelector = { - names = ["default"] - } - } - } - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env-group" - } - watch = False - } -} - -_items = [ - # Test case 1: Default behavior using namePrefix logic - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret" - spec= { - assertResources: _commonResources + _iamResourcesDefault + [ - _secretsManagerSecretDefault - _sharedSecretStoreDefault - _sharedExternalSecretDefault - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xrPath: "examples/sharedawssecret/example-default.yaml" - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } - # Test case 2: With explicit secretsManagerSecret.name override - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-name-override" - spec= { - assertResources: _commonResources + _iamResourcesOverride + [ - _secretsManagerSecretOverride - _sharedSecretStoreNamespaceOverride - _sharedExternalSecretOverride - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xrPath: "examples/sharedawssecret/example.yaml" - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } - # Test case 3: With create set to false (no secret creation, but IAM resources still created) - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-no-create" - spec= { - assertResources: _commonResources + _iamResourcesNoCreate + [ - # No secretsManagerSecret in this case since create=false - _sharedSecretStoreDefault - _sharedExternalSecretNoCreate - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xr: sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - namespace = "default" - } - spec = { - parameters = { - deletionPolicy = "Orphan" - aws = { - accountId = "123456789012" - region = "us-east-1" - secretsManagerSecret = { - name = "existing-secret" - create = False - } - namePrefix = "example-env" - providerConfigRef = { - name = "example-env" - } - } - upbound = { - group = "example-env" - controlPlane = "example-ctp" - providerConfigRef = { - name = "example-env-group" - } - } - } - } - } - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } - # Test case 3b: recoveryWindowInDays reaches the managed resource. - # - # AWS does not delete a Secrets Manager secret outright - it schedules it, and for the - # length of the recovery window (30 days by default) the name stays taken. Recreating a - # secret with that name fails with "already scheduled for deletion", so any environment - # that is torn down and stood back up under the same name is blocked until the window - # expires. Setting 0 deletes immediately with no recovery. - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-recovery-window" - spec = { - assertResources = [ - secretsmanagerv1beta1.Secret{ - metadata.name = "example-env-config-secretsmanager-secret" - spec.forProvider = { - name = "example-env-config" - region = "us-east-1" - recoveryWindowInDays = 0 - } - } - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xr: sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - namespace = "default" - } - spec.parameters = { - deletionPolicy = "Delete" - aws = { - accountId = "123456789012" - region = "us-east-1" - namePrefix = "example-env" - secretsManagerSecret = { - recoveryWindowInDays = 0 - } - providerConfigRef = { - name = "example-env" - } - } - upbound = { - group = "example-env" - controlPlane = "example-ctp" - providerConfigRef = { - name = "example-env-group" - } - } - } - } - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } - # Test case 4: Long name that triggers hash truncation - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-long-name" - spec= { - assertResources: _commonResources + _iamResourcesLongName + [ - _secretsManagerSecretLongName - _sharedSecretStoreDefault - _sharedExternalSecretLongName - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xr: sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - namespace = "default" - } - spec = { - parameters = { - deletionPolicy = "Orphan" - aws = { - accountId = "123456789012" - region = "us-east-1" - secretsManagerSecret = { - name = "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit-for-iam-resources-and-should-trigger-hash-truncation" - create = True - } - namePrefix = "example-env" - providerConfigRef = { - name = "example-env" - } - } - upbound = { - group = "example-env" - controlPlane = "example-ctp" - providerConfigRef = { - name = "example-env-group" - } - } - } - } - } - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } - # Regression guard: secretsManagerSecret is an optional block, and Environment omits it - # whenever sharedSecret is set without one. Reading .create directly off the absent block - # aborted the whole pipeline on a live control plane while every other case here passed, - # because they all happen to set it. - metav1alpha1.CompositionTest{ - metadata.name: "test-sharedawssecret-no-secretsmanager-block" - spec= { - assertResources: [ - secretsmanagerv1beta1.Secret{ - metadata.name = "example-env-config-secretsmanager-secret" - spec = { - forProvider = { - # create defaults to true when the block is absent - name = "example-env-config" - region = "us-east-1" - } - managementPolicies = ["Create", "Observe", "Update", "LateInitialize"] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-env" - } - } - } - ] - compositionPath: "apis/sharedawssecrets/composition.yaml" - xr: sav1.SharedAWSSecret{ - metadata = { - name = "example-shared-secret" - namespace = "default" - } - spec = { - parameters = { - deletionPolicy = "Orphan" - aws = { - accountId = "123456789012" - region = "us-east-1" - # secretsManagerSecret deliberately omitted - namePrefix = "example-env" - providerConfigRef = { - name = "example-env" - } - } - upbound = { - group = "example-env" - controlPlane = "example-ctp" - providerConfigRef = { - name = "example-env-group" - } - } - } - } - } - xrdPath: "apis/sharedawssecrets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } -] -items= _items diff --git a/tests/test-sharedawssecret/model b/tests/test-sharedawssecret/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-sharedawssecret/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-sharedawssecret/pyproject.toml b/tests/test-sharedawssecret/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-sharedawssecret/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-sharedawssecret/test/__init__.py b/tests/test-sharedawssecret/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-sharedawssecret/test/__main__.py b/tests/test-sharedawssecret/test/__main__.py new file mode 100644 index 0000000..b4f43f9 --- /dev/null +++ b/tests/test-sharedawssecret/test/__main__.py @@ -0,0 +1,345 @@ +"""SharedAWSSecret: the IAM user that reads a Secrets Manager secret, and its Spaces fan-out. + +Six scenarios: the default namePrefix naming, an explicit secretsManagerSecret.name override, +create=false, recoveryWindowInDays, a name long enough to trigger hash truncation, and an XR +that omits the secretsManagerSecret block entirely. +""" + +import json + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +ACCESS_KEY_SECRET = "example-env-secrets-read-access-key" +AWS_PROVIDER_CONFIG = {"kind": "ProviderConfig", "name": "example-env"} +GROUP_PROVIDER_CONFIG = {"kind": "ProviderConfig", "name": "example-env-group"} +LONG_SECRET_NAME = ( + "this-is-a-very-long-secret-name-that-will-exceed-the-64-character-limit" + "-for-iam-resources-and-should-trigger-hash-truncation" +) + + +def kubernetes_object(name: str, manifest: dict, **spec) -> dict: + return { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": name}, + "spec": { + # deletionPropagationPolicy is the schema default the KCL model materialised. + "forProvider": {"deletionPropagationPolicy": "Background", "manifest": manifest}, + "managementPolicies": ORPHAN, + "providerConfigRef": GROUP_PROVIDER_CONFIG, + **spec, + "watch": False, + }, + } + + +# Common resources that are the same across all test scenarios (only the secret copy) +COMMON_RESOURCES = [ + kubernetes_object( + ACCESS_KEY_SECRET, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": ACCESS_KEY_SECRET, "namespace": "example-env"}, + }, + references=[ + { + "patchesFrom": { + "apiVersion": "v1", + "fieldPath": "data", + "kind": "Secret", + "name": ACCESS_KEY_SECRET, + "namespace": "default", + }, + "toFieldPath": "data", + } + ], + ) +] + + +def iam_resource(kind: str, name: str, for_provider: dict, **spec) -> dict: + return { + "apiVersion": "iam.aws.m.upbound.io/v1beta1", + "kind": kind, + "metadata": {"name": name}, + "spec": { + "forProvider": for_provider, + "managementPolicies": ORPHAN, + "providerConfigRef": AWS_PROVIDER_CONFIG, + **spec, + }, + } + + +def iam_resources(name: str, secret: str) -> list[dict]: + """The Policy, UserPolicyAttachment, AccessKey and User that grant read access to secret.""" + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecretVersionIds", + ], + "Resource": [f"arn:aws:secretsmanager:us-east-1:123456789012:secret:{secret}-*"], + } + ], + } + return [ + iam_resource("Policy", name, {"policy": json.dumps(policy)}), + iam_resource( + "UserPolicyAttachment", + name, + {"policyArnSelector": {"matchControllerRef": True}, "userSelector": {"matchControllerRef": True}}, + ), + iam_resource( + "AccessKey", + name, + {"userSelector": {"matchControllerRef": True}}, + writeConnectionSecretToRef={"name": ACCESS_KEY_SECRET}, + ), + iam_resource("User", name, {}), + ] + + +# Static IAM resources for different test cases +IAM_RESOURCES_DEFAULT = iam_resources("example-shared-secret-example-env-config-secrets-read", "example-env-config") +IAM_RESOURCES_OVERRIDE = iam_resources("example-shared-secret-example-config-secrets-read", "example-config") +IAM_RESOURCES_NO_CREATE = iam_resources("example-shared-secret-existing-secret-secrets-read", "existing-secret") + + +# Static shared resources +def shared_secret_store(namespace: str) -> dict: + return kubernetes_object( + "example-ctp-sss", + { + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedSecretStore", + "metadata": {"name": "example-ctp", "namespace": "example-env"}, + "spec": { + "controlPlaneSelector": {"names": ["example-ctp"]}, + "namespaceSelector": {"names": [namespace]}, + "provider": { + "aws": { + "auth": { + "secretRef": { + "accessKeyIDSecretRef": {"key": "username", "name": ACCESS_KEY_SECRET}, + "secretAccessKeySecretRef": {"key": "password", "name": ACCESS_KEY_SECRET}, + } + }, + "region": "us-east-1", + "service": "SecretsManager", + } + }, + }, + }, + ) + + +SHARED_SECRET_STORE_DEFAULT = shared_secret_store("default") +SHARED_SECRET_STORE_NAMESPACE_OVERRIDE = shared_secret_store("my-namespace") + + +def shared_external_secret(key: str, namespace: str = "default", template: dict | None = None) -> dict: + target = {"creationPolicy": "Owner", "deletionPolicy": "Retain", "name": "example-ctp"} + if template is not None: + target["template"] = template + return kubernetes_object( + "example-ctp-ses", + { + "apiVersion": "spaces.upbound.io/v1alpha1", + "kind": "SharedExternalSecret", + "metadata": {"name": "example-ctp", "namespace": "example-env"}, + "spec": { + "controlPlaneSelector": {"names": ["example-ctp"]}, + "externalSecretSpec": { + "dataFrom": [ + { + "extract": { + "conversionStrategy": "Default", + "decodingStrategy": "None", + "key": key, + "metadataPolicy": "None", + } + } + ], + "refreshInterval": "1m", + "secretStoreRef": {"kind": "ClusterSecretStore", "name": "example-ctp"}, + "target": target, + }, + "namespaceSelector": {"names": [namespace]}, + }, + }, + ) + + +SHARED_EXTERNAL_SECRET_DEFAULT = shared_external_secret("example-env-config") +SHARED_EXTERNAL_SECRET_OVERRIDE = shared_external_secret( + "example-config", + namespace="my-namespace", + template={"metadata": {"labels": {"app": "my-app", "environment": "production"}}}, +) +SHARED_EXTERNAL_SECRET_NO_CREATE = shared_external_secret("existing-secret") + + +def secrets_manager_secret( + name: str, annotations: dict | None = None, for_provider: dict | None = None, **spec +) -> dict: + metadata = {"name": f"{name}-secretsmanager-secret"} + if annotations is not None: + metadata["annotations"] = annotations + return { + "apiVersion": "secretsmanager.aws.m.upbound.io/v1beta1", + "kind": "Secret", + "metadata": metadata, + "spec": {"forProvider": {"name": name, "region": "us-east-1", **(for_provider or {})}, **spec}, + } + + +def managed_secret(name: str, annotations: dict) -> dict: + return secrets_manager_secret( + name, annotations, managementPolicies=ORPHAN, providerConfigRef=AWS_PROVIDER_CONFIG + ) + + +def external_name(name: str) -> dict: + return {"crossplane.io/external-name": f"arn:aws:secretsmanager:us-east-1:123456789012:secret:{name}-AbCdEf"} + + +# Static SecretsManager Secret resources for each test case +SECRETS_MANAGER_SECRET_DEFAULT = managed_secret("example-env-config", external_name("example-env-config")) +SECRETS_MANAGER_SECRET_OVERRIDE = managed_secret("example-config", external_name("example-config")) + +# Long name resources with hashing +IAM_RESOURCES_LONG_NAME = iam_resources( + "example-shared-secret-this-is-a-very-long-1046060-secrets-read", LONG_SECRET_NAME +) +SECRETS_MANAGER_SECRET_LONG_NAME = managed_secret( + LONG_SECRET_NAME, + {"crossplane.io/composition-resource-name": f"{LONG_SECRET_NAME}-secretsmanager-secret"}, +) +SHARED_EXTERNAL_SECRET_LONG_NAME = shared_external_secret(LONG_SECRET_NAME) + + +def shared_aws_secret(deletion_policy: str, secrets_manager_secret: dict | None = None) -> dict: + aws = { + "accountId": "123456789012", + "region": "us-east-1", + "namePrefix": "example-env", + "providerConfigRef": {"name": "example-env"}, + } + if secrets_manager_secret is not None: + aws["secretsManagerSecret"] = secrets_manager_secret + return { + "apiVersion": "sa.upbound.io/v1", + "kind": "SharedAWSSecret", + "metadata": {"name": "example-shared-secret", "namespace": "default"}, + "spec": { + "parameters": { + "deletionPolicy": deletion_policy, + "aws": aws, + "upbound": { + "group": "example-env", + "controlPlane": "example-ctp", + "providerConfigRef": {"name": "example-env-group"}, + }, + } + }, + } + + +def composition_test(name: str, assert_resources: list[dict], **spec) -> compositiontest.CompositionTest: + return compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name=name), + spec=compositiontest.Spec( + assertResources=assert_resources, + compositionPath="apis/sharedawssecrets/composition.yaml", + xrdPath="apis/sharedawssecrets/definition.yaml", + timeoutSeconds=60, + validate=False, + **spec, + ), + ) + + +tests = [ + # Test case 1: Default behavior using namePrefix logic + composition_test( + "test-sharedawssecret", + COMMON_RESOURCES + + IAM_RESOURCES_DEFAULT + + [SECRETS_MANAGER_SECRET_DEFAULT, SHARED_SECRET_STORE_DEFAULT, SHARED_EXTERNAL_SECRET_DEFAULT], + xrPath="examples/sharedawssecret/example-default.yaml", + ), + # Test case 2: With explicit secretsManagerSecret.name override + composition_test( + "test-sharedawssecret-name-override", + COMMON_RESOURCES + + IAM_RESOURCES_OVERRIDE + + [SECRETS_MANAGER_SECRET_OVERRIDE, SHARED_SECRET_STORE_NAMESPACE_OVERRIDE, SHARED_EXTERNAL_SECRET_OVERRIDE], + xrPath="examples/sharedawssecret/example.yaml", + ), + # Test case 3: With create set to false (no secret creation, but IAM resources still created) + composition_test( + "test-sharedawssecret-no-create", + COMMON_RESOURCES + + IAM_RESOURCES_NO_CREATE + + [ + # No secretsManagerSecret in this case since create=false + SHARED_SECRET_STORE_DEFAULT, + SHARED_EXTERNAL_SECRET_NO_CREATE, + ], + xr=shared_aws_secret("Orphan", {"name": "existing-secret", "create": False}), + ), + # Test case 3b: recoveryWindowInDays reaches the managed resource. + # + # AWS does not delete a Secrets Manager secret outright - it schedules it, and for the + # length of the recovery window (30 days by default) the name stays taken. Recreating a + # secret with that name fails with "already scheduled for deletion", so any environment + # that is torn down and stood back up under the same name is blocked until the window + # expires. Setting 0 deletes immediately with no recovery. + composition_test( + "test-sharedawssecret-recovery-window", + [ + # managementPolicies ["*"] is the schema default the KCL model materialised. + secrets_manager_secret( + "example-env-config", for_provider={"recoveryWindowInDays": 0}, managementPolicies=["*"] + ), + ], + # create: True is the schema default the KCL model materialised. + xr=shared_aws_secret("Delete", {"recoveryWindowInDays": 0, "create": True}), + ), + # Test case 4: Long name that triggers hash truncation + composition_test( + "test-sharedawssecret-long-name", + COMMON_RESOURCES + + IAM_RESOURCES_LONG_NAME + + [SECRETS_MANAGER_SECRET_LONG_NAME, SHARED_SECRET_STORE_DEFAULT, SHARED_EXTERNAL_SECRET_LONG_NAME], + xr=shared_aws_secret("Orphan", {"name": LONG_SECRET_NAME, "create": True}), + ), + # Regression guard: secretsManagerSecret is an optional block, and Environment omits it + # whenever sharedSecret is set without one. Reading .create directly off the absent block + # aborted the whole pipeline on a live control plane while every other case here passed, + # because they all happen to set it. + composition_test( + "test-sharedawssecret-no-secretsmanager-block", + [ + # create defaults to true when the block is absent + secrets_manager_secret( + "example-env-config", managementPolicies=ORPHAN, providerConfigRef=AWS_PROVIDER_CONFIG + ), + ], + # secretsManagerSecret deliberately omitted + xr=shared_aws_secret("Orphan"), + ), +] + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-upboundreposet-repo-config/README.md b/tests/test-upboundreposet-repo-config/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-upboundreposet-repo-config/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-upboundreposet-repo-config/kcl.mod b/tests/test-upboundreposet-repo-config/kcl.mod deleted file mode 100644 index 663965b..0000000 --- a/tests/test-upboundreposet-repo-config/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-upboundreposet-repo-config" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-upboundreposet-repo-config/kcl.mod.lock b/tests/test-upboundreposet-repo-config/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-upboundreposet-repo-config/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-upboundreposet-repo-config/main.k b/tests/test-upboundreposet-repo-config/main.k deleted file mode 100644 index 8dcefd8..0000000 --- a/tests/test-upboundreposet-repo-config/main.k +++ /dev/null @@ -1,160 +0,0 @@ -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 -import models.io.upbound.repositorym.v1alpha1 as repositoryv1alpha1 - - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-upboundreposet-repo-config" - spec= { - assertResources: [ - repositoryv1alpha1.Repository{ - metadata = { - annotations = { - "crosslane.io/external-name" = "test1" - "crossplane.io/composition-resource-name" = "test-org-test1" - } - } - spec = { - forProvider = { - name = "test1" - organizationName = "test-org" - public = True - publish = False - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "test-upboundreposet-repo-config-test-org-reposet" - } - } - } - repositoryv1alpha1.Repository{ - metadata = { - annotations = { - "crosslane.io/external-name" = "test2" - "crossplane.io/composition-resource-name" = "test-org-test2" - } - } - spec = { - forProvider = { - name = "test2" - organizationName = "test-org" - public = True - publish = True - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "test-upboundreposet-repo-config-test-org-reposet" - } - } - } - repositoryv1alpha1.Repository{ - metadata = { - annotations = { - "crosslane.io/external-name" = "test3" - "crossplane.io/composition-resource-name" = "test-org-test3" - } - } - spec = { - forProvider = { - name = "test3" - organizationName = "test-org" - public = False - publish = True - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "test-upboundreposet-repo-config-test-org-reposet" - } - } - } - repositoryv1alpha1.Repository{ - metadata = { - annotations = { - "crosslane.io/external-name" = "test4" - "crossplane.io/composition-resource-name" = "test-org-test4" - } - } - spec = { - forProvider = { - name = "test4" - organizationName = "test-org" - public = False - publish = False - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "test-upboundreposet-repo-config-test-org-reposet" - } - } - } - ] - compositionPath: "apis/upboundreposets/composition.yaml" - xr: sav1.UpboundRepoSet{ - metadata.name = "test-upboundreposet-repo-config" - metadata.namespace = "default" - spec.parameters: { - organization: "test-org" - settings: { - public: False - publish: False - } - permissions: { - teams: { - "test-team": { - permission: "write" - } - } - } - repositories: { - test1: { - public: True - publish: False - } - test2: { - public: True - publish: True - } - test3: { - public: False - publish: True - } - test4: {} - } - tokenSecretRef: { - key: "creds" - name: "my-secret" - } - } - } - xrdPath: "apis/upboundreposets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } -] -items= _items diff --git a/tests/test-upboundreposet-repo-config/model b/tests/test-upboundreposet-repo-config/model deleted file mode 120000 index faff6e4..0000000 --- a/tests/test-upboundreposet-repo-config/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-upboundreposet-repo-config/pyproject.toml b/tests/test-upboundreposet-repo-config/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-upboundreposet-repo-config/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-upboundreposet-repo-config/test/__init__.py b/tests/test-upboundreposet-repo-config/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-upboundreposet-repo-config/test/__main__.py b/tests/test-upboundreposet-repo-config/test/__main__.py new file mode 100644 index 0000000..22610d9 --- /dev/null +++ b/tests/test-upboundreposet-repo-config/test/__main__.py @@ -0,0 +1,67 @@ +"""UpboundRepoSet: per-repository public/publish settings override the set-wide defaults.""" + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +NAME = "test-upboundreposet-repo-config" +ORG = "test-org" +PROVIDER_CONFIG = f"{NAME}-{ORG}-reposet" +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] + + +def repository(name: str, public: bool, publish: bool) -> dict: + return { + "apiVersion": "repository.m.upbound.io/v1alpha1", + "kind": "Repository", + "metadata": { + "annotations": { + "crosslane.io/external-name": name, + "crossplane.io/composition-resource-name": f"{ORG}-{name}", + }, + }, + "spec": { + "forProvider": {"name": name, "organizationName": ORG, "public": public, "publish": publish}, + "managementPolicies": ORPHAN, + "providerConfigRef": {"kind": "ProviderConfig", "name": PROVIDER_CONFIG}, + }, + } + + +test = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name=NAME), + spec=compositiontest.Spec( + assertResources=[ + repository("test1", public=True, publish=False), + repository("test2", public=True, publish=True), + repository("test3", public=False, publish=True), + repository("test4", public=False, publish=False), + ], + compositionPath="apis/upboundreposets/composition.yaml", + xr={ + "apiVersion": "sa.upbound.io/v1", + "kind": "UpboundRepoSet", + "metadata": {"name": NAME, "namespace": "default"}, + "spec": { + "parameters": { + "organization": ORG, + "settings": {"public": False, "publish": False}, + "permissions": {"teams": {"test-team": {"permission": "write"}}}, + "repositories": { + "test1": {"public": True, "publish": False}, + "test2": {"public": True, "publish": True}, + "test3": {"public": False, "publish": True}, + "test4": {}, + }, + "tokenSecretRef": {"key": "creds", "name": "my-secret", "namespace": "default"}, + }, + }, + }, + xrdPath="apis/upboundreposets/definition.yaml", + timeoutSeconds=60, + validate=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) diff --git a/tests/test-upboundreposet/README.md b/tests/test-upboundreposet/README.md new file mode 100644 index 0000000..b3c0b31 --- /dev/null +++ b/tests/test-upboundreposet/README.md @@ -0,0 +1,4 @@ +# Composition Test + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your composition test here. diff --git a/tests/test-upboundreposet/kcl.mod b/tests/test-upboundreposet/kcl.mod deleted file mode 100644 index 4209aa1..0000000 --- a/tests/test-upboundreposet/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "test-upboundreposet" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } \ No newline at end of file diff --git a/tests/test-upboundreposet/kcl.mod.lock b/tests/test-upboundreposet/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/tests/test-upboundreposet/kcl.mod.lock +++ /dev/null @@ -1,5 +0,0 @@ -[dependencies] - [dependencies.models] - name = "models" - full_name = "models_0.0.1" - version = "0.0.1" diff --git a/tests/test-upboundreposet/main.k b/tests/test-upboundreposet/main.k deleted file mode 100644 index d0fe0ff..0000000 --- a/tests/test-upboundreposet/main.k +++ /dev/null @@ -1,175 +0,0 @@ -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.io.upbound.sa.v1 as sav1 - -_items = [ - metav1alpha1.CompositionTest{ - metadata.name: "test-upboundreposet" - spec= { - assertResources: [ - sav1.UpboundRepoSet{ - metadata = { - name = "example" - namespace = "default" - } - spec = { - parameters = {} - } - } - { - apiVersion = "m.upbound.io/v1alpha1" - kind = "ProviderConfig" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "providerConfigUpbound" - } - labels = { - "crossplane.io/composite" = "example" - } - name = "example-upboundcare-reposet" - } - spec = { - credentials = { - secretRef = { - key = "token" - name = "solutions-non-prod-bootstrap-token" - namespace = "default" - } - source = "Secret" - } - organization = "upboundcare" - } - } - { - apiVersion = "repository.m.upbound.io/v1alpha1" - kind = "Repository" - metadata = { - annotations = { - "crosslane.io/external-name" = "configuration-aws-network" - "crossplane.io/composition-resource-name" = "upboundcare-configuration-aws-network" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - } - spec = { - forProvider = { - name = "configuration-aws-network" - organizationName = "upboundcare" - public = True - publish = True - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-upboundcare-reposet" - } - } - } - { - apiVersion = "repository.m.upbound.io/v1alpha1" - kind = "Permission" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "upboundcare-configuration-aws-network-solutions-non-prod-ci-team" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - } - spec = { - forProvider = { - organizationName = "upboundcare" - permission = "write" - repository = "configuration-aws-network" - teamIdRef = { - name = "solutions-non-prod-ci-team" - } - } - managementPolicies = [ - "*" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-upboundcare-reposet" - } - } - } - { - apiVersion = "repository.m.upbound.io/v1alpha1" - kind = "Repository" - metadata = { - annotations = { - "crosslane.io/external-name" = "configuration-aws-network_xnetwork" - "crossplane.io/composition-resource-name" = "upboundcare-configuration-aws-network_xnetwork" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - } - spec = { - forProvider = { - name = "configuration-aws-network_xnetwork" - organizationName = "upboundcare" - public = False - publish = False - } - managementPolicies = [ - "Create" - "Observe" - "Update" - "LateInitialize" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-upboundcare-reposet" - } - } - } - { - apiVersion = "repository.m.upbound.io/v1alpha1" - kind = "Permission" - metadata = { - annotations = { - "crossplane.io/composition-resource-name" = "upboundcare-configuration-aws-network_xnetwork-solutions-non-prod-ci-team" - } - generateName = "example-" - labels = { - "crossplane.io/composite" = "example" - } - } - spec = { - forProvider = { - organizationName = "upboundcare" - permission = "write" - repository = "configuration-aws-network_xnetwork" - teamIdRef = { - name = "solutions-non-prod-ci-team" - } - } - managementPolicies = [ - "*" - ] - providerConfigRef = { - kind = "ProviderConfig" - name = "example-upboundcare-reposet" - } - } - } - ] - compositionPath: "apis/upboundreposets/composition.yaml" - xrPath: "examples/upboundreposet/example.yaml" - xrdPath: "apis/upboundreposets/definition.yaml" - timeoutSeconds: 60 - validate: False - } - } -] -items= _items diff --git a/tests/test-upboundreposet/model b/tests/test-upboundreposet/model deleted file mode 120000 index 1b8c82f..0000000 --- a/tests/test-upboundreposet/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models/ \ No newline at end of file diff --git a/tests/test-upboundreposet/pyproject.toml b/tests/test-upboundreposet/pyproject.toml new file mode 100644 index 0000000..a18d683 --- /dev/null +++ b/tests/test-upboundreposet/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "test" +description = "A Crossplane composition test." +readme = "README.md" +requires-python = ">=3.11,<3.14" +version = "0.0.0" +dependencies = [ + "pydantic==2.12.4", + "pyyaml==6.0.2", + "crossplane-models @ file:./../../.up/python", +] + +[tool.hatch.envs.default.scripts] +test = "python -m test" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/tests/test-upboundreposet/test/__init__.py b/tests/test-upboundreposet/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-upboundreposet/test/__main__.py b/tests/test-upboundreposet/test/__main__.py new file mode 100644 index 0000000..0708e64 --- /dev/null +++ b/tests/test-upboundreposet/test/__main__.py @@ -0,0 +1,101 @@ +"""UpboundRepoSet: repositories, per-team permissions, and the ProviderConfig they share. + +Renders examples/upboundreposet/example.yaml - two repositories, one public and one private, +and one team with write access - and asserts the full set of composed resources. +""" + +import yaml +from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s +from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + +ORG = "upboundcare" +TEAM = "solutions-non-prod-ci-team" +PROVIDER_CONFIG = f"example-{ORG}-reposet" +COMPOSITE_LABEL = {"crossplane.io/composite": "example"} +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] + + +def repository(name: str, public: bool) -> dict: + return { + "apiVersion": "repository.m.upbound.io/v1alpha1", + "kind": "Repository", + "metadata": { + "annotations": { + "crosslane.io/external-name": name, + "crossplane.io/composition-resource-name": f"{ORG}-{name}", + }, + "generateName": "example-", + "labels": COMPOSITE_LABEL, + }, + "spec": { + "forProvider": {"name": name, "organizationName": ORG, "public": public, "publish": public}, + # Repositories are orphaned: they outlive the UpboundRepoSet. + "managementPolicies": ORPHAN, + "providerConfigRef": {"kind": "ProviderConfig", "name": PROVIDER_CONFIG}, + }, + } + + +def permission(repo: str) -> dict: + return { + "apiVersion": "repository.m.upbound.io/v1alpha1", + "kind": "Permission", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": f"{ORG}-{repo}-{TEAM}"}, + "generateName": "example-", + "labels": COMPOSITE_LABEL, + }, + "spec": { + "forProvider": { + "organizationName": ORG, + "permission": "write", + "repository": repo, + "teamIdRef": {"name": TEAM}, + }, + "managementPolicies": ["*"], + "providerConfigRef": {"kind": "ProviderConfig", "name": PROVIDER_CONFIG}, + }, + } + + +test = compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name="test-upboundreposet"), + spec=compositiontest.Spec( + assertResources=[ + { + "apiVersion": "sa.upbound.io/v1", + "kind": "UpboundRepoSet", + "metadata": {"name": "example", "namespace": "default"}, + "spec": {"parameters": {}}, + }, + { + "apiVersion": "m.upbound.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": { + "annotations": {"crossplane.io/composition-resource-name": "providerConfigUpbound"}, + "labels": COMPOSITE_LABEL, + "name": PROVIDER_CONFIG, + }, + "spec": { + "credentials": { + "secretRef": {"key": "token", "name": "solutions-non-prod-bootstrap-token", "namespace": "default"}, + "source": "Secret", + }, + "organization": ORG, + }, + }, + repository("configuration-aws-network", public=True), + permission("configuration-aws-network"), + repository("configuration-aws-network_xnetwork", public=False), + permission("configuration-aws-network_xnetwork"), + ], + compositionPath="apis/upboundreposets/composition.yaml", + xrPath="examples/upboundreposet/example.yaml", + xrdPath="apis/upboundreposets/definition.yaml", + timeoutSeconds=60, + validate=False, + ), +) + +# The test runner expects an "items" array, one entry per test. +print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) From 3968b2d7f81a6ffc95588099a4adc59fc20d75b8 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Sep 2026 15:18:48 +0200 Subject: [PATCH 05/10] fix: set Repository external names under the right annotation key Repositories carried `crosslane.io/external-name`, a misspelling that Crossplane ignores. provider-upbound looks a Repository up by its external name, so with none set Crossplane defaulted it to the generated metadata.name, the first Observe found nothing, and the provider only recovered by running Create - an upsert on forProvider.name - which then rewrote the external name to the repository name. With the key spelled correctly, the external name is the repository name from the start and an existing repository is observed directly. Resources already reconciled have that external name, so this changes nothing for them. --- functions/upboundreposet/function/fn.py | 11 ++++++----- .../test-upboundreposet-repo-config/test/__main__.py | 2 +- tests/test-upboundreposet/test/__main__.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/functions/upboundreposet/function/fn.py b/functions/upboundreposet/function/fn.py index 655ac17..7284603 100644 --- a/functions/upboundreposet/function/fn.py +++ b/functions/upboundreposet/function/fn.py @@ -53,11 +53,12 @@ async def RunFunction( rsp.desired.resources[f"{org}-{repo}"], repov1alpha1.Repository( metadata=k8s.ObjectMeta( - # The misspelling is inherited from the KCL function, whose output - # this port reproduces exactly: correcting it changes how - # provider-upbound identifies existing repositories, which is a - # behaviour change to make deliberately, on its own. - annotations={"crosslane.io/external-name": repo}, + # provider-upbound looks a Repository up by its external name. + # Setting it to the repository name lets the first Observe find an + # existing repository; left unset, Crossplane defaults it to the + # generated metadata.name, which matches nothing, and the provider + # only recovers by running Create - an upsert - and rewriting it. + annotations={"crossplane.io/external-name": repo}, ), spec=repov1alpha1.Spec( managementPolicies=ORPHAN, diff --git a/tests/test-upboundreposet-repo-config/test/__main__.py b/tests/test-upboundreposet-repo-config/test/__main__.py index 22610d9..59d5ec5 100644 --- a/tests/test-upboundreposet-repo-config/test/__main__.py +++ b/tests/test-upboundreposet-repo-config/test/__main__.py @@ -16,7 +16,7 @@ def repository(name: str, public: bool, publish: bool) -> dict: "kind": "Repository", "metadata": { "annotations": { - "crosslane.io/external-name": name, + "crossplane.io/external-name": name, "crossplane.io/composition-resource-name": f"{ORG}-{name}", }, }, diff --git a/tests/test-upboundreposet/test/__main__.py b/tests/test-upboundreposet/test/__main__.py index 0708e64..94c00d1 100644 --- a/tests/test-upboundreposet/test/__main__.py +++ b/tests/test-upboundreposet/test/__main__.py @@ -21,7 +21,7 @@ def repository(name: str, public: bool) -> dict: "kind": "Repository", "metadata": { "annotations": { - "crosslane.io/external-name": name, + "crossplane.io/external-name": name, "crossplane.io/composition-resource-name": f"{ORG}-{name}", }, "generateName": "example-", From 74e3cbd9eb39b6b32557e5fc63d4d9b09a57b0bc Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Sep 2026 17:01:53 +0200 Subject: [PATCH 06/10] refactor: restore module boundaries and share common helpers environments' resources.py had grown to hold every area of the composition behind comment headers. It is now a resources/ package with one module per area, mirroring the KCL modules it replaced: kubernetes (Objects, kubeconfigs, ProviderConfigs), argo, team_robot, secret_sync, aws, and util. The helpers duplicated across functions - ORPHAN, management_policies, the IAM name truncation and its hash, dig, and the Object defaults kept for KCL parity - now live once, in common/ at the project root. A function is packaged from its own directory only, so each carries a function/common symlink; up follows symlinks when it packages a function's source, so every built function gets its own copy. A pure move: all 20 composition tests pass, and the full render of every test is identical to before the change. --- README.md | 5 + common/__init__.py | 9 + common/dicts.py | 10 + common/kcl_parity.py | 11 + common/naming.py | 24 ++ common/policy.py | 10 + functions/environments/function/common | 1 + functions/environments/function/compat.py | 4 +- functions/environments/function/fn.py | 16 +- functions/environments/function/resources.py | 408 ------------------ .../function/resources/__init__.py | 32 ++ .../environments/function/resources/argo.py | 45 ++ .../environments/function/resources/aws.py | 89 ++++ .../function/resources/kubernetes.py | 130 ++++++ .../function/resources/secret_sync.py | 28 ++ .../function/resources/team_robot.py | 83 ++++ .../environments/function/resources/util.py | 5 + functions/sharedawssecret/function/common | 1 + functions/sharedawssecret/function/fn.py | 55 +-- functions/upboundreposet/function/common | 1 + functions/upboundreposet/function/fn.py | 5 +- 21 files changed, 504 insertions(+), 468 deletions(-) create mode 100644 common/__init__.py create mode 100644 common/dicts.py create mode 100644 common/kcl_parity.py create mode 100644 common/naming.py create mode 100644 common/policy.py create mode 120000 functions/environments/function/common delete mode 100644 functions/environments/function/resources.py create mode 100644 functions/environments/function/resources/__init__.py create mode 100644 functions/environments/function/resources/argo.py create mode 100644 functions/environments/function/resources/aws.py create mode 100644 functions/environments/function/resources/kubernetes.py create mode 100644 functions/environments/function/resources/secret_sync.py create mode 100644 functions/environments/function/resources/team_robot.py create mode 100644 functions/environments/function/resources/util.py create mode 120000 functions/sharedawssecret/function/common create mode 120000 functions/upboundreposet/function/common diff --git a/README.md b/README.md index 94ecf2b..7a9445e 100644 --- a/README.md +++ b/README.md @@ -529,6 +529,11 @@ for d in functions/*; do (cd "$d" && ../../.venv/bin/pip install -q -e .); done .venv/bin/pip install -e .up/python # last, and editable, so regenerated models need no reinstall ``` +Code more than one function needs lives in `common/` at the project root, not in any one +function. A function is packaged from its own directory alone, so each carries a +`function/common` symlink to it, and `up` copies the symlink's target into the built function. +Import it as `from .common.naming import truncate_iam_name`. + > Function directory names are the published package paths > (`xpkg.upbound.io//platform-ref-upbound_`) — renaming one publishes a new package. diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..a3f1606 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1,9 @@ +"""Code shared by this project's composition functions. + +Each function is built and packaged on its own, from its own directory, so a function cannot +import a sibling. This package is shared by symlink instead: every function carries a +`function/common` symlink pointing here, and `up` follows symlinks when it packages a +function's source, so each built function gets its own copy of this directory. + +Keep it free of imports from any one function, and of anything a function would not want. +""" diff --git a/common/dicts.py b/common/dicts.py new file mode 100644 index 0000000..b868da3 --- /dev/null +++ b/common/dicts.py @@ -0,0 +1,10 @@ +"""Reading untyped request data.""" + + +def dig(d, *path): + """Walk nested dicts, returning None at the first missing level.""" + for key in path: + if not isinstance(d, dict): + return None + d = d.get(key) + return d diff --git a/common/kcl_parity.py b/common/kcl_parity.py new file mode 100644 index 0000000..6c34fe7 --- /dev/null +++ b/common/kcl_parity.py @@ -0,0 +1,11 @@ +"""Output the KCL implementation produced without the functions asking for it. + +These functions replaced KCL ones and keep their rendered output identical. KCL's typed +models materialised every schema default into their output, so a few provider defaults +appear in the desired state although no function set them. They change nothing on a cluster; +they are kept so the rendered desired state - what the composition tests assert - is unchanged. +""" + +# provider-kubernetes Object defaults, emitted on every Object. +OBJECT_FOR_PROVIDER_DEFAULTS = {"deletionPropagationPolicy": "Background"} +OBJECT_SPEC_DEFAULTS = {"watch": False} diff --git a/common/naming.py b/common/naming.py new file mode 100644 index 0000000..8eb0ffb --- /dev/null +++ b/common/naming.py @@ -0,0 +1,24 @@ +"""AWS IAM resource names that fit IAM's length limit.""" + +IAM_NAME_MAX = 64 + + +def simple_hash(s: str) -> str: + """Position-weighted character sum, truncated to 8 digits. + + Not a cryptographic hash, and it does not need to be: it only has to be stable, because + its output becomes part of an AWS resource name. It must stay identical to the KCL + original it replaced - a different value renames, and so replaces, the IAM resource. + """ + return str(abs(len(s) * 31 + sum(ord(c) * (i + 1) for i, c in enumerate(s))))[:8] + + +def truncate_iam_name(name: str, suffix: str) -> str: + """Fit an IAM name into 64 characters, keeping the suffix and hashing the prefix.""" + if len(name) <= IAM_NAME_MAX: + return name + base = name[: len(name) - len(suffix)] + prefix_space = IAM_NAME_MAX - len(suffix) - 8 - 1 + if prefix_space <= 0: + return f"{simple_hash(base)}{suffix}" + return f"{base[:prefix_space].rstrip('-')}-{simple_hash(base)}{suffix}" diff --git a/common/policy.py b/common/policy.py new file mode 100644 index 0000000..fcdcf57 --- /dev/null +++ b/common/policy.py @@ -0,0 +1,10 @@ +"""managementPolicies for the XR-level deletionPolicy parameter.""" + +# Orphan on delete. Namespaced (.m.) managed resources have no deletionPolicy; leaving +# "Delete" out of managementPolicies is the only way to keep the external resource. +ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] + + +def management_policies(deletion_policy: str) -> list[str]: + """Translate the XR's Delete/Orphan parameter into managementPolicies.""" + return ["*"] if deletion_policy == "Delete" else ORPHAN diff --git a/functions/environments/function/common b/functions/environments/function/common new file mode 120000 index 0000000..f74dff0 --- /dev/null +++ b/functions/environments/function/common @@ -0,0 +1 @@ +../../../common \ No newline at end of file diff --git a/functions/environments/function/compat.py b/functions/environments/function/compat.py index 2f5d6fa..93bff05 100644 --- a/functions/environments/function/compat.py +++ b/functions/environments/function/compat.py @@ -11,9 +11,7 @@ set them. """ -# provider-kubernetes Object defaults KCL emitted on every Object. -OBJECT_FOR_PROVIDER_DEFAULTS = {"deletionPropagationPolicy": "Background"} -OBJECT_SPEC_DEFAULTS = {"watch": False} +from .common.kcl_parity import OBJECT_FOR_PROVIDER_DEFAULTS, OBJECT_SPEC_DEFAULTS def kcl_str(value, in_list: bool = False) -> str: diff --git a/functions/environments/function/fn.py b/functions/environments/function/fn.py index 55ddf12..c0f7ee6 100644 --- a/functions/environments/function/fn.py +++ b/functions/environments/function/fn.py @@ -28,6 +28,7 @@ from models.io.upbound.sa.sharedawssecret import v1 as sasv1 from . import resources as r +from .common.dicts import dig # The bootstrap kubeconfig's server URL has the shape # https:///apis/spaces.upbound.io/v1beta1/namespaces//controlplanes//k8s @@ -37,15 +38,6 @@ BOOTSTRAP_CTP_RE = re.compile(r"https:\/(?:\/[.\w-]+){7}\/([.\w-]+)(?:\/[.\w-]+)") -def _dig(d, *path): - """Walk nested dicts, returning None at the first missing level.""" - for key in path: - if not isinstance(d, dict): - return None - d = d.get(key) - return d - - def _observed(req: fnv1.RunFunctionRequest, key: str) -> dict: if key not in req.observed.resources: return {} @@ -128,7 +120,7 @@ async def RunFunction( # Initialisation # ================================================================================= parsed = {} - encoded = _dig(_observed(req, "observedCtpKubeconfig"), "status", "atProvider", "manifest", "data", "kubeconfig") + encoded = dig(_observed(req, "observedCtpKubeconfig"), "status", "atProvider", "manifest", "data", "kubeconfig") if encoded: parsed = parse_bootstrap_kubeconfig(encoded) @@ -220,7 +212,7 @@ def _compose(self, req, xr, st, parsed, token_ref, desired) -> dict: provider_config_name=bootstrap_pc, resource_name="observed-access-token", ) - token = _dig(_observed(req, "observed-access-token"), "status", "atProvider", "manifest", "data", "token") + token = dig(_observed(req, "observed-access-token"), "status", "atProvider", "manifest", "data", "token") if token: desired += r.argo_server_secret( access_token=base64.b64decode(token).decode(), @@ -247,7 +239,7 @@ def _compose(self, req, xr, st, parsed, token_ref, desired) -> dict: group=group, org=st.org, token_ref=token_ref, - observed_team_external_name=_dig( + observed_team_external_name=dig( _observed(req, "envTeam"), "metadata", "annotations", "crossplane.io/external-name" ), space_provider_config_name=f"{name}-space", diff --git a/functions/environments/function/resources.py b/functions/environments/function/resources.py deleted file mode 100644 index c3a3587..0000000 --- a/functions/environments/function/resources.py +++ /dev/null @@ -1,408 +0,0 @@ -"""Builders for the resources an Environment composes. - -Each returns a list of (composition key, resource) pairs; fn.py decides which apply. The -split mirrors the KCL modules this replaced - kubeconfigs and ProviderConfigs, the Argo CD -secret, Team and Robot, secret sync, and AWS - so a reader can hold the two side by side. - -Composition keys matter beyond this file. A changed key makes Crossplane delete the resource -under the old one and create another, so every key here is the one the KCL version actually -produced - including the handful where KCL fell back to the resource's name because merging -metadata had replaced the annotation carrying the intended key. Those are marked. -""" - -import base64 -import json - -from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 -from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 -from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 -from models.io.upbound.m.aws.iam.openidconnectprovider import v1beta1 as oidcv1beta1 -from models.io.upbound.m.aws.iam.role import v1beta1 as rolev1beta1 -from models.io.upbound.m.aws.iam.rolepolicyattachment import v1beta1 as rpav1beta1 -from models.io.upbound.m.aws.providerconfig import v1beta1 as awspcv1beta1 -from models.io.upbound.m.iam.robot import v1alpha1 as robotv1alpha1 -from models.io.upbound.m.iam.robotteammembership import v1alpha1 as rtmv1alpha1 -from models.io.upbound.m.iam.team import v1alpha1 as teamv1alpha1 -from models.io.upbound.m.iam.token import v1alpha1 as tokenv1alpha1 -from models.io.upbound.m.providerconfig import v1alpha1 as upbpcv1alpha1 - -from .compat import kcl_str, object_spec - -ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] -IAM_NAME_MAX = 64 -OBJECT_API = "kubernetes.m.crossplane.io/v1alpha1" - -TRUST_POLICY = """{{ - "Version": "2012-10-17", - "Statement": [ - {{ - "Effect": "Allow", - "Principal": {{ - "Federated": "arn:aws:iam::{account_id}:oidc-provider/proidc.upbound.io" - }}, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": {{ - "StringEquals": {{ - "proidc.upbound.io:sub": "mcp:{org}/{ctp}:provider:provider-aws", - "proidc.upbound.io:aud": "sts.amazonaws.com" - }} - }} - }} - ] -}}""" - - -def management_policies(deletion_policy: str) -> list[str]: - """Translate the XR's Delete/Orphan parameter into managementPolicies. - - Namespaced (.m.) managed resources have no deletionPolicy; managementPolicies is the only - way to say "do not delete the external resource". - """ - return ["*"] if deletion_policy == "Delete" else ORPHAN - - -def pc_ref(name: str) -> dict: - return {"kind": "ProviderConfig", "name": name} - - -def _simple_hash(s: str) -> str: - # Must stay identical to functions/sharedawssecret: its output is part of AWS names. - return str(abs(len(s) * 31 + sum(ord(c) * (i + 1) for i, c in enumerate(s))))[:8] - - -def truncate_iam_name(name: str, suffix: str) -> str: - """Fit an IAM name into 64 characters, keeping the suffix and hashing the prefix.""" - if len(name) <= IAM_NAME_MAX: - return name - base = name[: len(name) - len(suffix)] - prefix_space = IAM_NAME_MAX - len(suffix) - 8 - 1 - if prefix_space <= 0: - return f"{_simple_hash(base)}{suffix}" - return f"{base[:prefix_space].rstrip('-')}-{_simple_hash(base)}{suffix}" - - -def k8s_object(name: str | None, spec: dict, annotations: dict | None = None) -> objectv1alpha1.Object: - metadata = {} - if name is not None: - metadata["name"] = name - if annotations: - metadata["annotations"] = annotations - return objectv1alpha1.Object.model_validate({"metadata": metadata, "spec": object_spec(spec)}) - - -# --- kubeconfigs and provider-kubernetes ProviderConfigs ---------------------------------- - - -def upbound_kubeconfig(space_host: str, org: str, group: str, ctp: str) -> dict: - """A kubeconfig for the Space (ctp == "") or for one control plane in it. - - Authenticates by running `up organization token`, which provider-kubernetes supplies - with the Upbound token through the ProviderConfig's UpboundTokens identity. - """ - server = ( - f"https://{space_host}" - if ctp == "" - else f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s" - ) - return { - "apiVersion": "v1", - "clusters": [{"cluster": {"insecure-skip-tls-verify": True, "server": server}, "name": "upbound"}], - "contexts": [{ - "context": { - "cluster": "upbound", - "extensions": [{ - "extension": { - "apiVersion": "upbound.io/v1alpha1", - "kind": "SpaceExtension", - "spec": {"cloud": {"organization": org}}, - }, - "name": "spaces.upbound.io/space", - }], - "namespace": group if ctp == "" else "default", - "user": "upbound", - }, - "name": "upbound", - }], - "current-context": "upbound", - "kind": "Config", - "preferences": {}, - "users": [{ - "name": "upbound", - "user": {"exec": { - "apiVersion": "client.authentication.k8s.io/v1", - "args": ["organization", "token"], - "command": "up", - "env": [{"name": "ORGANIZATION", "value": org}, {"name": "UP_PROFILE", "value": "default"}], - "interactiveMode": "IfAvailable", - "provideClusterInfo": False, - }}, - }], - } - - -def upbound_provider_config(*, space_host, org, provider_config_name, secret_namespace, token_ref, - group=None, ctp=None, prefix=None) -> list: - """A provider-kubernetes ProviderConfig for the Space, a group, or a control plane. - - Three resources: the kubeconfig Secret (applied through an Object), the ProviderConfig - that reads it, and a Usage that keeps the Secret until the ProviderConfig is gone. - """ - config_name = f"{ctp}-ctp" if ctp else (f"{group}-group" if group else f"{prefix}-space") - scope = "envCtp" if ctp else ("envGroup" if group else "space") - secret_name = f"{config_name}-kubeconfig" - kubeconfig = kcl_str(upbound_kubeconfig(space_host, org, group or "default", ctp or "")) - return [ - (f"{scope}Kubeconfig", k8s_object(secret_name, { - # The API default, set explicitly: KCL materialised it. - "managementPolicies": ["*"], - "forProvider": {"manifest": { - "apiVersion": "v1", - "kind": "Secret", - "metadata": {"name": secret_name, "namespace": secret_namespace}, - # base64 `data`, not `stringData`: provider-kubernetes records ownership of the - # fields it writes, and stringData is never stored, so the next observe fails. - "data": {"kubeconfig": base64.b64encode(kubeconfig.encode()).decode()}, - }}, - "providerConfigRef": pc_ref(provider_config_name), - })), - # Keyed by name: see the module docstring. - (config_name, k8spcv1alpha1.ProviderConfig.model_validate({ - "metadata": {"name": config_name}, - "spec": { - "credentials": { - "source": "Secret", - "secretRef": {"name": secret_name, "namespace": secret_namespace, "key": "kubeconfig"}, - }, - "identity": { - "type": "UpboundTokens", - "source": "Secret", - "secretRef": token_ref, - }, - }, - })), - (f"{scope}Usage", usagev1beta1.Usage.model_validate({ - "metadata": {"name": secret_name}, - "spec": { - "replayDeletion": True, - "of": {"apiVersion": OBJECT_API, "kind": "Object", "resourceRef": {"name": secret_name}}, - "by": {"apiVersion": OBJECT_API, "kind": "ProviderConfig", "resourceRef": {"name": config_name}}, - }, - })), - ] - - -def observe_secret(*, ctp, name, namespace, provider_config_name, resource_name) -> list: - """An observe-only Object that reads a Secret off the bootstrap control plane.""" - return [(resource_name, k8s_object(f"{ctp}-{resource_name}-observed", { - "managementPolicies": ["Observe"], - "forProvider": {"manifest": { - "apiVersion": "v1", "kind": "Secret", "metadata": {"name": name, "namespace": namespace}, - }}, - "providerConfigRef": pc_ref(provider_config_name), - }))] - - -# --- Argo CD -------------------------------------------------------------------------------- - - -def argo_server_secret(*, access_token, org, group, ctp, provider_config_name, server_ca_data, space_host) -> list: - """Register the environment's control plane as a cluster with Argo CD.""" - cluster = f"{group}-{ctp}" - config = { - "execProviderConfig": { - "apiVersion": "client.authentication.k8s.io/v1", - "command": "up", - "args": ["org", "token"], - "env": {"ORGANIZATION": org, "UP_TOKEN": access_token}, - }, - "tlsClientConfig": {"insecure": False}, - } - # KCL dropped a key whose value was Undefined; the CA is absent when the bootstrap - # kubeconfig carries none. - if server_ca_data is not None: - config["tlsClientConfig"]["caData"] = server_ca_data - b64 = lambda s: base64.b64encode(s.encode()).decode() - return [("ctp-argocd", k8s_object(f"{ctp}-ctp-argocd-secret", { - "managementPolicies": ["*"], - "forProvider": {"manifest": { - "apiVersion": "v1", - "kind": "Secret", - "metadata": { - "name": cluster, - "namespace": "argocd", - "labels": {"argocd.argoproj.io/secret-type": "cluster"}, - }, - "type": "Opaque", - "data": { - "name": b64(cluster), - "server": b64(f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s"), - "config": b64(json.dumps(config)), - }, - }}, - "providerConfigRef": pc_ref(provider_config_name), - }))] - - -# --- Team and Robot ------------------------------------------------------------------------- - - -def team_with_robot(*, group, org, token_ref, observed_team_external_name, space_provider_config_name, - team_name_override=None, team_external_name=None, create_group_admin_binding=False) -> list: - """A Team, a Robot in it with a Token, and optionally admin rights for the Team on the group.""" - upbound_pc = pc_ref(f"{group}-upbound") - team_meta = {"name": f"{group}-team"} - if team_external_name: - team_meta["annotations"] = {"crossplane.io/external-name": team_external_name} - items = [ - # Keyed by name: see the module docstring. - (f"{group}-upbound", upbpcv1alpha1.ProviderConfig.model_validate({ - "metadata": {"name": f"{group}-upbound"}, - "spec": { - "credentials": {"secretRef": token_ref, "source": "Secret"}, - "organization": org, - }, - })), - ("envRobot", robotv1alpha1.Robot.model_validate({ - "metadata": {"name": f"{group}-robot"}, - "spec": { - "managementPolicies": ["*"], - "forProvider": {"description": f"Robot for {group}", "name": f"{group}-bot", "owner": {"name": org}}, - "providerConfigRef": upbound_pc, - }, - })), - ("envRobotToken", tokenv1alpha1.Token.model_validate({ - "metadata": {"name": f"{group}-robot-token"}, - "spec": { - "managementPolicies": ["*"], - "forProvider": {"name": group, "owner": {"idRef": {"name": f"{group}-robot"}, "type": "robots"}}, - "providerConfigRef": upbound_pc, - "writeConnectionSecretToRef": {"name": f"{group}-robot-token"}, - }, - })), - ("envTeam", teamv1alpha1.Team.model_validate({ - "metadata": team_meta, - "spec": { - "managementPolicies": ORPHAN, - "forProvider": {"name": team_name_override or f"{group}-team", "organizationName": org}, - "providerConfigRef": upbound_pc, - }, - })), - ("envRobotTeamMembership", rtmv1alpha1.RobotTeamMembership.model_validate({ - "metadata": {"name": f"{group}-robot-team-membership"}, - "spec": { - "managementPolicies": ["*"], - "forProvider": {"robotIdRef": {"name": f"{group}-robot"}, "teamIdRef": {"name": f"{group}-team"}}, - "providerConfigRef": upbound_pc, - }, - })), - ] - if create_group_admin_binding: - subject = {"kind": "UpboundTeam", "role": "admin"} - # The Team's Upbound ID exists only once the Team has been created; until then the - # subject has no name and the binding cannot apply yet. KCL behaved the same way. - if observed_team_external_name: - subject["name"] = observed_team_external_name - items.append(("teamAdminBinding", k8s_object(f"{group}-admin-binding", { - "managementPolicies": ["*"], - "forProvider": {"manifest": { - "apiVersion": "authorization.spaces.upbound.io/v1alpha1", - "kind": "ObjectRoleBinding", - "metadata": {"name": f"{group}-admin-binding", "namespace": group}, - "spec": { - "object": {"apiGroup": "core", "resource": "namespaces", "name": group}, - "subjects": [subject], - }, - }}, - "providerConfigRef": pc_ref(space_provider_config_name), - }))) - return items - - -# --- secret sync ---------------------------------------------------------------------------- - - -def synced_secret(*, source_ref, dest_ref, provider_config_name) -> list: - """Copy a Secret from the bootstrap control plane into the environment's control plane.""" - key = f"{source_ref['namespace']}-{source_ref['name']}-to-{dest_ref['namespace']}-{dest_ref['name']}-syncedSecret" - return [(key, k8s_object(None, { - "managementPolicies": ["*"], - "forProvider": {"manifest": { - "apiVersion": "v1", - "kind": "Secret", - "metadata": {"name": dest_ref["name"], "namespace": dest_ref["namespace"]}, - }}, - "providerConfigRef": pc_ref(provider_config_name), - "references": [{ - "patchesFrom": { - "apiVersion": "v1", - "kind": "Secret", - "name": source_ref["name"], - "namespace": source_ref["namespace"], - "fieldPath": "data", - }, - "toFieldPath": "data", - }], - }))] - - -# --- AWS ------------------------------------------------------------------------------------ - - -def aws_provider_config(*, env_name, role_arn=None, creds_secret_ref=None) -> list: - if role_arn: - credentials = {"source": "Upbound", "upbound": {"webIdentity": {"roleARN": role_arn}}} - else: - credentials = {"source": "Secret"} - if creds_secret_ref: - credentials["secretRef"] = creds_secret_ref - # Keyed by name: see the module docstring. - return [(env_name, awspcv1beta1.ProviderConfig.model_validate({ - "metadata": {"name": env_name}, - "spec": {"credentials": credentials}, - }))] - - -def crossplane_role(*, account_id, deletion_policy, env_name, ctp_name, name_prefix, oidc_provider_arn, - upbound_org) -> list: - """An admin IAM role the environment's provider-aws assumes through Upbound's OIDC provider.""" - mgmt = management_policies(deletion_policy) - role_name = truncate_iam_name(f"{name_prefix}-admin", "-admin") - oidc_name = f"{name_prefix}-oidc-provider" - return [ - ("iamAdminRole", rolev1beta1.Role.model_validate({ - "metadata": {"name": role_name}, - "spec": { - "managementPolicies": mgmt, - "forProvider": { - "assumeRolePolicy": TRUST_POLICY.format(account_id=account_id, org=upbound_org, ctp=ctp_name), - }, - "providerConfigRef": pc_ref(env_name), - }, - })), - ("iamAdminRoleAttach", rpav1beta1.RolePolicyAttachment.model_validate({ - "metadata": {"name": role_name}, - "spec": { - "managementPolicies": mgmt, - "forProvider": { - "roleSelector": {"matchControllerRef": True}, - "policyArn": "arn:aws:iam::aws:policy/AdministratorAccess", - }, - "providerConfigRef": pc_ref(env_name), - }, - })), - # Keyed by name: see the module docstring. - (oidc_name, oidcv1beta1.OpenIDConnectProvider.model_validate({ - "metadata": { - "name": oidc_name, - "annotations": {"crossplane.io/external-name": oidc_provider_arn} if oidc_provider_arn else {}, - }, - "spec": { - # Adoption implies orphaning, whatever deletionPolicy says: AWS allows one OIDC - # provider per URL per account, and proidc.upbound.io is shared by every Upbound - # integration in it. Deleting an adopted one would break all of them. - "managementPolicies": ORPHAN if oidc_provider_arn else mgmt, - "forProvider": {"clientIdList": ["sts.amazonaws.com"], "url": "https://proidc.upbound.io"}, - "providerConfigRef": pc_ref(env_name), - }, - })), - ] diff --git a/functions/environments/function/resources/__init__.py b/functions/environments/function/resources/__init__.py new file mode 100644 index 0000000..b9670a0 --- /dev/null +++ b/functions/environments/function/resources/__init__.py @@ -0,0 +1,32 @@ +"""Builders for the resources an Environment composes. + +Each builder returns a list of (composition key, resource) pairs; fn.py decides which apply. +The modules mirror the KCL modules this replaced - kubeconfigs and ProviderConfigs, the Argo CD +secret, Team and Robot, secret sync, and AWS - so a reader can hold the two side by side. + +Composition keys matter beyond this package. A changed key makes Crossplane delete the resource +under the old one and create another, so every key here is the one the KCL version actually +produced - including the handful where KCL fell back to the resource's name because merging +metadata had replaced the annotation carrying the intended key. Those are marked. +""" + +from ..common.policy import management_policies +from .argo import argo_server_secret +from .aws import aws_provider_config, crossplane_role +from .kubernetes import k8s_object, observe_secret, upbound_provider_config +from .secret_sync import synced_secret +from .team_robot import team_with_robot +from .util import pc_ref + +__all__ = [ + "argo_server_secret", + "aws_provider_config", + "crossplane_role", + "k8s_object", + "management_policies", + "observe_secret", + "pc_ref", + "synced_secret", + "team_with_robot", + "upbound_provider_config", +] diff --git a/functions/environments/function/resources/argo.py b/functions/environments/function/resources/argo.py new file mode 100644 index 0000000..1edf1a7 --- /dev/null +++ b/functions/environments/function/resources/argo.py @@ -0,0 +1,45 @@ +"""Argo CD cluster registration for the environment's control plane.""" + +import base64 +import json + +from .kubernetes import k8s_object +from .util import pc_ref + + +def argo_server_secret(*, access_token, org, group, ctp, provider_config_name, server_ca_data, space_host) -> list: + """Register the environment's control plane as a cluster with Argo CD.""" + cluster = f"{group}-{ctp}" + config = { + "execProviderConfig": { + "apiVersion": "client.authentication.k8s.io/v1", + "command": "up", + "args": ["org", "token"], + "env": {"ORGANIZATION": org, "UP_TOKEN": access_token}, + }, + "tlsClientConfig": {"insecure": False}, + } + # KCL dropped a key whose value was Undefined; the CA is absent when the bootstrap + # kubeconfig carries none. + if server_ca_data is not None: + config["tlsClientConfig"]["caData"] = server_ca_data + b64 = lambda s: base64.b64encode(s.encode()).decode() + return [("ctp-argocd", k8s_object(f"{ctp}-ctp-argocd-secret", { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": cluster, + "namespace": "argocd", + "labels": {"argocd.argoproj.io/secret-type": "cluster"}, + }, + "type": "Opaque", + "data": { + "name": b64(cluster), + "server": b64(f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s"), + "config": b64(json.dumps(config)), + }, + }}, + "providerConfigRef": pc_ref(provider_config_name), + }))] diff --git a/functions/environments/function/resources/aws.py b/functions/environments/function/resources/aws.py new file mode 100644 index 0000000..01b3877 --- /dev/null +++ b/functions/environments/function/resources/aws.py @@ -0,0 +1,89 @@ +"""The AWS ProviderConfig, and the admin IAM role provider-aws assumes through OIDC.""" + +from models.io.upbound.m.aws.iam.openidconnectprovider import v1beta1 as oidcv1beta1 +from models.io.upbound.m.aws.iam.role import v1beta1 as rolev1beta1 +from models.io.upbound.m.aws.iam.rolepolicyattachment import v1beta1 as rpav1beta1 +from models.io.upbound.m.aws.providerconfig import v1beta1 as awspcv1beta1 + +from ..common.naming import truncate_iam_name +from ..common.policy import ORPHAN, management_policies +from .util import pc_ref + +TRUST_POLICY = """{{ + "Version": "2012-10-17", + "Statement": [ + {{ + "Effect": "Allow", + "Principal": {{ + "Federated": "arn:aws:iam::{account_id}:oidc-provider/proidc.upbound.io" + }}, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": {{ + "StringEquals": {{ + "proidc.upbound.io:sub": "mcp:{org}/{ctp}:provider:provider-aws", + "proidc.upbound.io:aud": "sts.amazonaws.com" + }} + }} + }} + ] +}}""" + + +def aws_provider_config(*, env_name, role_arn=None, creds_secret_ref=None) -> list: + if role_arn: + credentials = {"source": "Upbound", "upbound": {"webIdentity": {"roleARN": role_arn}}} + else: + credentials = {"source": "Secret"} + if creds_secret_ref: + credentials["secretRef"] = creds_secret_ref + # Keyed by name: see the resources package docstring. + return [(env_name, awspcv1beta1.ProviderConfig.model_validate({ + "metadata": {"name": env_name}, + "spec": {"credentials": credentials}, + }))] + + +def crossplane_role(*, account_id, deletion_policy, env_name, ctp_name, name_prefix, oidc_provider_arn, + upbound_org) -> list: + """An admin IAM role the environment's provider-aws assumes through Upbound's OIDC provider.""" + mgmt = management_policies(deletion_policy) + role_name = truncate_iam_name(f"{name_prefix}-admin", "-admin") + oidc_name = f"{name_prefix}-oidc-provider" + return [ + ("iamAdminRole", rolev1beta1.Role.model_validate({ + "metadata": {"name": role_name}, + "spec": { + "managementPolicies": mgmt, + "forProvider": { + "assumeRolePolicy": TRUST_POLICY.format(account_id=account_id, org=upbound_org, ctp=ctp_name), + }, + "providerConfigRef": pc_ref(env_name), + }, + })), + ("iamAdminRoleAttach", rpav1beta1.RolePolicyAttachment.model_validate({ + "metadata": {"name": role_name}, + "spec": { + "managementPolicies": mgmt, + "forProvider": { + "roleSelector": {"matchControllerRef": True}, + "policyArn": "arn:aws:iam::aws:policy/AdministratorAccess", + }, + "providerConfigRef": pc_ref(env_name), + }, + })), + # Keyed by name: see the resources package docstring. + (oidc_name, oidcv1beta1.OpenIDConnectProvider.model_validate({ + "metadata": { + "name": oidc_name, + "annotations": {"crossplane.io/external-name": oidc_provider_arn} if oidc_provider_arn else {}, + }, + "spec": { + # Adoption implies orphaning, whatever deletionPolicy says: AWS allows one OIDC + # provider per URL per account, and proidc.upbound.io is shared by every Upbound + # integration in it. Deleting an adopted one would break all of them. + "managementPolicies": ORPHAN if oidc_provider_arn else mgmt, + "forProvider": {"clientIdList": ["sts.amazonaws.com"], "url": "https://proidc.upbound.io"}, + "providerConfigRef": pc_ref(env_name), + }, + })), + ] diff --git a/functions/environments/function/resources/kubernetes.py b/functions/environments/function/resources/kubernetes.py new file mode 100644 index 0000000..cb73d82 --- /dev/null +++ b/functions/environments/function/resources/kubernetes.py @@ -0,0 +1,130 @@ +"""provider-kubernetes Objects, kubeconfigs and ProviderConfigs for Upbound Spaces.""" + +import base64 + +from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 +from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 +from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 + +from ..compat import kcl_str, object_spec +from .util import pc_ref + +OBJECT_API = "kubernetes.m.crossplane.io/v1alpha1" + + +def k8s_object(name: str | None, spec: dict, annotations: dict | None = None) -> objectv1alpha1.Object: + metadata = {} + if name is not None: + metadata["name"] = name + if annotations: + metadata["annotations"] = annotations + return objectv1alpha1.Object.model_validate({"metadata": metadata, "spec": object_spec(spec)}) + + +def upbound_kubeconfig(space_host: str, org: str, group: str, ctp: str) -> dict: + """A kubeconfig for the Space (ctp == "") or for one control plane in it. + + Authenticates by running `up organization token`, which provider-kubernetes supplies + with the Upbound token through the ProviderConfig's UpboundTokens identity. + """ + server = ( + f"https://{space_host}" + if ctp == "" + else f"https://{space_host}/apis/spaces.upbound.io/v1beta1/namespaces/{group}/controlplanes/{ctp}/k8s" + ) + return { + "apiVersion": "v1", + "clusters": [{"cluster": {"insecure-skip-tls-verify": True, "server": server}, "name": "upbound"}], + "contexts": [{ + "context": { + "cluster": "upbound", + "extensions": [{ + "extension": { + "apiVersion": "upbound.io/v1alpha1", + "kind": "SpaceExtension", + "spec": {"cloud": {"organization": org}}, + }, + "name": "spaces.upbound.io/space", + }], + "namespace": group if ctp == "" else "default", + "user": "upbound", + }, + "name": "upbound", + }], + "current-context": "upbound", + "kind": "Config", + "preferences": {}, + "users": [{ + "name": "upbound", + "user": {"exec": { + "apiVersion": "client.authentication.k8s.io/v1", + "args": ["organization", "token"], + "command": "up", + "env": [{"name": "ORGANIZATION", "value": org}, {"name": "UP_PROFILE", "value": "default"}], + "interactiveMode": "IfAvailable", + "provideClusterInfo": False, + }}, + }], + } + + +def upbound_provider_config(*, space_host, org, provider_config_name, secret_namespace, token_ref, + group=None, ctp=None, prefix=None) -> list: + """A provider-kubernetes ProviderConfig for the Space, a group, or a control plane. + + Three resources: the kubeconfig Secret (applied through an Object), the ProviderConfig + that reads it, and a Usage that keeps the Secret until the ProviderConfig is gone. + """ + config_name = f"{ctp}-ctp" if ctp else (f"{group}-group" if group else f"{prefix}-space") + scope = "envCtp" if ctp else ("envGroup" if group else "space") + secret_name = f"{config_name}-kubeconfig" + kubeconfig = kcl_str(upbound_kubeconfig(space_host, org, group or "default", ctp or "")) + return [ + (f"{scope}Kubeconfig", k8s_object(secret_name, { + # The API default, set explicitly: KCL materialised it. + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": secret_name, "namespace": secret_namespace}, + # base64 `data`, not `stringData`: provider-kubernetes records ownership of the + # fields it writes, and stringData is never stored, so the next observe fails. + "data": {"kubeconfig": base64.b64encode(kubeconfig.encode()).decode()}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + })), + # Keyed by name: see the resources package docstring. + (config_name, k8spcv1alpha1.ProviderConfig.model_validate({ + "metadata": {"name": config_name}, + "spec": { + "credentials": { + "source": "Secret", + "secretRef": {"name": secret_name, "namespace": secret_namespace, "key": "kubeconfig"}, + }, + "identity": { + "type": "UpboundTokens", + "source": "Secret", + "secretRef": token_ref, + }, + }, + })), + (f"{scope}Usage", usagev1beta1.Usage.model_validate({ + "metadata": {"name": secret_name}, + "spec": { + "replayDeletion": True, + "of": {"apiVersion": OBJECT_API, "kind": "Object", "resourceRef": {"name": secret_name}}, + "by": {"apiVersion": OBJECT_API, "kind": "ProviderConfig", "resourceRef": {"name": config_name}}, + }, + })), + ] + + +def observe_secret(*, ctp, name, namespace, provider_config_name, resource_name) -> list: + """An observe-only Object that reads a Secret off the bootstrap control plane.""" + return [(resource_name, k8s_object(f"{ctp}-{resource_name}-observed", { + "managementPolicies": ["Observe"], + "forProvider": {"manifest": { + "apiVersion": "v1", "kind": "Secret", "metadata": {"name": name, "namespace": namespace}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + }))] diff --git a/functions/environments/function/resources/secret_sync.py b/functions/environments/function/resources/secret_sync.py new file mode 100644 index 0000000..de4ce85 --- /dev/null +++ b/functions/environments/function/resources/secret_sync.py @@ -0,0 +1,28 @@ +"""Copying Secrets from the bootstrap control plane into the environment.""" + +from .kubernetes import k8s_object +from .util import pc_ref + + +def synced_secret(*, source_ref, dest_ref, provider_config_name) -> list: + """Copy a Secret from the bootstrap control plane into the environment's control plane.""" + key = f"{source_ref['namespace']}-{source_ref['name']}-to-{dest_ref['namespace']}-{dest_ref['name']}-syncedSecret" + return [(key, k8s_object(None, { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": dest_ref["name"], "namespace": dest_ref["namespace"]}, + }}, + "providerConfigRef": pc_ref(provider_config_name), + "references": [{ + "patchesFrom": { + "apiVersion": "v1", + "kind": "Secret", + "name": source_ref["name"], + "namespace": source_ref["namespace"], + "fieldPath": "data", + }, + "toFieldPath": "data", + }], + }))] diff --git a/functions/environments/function/resources/team_robot.py b/functions/environments/function/resources/team_robot.py new file mode 100644 index 0000000..873dd6c --- /dev/null +++ b/functions/environments/function/resources/team_robot.py @@ -0,0 +1,83 @@ +"""A Team, a Robot with a Token, and the Team's admin rights on the environment group.""" + +from models.io.upbound.m.iam.robot import v1alpha1 as robotv1alpha1 +from models.io.upbound.m.iam.robotteammembership import v1alpha1 as rtmv1alpha1 +from models.io.upbound.m.iam.team import v1alpha1 as teamv1alpha1 +from models.io.upbound.m.iam.token import v1alpha1 as tokenv1alpha1 +from models.io.upbound.m.providerconfig import v1alpha1 as upbpcv1alpha1 + +from ..common.policy import ORPHAN +from .kubernetes import k8s_object +from .util import pc_ref + + +def team_with_robot(*, group, org, token_ref, observed_team_external_name, space_provider_config_name, + team_name_override=None, team_external_name=None, create_group_admin_binding=False) -> list: + """A Team, a Robot in it with a Token, and optionally admin rights for the Team on the group.""" + upbound_pc = pc_ref(f"{group}-upbound") + team_meta = {"name": f"{group}-team"} + if team_external_name: + team_meta["annotations"] = {"crossplane.io/external-name": team_external_name} + items = [ + # Keyed by name: see the resources package docstring. + (f"{group}-upbound", upbpcv1alpha1.ProviderConfig.model_validate({ + "metadata": {"name": f"{group}-upbound"}, + "spec": { + "credentials": {"secretRef": token_ref, "source": "Secret"}, + "organization": org, + }, + })), + ("envRobot", robotv1alpha1.Robot.model_validate({ + "metadata": {"name": f"{group}-robot"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"description": f"Robot for {group}", "name": f"{group}-bot", "owner": {"name": org}}, + "providerConfigRef": upbound_pc, + }, + })), + ("envRobotToken", tokenv1alpha1.Token.model_validate({ + "metadata": {"name": f"{group}-robot-token"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"name": group, "owner": {"idRef": {"name": f"{group}-robot"}, "type": "robots"}}, + "providerConfigRef": upbound_pc, + "writeConnectionSecretToRef": {"name": f"{group}-robot-token"}, + }, + })), + ("envTeam", teamv1alpha1.Team.model_validate({ + "metadata": team_meta, + "spec": { + "managementPolicies": ORPHAN, + "forProvider": {"name": team_name_override or f"{group}-team", "organizationName": org}, + "providerConfigRef": upbound_pc, + }, + })), + ("envRobotTeamMembership", rtmv1alpha1.RobotTeamMembership.model_validate({ + "metadata": {"name": f"{group}-robot-team-membership"}, + "spec": { + "managementPolicies": ["*"], + "forProvider": {"robotIdRef": {"name": f"{group}-robot"}, "teamIdRef": {"name": f"{group}-team"}}, + "providerConfigRef": upbound_pc, + }, + })), + ] + if create_group_admin_binding: + subject = {"kind": "UpboundTeam", "role": "admin"} + # The Team's Upbound ID exists only once the Team has been created; until then the + # subject has no name and the binding cannot apply yet. KCL behaved the same way. + if observed_team_external_name: + subject["name"] = observed_team_external_name + items.append(("teamAdminBinding", k8s_object(f"{group}-admin-binding", { + "managementPolicies": ["*"], + "forProvider": {"manifest": { + "apiVersion": "authorization.spaces.upbound.io/v1alpha1", + "kind": "ObjectRoleBinding", + "metadata": {"name": f"{group}-admin-binding", "namespace": group}, + "spec": { + "object": {"apiGroup": "core", "resource": "namespaces", "name": group}, + "subjects": [subject], + }, + }}, + "providerConfigRef": pc_ref(space_provider_config_name), + }))) + return items diff --git a/functions/environments/function/resources/util.py b/functions/environments/function/resources/util.py new file mode 100644 index 0000000..45faff1 --- /dev/null +++ b/functions/environments/function/resources/util.py @@ -0,0 +1,5 @@ +"""Small helpers every resource module uses.""" + + +def pc_ref(name: str) -> dict: + return {"kind": "ProviderConfig", "name": name} diff --git a/functions/sharedawssecret/function/common b/functions/sharedawssecret/function/common new file mode 120000 index 0000000..f74dff0 --- /dev/null +++ b/functions/sharedawssecret/function/common @@ -0,0 +1 @@ +../../../common \ No newline at end of file diff --git a/functions/sharedawssecret/function/fn.py b/functions/sharedawssecret/function/fn.py index a34c700..222f4c7 100644 --- a/functions/sharedawssecret/function/fn.py +++ b/functions/sharedawssecret/function/fn.py @@ -27,45 +27,16 @@ from models.io.upbound.m.aws.secretsmanager.secret import v1beta1 as smsecretv1beta1 from models.io.upbound.sa.sharedawssecret import v1 as sasv1 -ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] -IAM_NAME_MAX = 64 - - -def _simple_hash(s: str) -> str: - """Position-weighted character sum, truncated to 8 digits. - - Not a cryptographic hash, and it does not need to be: it only has to be stable, because - its output becomes part of an AWS resource name. It must stay identical to the KCL - original it replaces - a different value renames, and so replaces, the IAM user. - """ - return str(abs(len(s) * 31 + sum(ord(c) * (i + 1) for i, c in enumerate(s))))[:8] - - -def truncate_iam_name(name: str, suffix: str) -> str: - """Fit an IAM name into 64 characters, keeping the suffix and hashing the prefix.""" - if len(name) <= IAM_NAME_MAX: - return name - base = name[: len(name) - len(suffix)] - prefix_space = IAM_NAME_MAX - len(suffix) - 8 - 1 - if prefix_space <= 0: - return f"{_simple_hash(base)}{suffix}" - return f"{base[:prefix_space].rstrip('-')}-{_simple_hash(base)}{suffix}" - - -def _dig(d: dict, *path): - """Walk nested dicts, returning None at the first missing or empty level.""" - for key in path: - if not isinstance(d, dict): - return None - d = d.get(key) - return d - +from .common.dicts import dig +from .common.kcl_parity import OBJECT_FOR_PROVIDER_DEFAULTS, OBJECT_SPEC_DEFAULTS +from .common.naming import truncate_iam_name +from .common.policy import management_policies # Defaults the KCL implementation emitted without setting them - its typed models # materialise every schema default into the output. They are the provider's and # External Secrets Operator's own defaults, so they change nothing on a cluster, but they are -# part of the rendered desired state, and the port keeps that output identical. -OBJECT_DEFAULTS = {"deletionPropagationPolicy": "Background"} +# part of the rendered desired state, and the port keeps that output identical. The Object +# defaults shared with the other functions live in common.kcl_parity. REMOTE_REF_DEFAULTS = {"conversionStrategy": "Default", "decodingStrategy": "None", "metadataPolicy": "None"} TARGET_DEFAULTS = {"creationPolicy": "Owner", "deletionPolicy": "Retain"} TEMPLATE_DEFAULTS = {"engineVersion": "v2", "mergePolicy": "Replace"} @@ -78,8 +49,8 @@ def _with_defaults(d: dict, defaults: dict) -> dict: def _object_spec(**kwargs) -> objectv1alpha1.Spec: """An Object spec carrying the two provider-kubernetes defaults KCL materialised.""" - kwargs["forProvider"] = objectv1alpha1.ForProvider(**OBJECT_DEFAULTS, **kwargs["forProvider"]) - return objectv1alpha1.Spec(watch=False, **kwargs) + kwargs["forProvider"] = objectv1alpha1.ForProvider(**OBJECT_FOR_PROVIDER_DEFAULTS, **kwargs["forProvider"]) + return objectv1alpha1.Spec(**OBJECT_SPEC_DEFAULTS, **kwargs) class FunctionRunner(grpcv1.FunctionRunnerService): @@ -103,7 +74,7 @@ async def RunFunction( sms = aws.secretsManagerSecret or sasv1.SecretsManagerSecret() deletion_policy = params.deletionPolicy or "Orphan" - mgmt = ["*"] if deletion_policy == "Delete" else ORPHAN + mgmt = management_policies(deletion_policy) # Opt-out, not opt-in: only an explicit false disables creation. The block itself is # optional, and Environment omits it whenever sharedSecret carries no settings. create_secret = sms.create is not False @@ -118,10 +89,10 @@ async def RunFunction( # User-supplied pass-through values come from the raw request, not the typed model, # so they reach the manifest exactly as written - no defaults added, nothing reordered. - raw_ext = _dig(raw, "spec", "parameters", "externalSecret") or {} - secret_labels = _dig(raw_ext, "spec", "target", "template", "metadata", "labels") or {} - secret_template_data = _dig(raw_ext, "spec", "target", "template", "data") - secret_data = _dig(raw_ext, "spec", "data") + raw_ext = dig(raw, "spec", "parameters", "externalSecret") or {} + secret_labels = dig(raw_ext, "spec", "target", "template", "metadata", "labels") or {} + secret_template_data = dig(raw_ext, "spec", "target", "template", "data") + secret_data = dig(raw_ext, "spec", "data") secret_namespace = raw_ext.get("namespace") or "default" external_secret_name = raw_ext.get("name") or ctp diff --git a/functions/upboundreposet/function/common b/functions/upboundreposet/function/common new file mode 120000 index 0000000..f74dff0 --- /dev/null +++ b/functions/upboundreposet/function/common @@ -0,0 +1 @@ +../../../common \ No newline at end of file diff --git a/functions/upboundreposet/function/fn.py b/functions/upboundreposet/function/fn.py index 7284603..12014d3 100644 --- a/functions/upboundreposet/function/fn.py +++ b/functions/upboundreposet/function/fn.py @@ -19,9 +19,7 @@ from models.io.upbound.m.repository.permission import v1alpha1 as permv1alpha1 from models.io.upbound.sa.upboundreposet import v1 as reposetv1 -# Orphan on delete: a repository outlives the UpboundRepoSet that created it. Namespaced -# MRs have no deletionPolicy; this is the managementPolicies equivalent. -ORPHAN = ["Create", "Observe", "Update", "LateInitialize"] +from .common.policy import ORPHAN class FunctionRunner(grpcv1.FunctionRunnerService): @@ -61,6 +59,7 @@ async def RunFunction( annotations={"crossplane.io/external-name": repo}, ), spec=repov1alpha1.Spec( + # Orphan on delete: a repository outlives the UpboundRepoSet. managementPolicies=ORPHAN, forProvider=repov1alpha1.ForProvider( name=repo, From c8772e71f48e74fe1c5eefd5ebb27ad77642202b Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Sep 2026 18:03:34 +0200 Subject: [PATCH 07/10] refactor: write the kubeconfig Secrets as YAML The kubeconfig Secrets held KCL's str() rendering of a dict - single quotes, True/False, unquoted strings in lists - which the Python port reproduced byte for byte through a KCL-format serializer. It only worked because that text happens to parse as a YAML flow mapping. Write real YAML with yaml.safe_dump instead, and drop compat.py: the serializer was all it held besides object_spec, which now lives with k8s_object, its only caller. The kubeconfig is the same; only its bytes change. The tests now state the expected kubeconfig as data rather than as a KCL-format string. Of the full render of every test, the kubeconfig data in the three kubeconfig Secret Objects is the only thing that changed. --- functions/environments/function/compat.py | 39 --------------- .../function/resources/kubernetes.py | 13 ++++- .../test/__main__.py | 49 +++++++++++++++++-- tests/test-environment/test/__main__.py | 49 +++++++++++++++++-- 4 files changed, 103 insertions(+), 47 deletions(-) delete mode 100644 functions/environments/function/compat.py diff --git a/functions/environments/function/compat.py b/functions/environments/function/compat.py deleted file mode 100644 index 93bff05..0000000 --- a/functions/environments/function/compat.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Output the KCL implementation produced that Python does not produce on its own. - -This function replaced a KCL one, and keeps its rendered output identical - the composition -tests compare it byte for byte in places, and a changed field on a live control plane is a -changed resource. Two KCL behaviours need reproducing to get there: - -- `str()` of a dict, which KCL renders in its own syntax rather than as YAML or JSON. The - kubeconfig Secrets were written that way, and their bytes are what provider-kubernetes - reads - it happens to parse as a YAML flow mapping. -- defaults KCL's typed models materialise into every resource, whether or not the function - set them. -""" - -from .common.kcl_parity import OBJECT_FOR_PROVIDER_DEFAULTS, OBJECT_SPEC_DEFAULTS - - -def kcl_str(value, in_list: bool = False) -> str: - """Render a value the way KCL's str() does. - - Dict keys and dict values that are strings are single-quoted; a string that is a list - element is NOT quoted - so a list of strings renders as `[organization, token]`. Booleans - are True/False. Insertion order is kept. - """ - if isinstance(value, dict): - return "{" + ", ".join(f"'{k}': {kcl_str(v)}" for k, v in value.items()) + "}" - if isinstance(value, list): - return "[" + ", ".join(kcl_str(v, in_list=True) for v in value) + "]" - if isinstance(value, bool): - return "True" if value else "False" - if isinstance(value, str): - return value if in_list else f"'{value}'" - return str(value) - - -def object_spec(spec: dict) -> dict: - """An Object spec with the provider-kubernetes defaults KCL materialised.""" - spec = {**OBJECT_SPEC_DEFAULTS, **spec} - spec["forProvider"] = {**OBJECT_FOR_PROVIDER_DEFAULTS, **spec["forProvider"]} - return spec diff --git a/functions/environments/function/resources/kubernetes.py b/functions/environments/function/resources/kubernetes.py index cb73d82..a089ea4 100644 --- a/functions/environments/function/resources/kubernetes.py +++ b/functions/environments/function/resources/kubernetes.py @@ -2,16 +2,25 @@ import base64 +import yaml + from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 -from ..compat import kcl_str, object_spec +from ..common.kcl_parity import OBJECT_FOR_PROVIDER_DEFAULTS, OBJECT_SPEC_DEFAULTS from .util import pc_ref OBJECT_API = "kubernetes.m.crossplane.io/v1alpha1" +def object_spec(spec: dict) -> dict: + """An Object spec with the provider-kubernetes defaults KCL materialised.""" + spec = {**OBJECT_SPEC_DEFAULTS, **spec} + spec["forProvider"] = {**OBJECT_FOR_PROVIDER_DEFAULTS, **spec["forProvider"]} + return spec + + def k8s_object(name: str | None, spec: dict, annotations: dict | None = None) -> objectv1alpha1.Object: metadata = {} if name is not None: @@ -78,7 +87,7 @@ def upbound_provider_config(*, space_host, org, provider_config_name, secret_nam config_name = f"{ctp}-ctp" if ctp else (f"{group}-group" if group else f"{prefix}-space") scope = "envCtp" if ctp else ("envGroup" if group else "space") secret_name = f"{config_name}-kubeconfig" - kubeconfig = kcl_str(upbound_kubeconfig(space_host, org, group or "default", ctp or "")) + kubeconfig = yaml.safe_dump(upbound_kubeconfig(space_host, org, group or "default", ctp or ""), sort_keys=False) return [ (f"{scope}Kubeconfig", k8s_object(secret_name, { # The API default, set explicitly: KCL materialised it. diff --git a/tests/test-environment-no-cloudprovider-resource/test/__main__.py b/tests/test-environment-no-cloudprovider-resource/test/__main__.py index 44cc87d..8b34a21 100644 --- a/tests/test-environment-no-cloudprovider-resource/test/__main__.py +++ b/tests/test-environment-no-cloudprovider-resource/test/__main__.py @@ -4,6 +4,49 @@ from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest +def kubeconfig(server: str, namespace: str) -> str: + """The kubeconfig the composition writes for provider-kubernetes, as YAML. + + Stated here as data rather than copied from the function, so the test pins what the + kubeconfig says: the server, the context namespace, and `up organization token` as the + credential plugin. + """ + return yaml.safe_dump({ + "apiVersion": "v1", + "clusters": [{"cluster": {"insecure-skip-tls-verify": True, "server": server}, "name": "upbound"}], + "contexts": [{ + "context": { + "cluster": "upbound", + "extensions": [{ + "extension": { + "apiVersion": "upbound.io/v1alpha1", + "kind": "SpaceExtension", + "spec": {"cloud": {"organization": "upbound"}}, + }, + "name": "spaces.upbound.io/space", + }], + "namespace": namespace, + "user": "upbound", + }, + "name": "upbound", + }], + "current-context": "upbound", + "kind": "Config", + "preferences": {}, + "users": [{ + "name": "upbound", + "user": {"exec": { + "apiVersion": "client.authentication.k8s.io/v1", + "args": ["organization", "token"], + "command": "up", + "env": [{"name": "ORGANIZATION", "value": "upbound"}, {"name": "UP_PROFILE", "value": "default"}], + "interactiveMode": "IfAvailable", + "provideClusterInfo": False, + }}, + }], + }, sort_keys=False) + + K8S_API = "kubernetes.m.crossplane.io/v1alpha1" UPBOUND_PROVIDER_CONFIG = "solutions-non-prod-default-example" COMPOSITE_LABEL = {"crossplane.io/composite": "example"} @@ -97,7 +140,7 @@ def _observed(kind: str, resource_name: str, name: str, spec: dict, api_version: assertResources=[ _kubeconfig_secret( "example-ctp-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s", "default"), ), { "apiVersion": K8S_API, @@ -120,7 +163,7 @@ def _observed(kind: str, resource_name: str, name: str, spec: dict, api_version: }, _kubeconfig_secret( "solutions-non-prod-default-example-group-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io", "solutions-non-prod-default-example"), ), _provider_config("example-ctp"), _provider_config("solutions-non-prod-default-example-group"), @@ -129,7 +172,7 @@ def _observed(kind: str, resource_name: str, name: str, spec: dict, api_version: _usage("example-ctp"), _kubeconfig_secret( "example-space-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io", "default"), ), _provider_config("example-space"), ], diff --git a/tests/test-environment/test/__main__.py b/tests/test-environment/test/__main__.py index 4475fbb..cbb20f9 100644 --- a/tests/test-environment/test/__main__.py +++ b/tests/test-environment/test/__main__.py @@ -5,6 +5,49 @@ from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest +def kubeconfig(server: str, namespace: str) -> str: + """The kubeconfig the composition writes for provider-kubernetes, as YAML. + + Stated here as data rather than copied from the function, so the test pins what the + kubeconfig says: the server, the context namespace, and `up organization token` as the + credential plugin. + """ + return yaml.safe_dump({ + "apiVersion": "v1", + "clusters": [{"cluster": {"insecure-skip-tls-verify": True, "server": server}, "name": "upbound"}], + "contexts": [{ + "context": { + "cluster": "upbound", + "extensions": [{ + "extension": { + "apiVersion": "upbound.io/v1alpha1", + "kind": "SpaceExtension", + "spec": {"cloud": {"organization": "upbound"}}, + }, + "name": "spaces.upbound.io/space", + }], + "namespace": namespace, + "user": "upbound", + }, + "name": "upbound", + }], + "current-context": "upbound", + "kind": "Config", + "preferences": {}, + "users": [{ + "name": "upbound", + "user": {"exec": { + "apiVersion": "client.authentication.k8s.io/v1", + "args": ["organization", "token"], + "command": "up", + "env": [{"name": "ORGANIZATION", "value": "upbound"}, {"name": "UP_PROFILE", "value": "default"}], + "interactiveMode": "IfAvailable", + "provideClusterInfo": False, + }}, + }], + }, sort_keys=False) + + ORCHESTRATE = ["Create", "Observe", "Update", "LateInitialize"] @@ -105,7 +148,7 @@ def usage(name: str, by: str) -> dict: assertResources=[ kubeconfig_object( "example-ctp-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io/apis/spaces.upbound.io/v1beta1/namespaces/solutions-non-prod-default-example/controlplanes/example/k8s", "default"), ), kubernetes_object( "example-ctp", @@ -125,7 +168,7 @@ def usage(name: str, by: str) -> dict: ), kubeconfig_object( "solutions-non-prod-default-example-group-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'solutions-non-prod-default-example', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io", "solutions-non-prod-default-example"), ), kubernetes_provider_config("example-ctp", "example-ctp-kubeconfig"), ### AWS ### @@ -225,7 +268,7 @@ def usage(name: str, by: str) -> dict: usage("example-ctp-kubeconfig", by="example-ctp"), kubeconfig_object( "example-space-kubeconfig", - "{'apiVersion': 'v1', 'clusters': [{'cluster': {'insecure-skip-tls-verify': True, 'server': 'https://upbound-aws-us-east-1.space.mxe.upbound.io'}, 'name': 'upbound'}], 'contexts': [{'context': {'cluster': 'upbound', 'extensions': [{'extension': {'apiVersion': 'upbound.io/v1alpha1', 'kind': 'SpaceExtension', 'spec': {'cloud': {'organization': 'upbound'}}}, 'name': 'spaces.upbound.io/space'}], 'namespace': 'default', 'user': 'upbound'}, 'name': 'upbound'}], 'current-context': 'upbound', 'kind': 'Config', 'preferences': {}, 'users': [{'name': 'upbound', 'user': {'exec': {'apiVersion': 'client.authentication.k8s.io/v1', 'args': [organization, token], 'command': 'up', 'env': [{'name': 'ORGANIZATION', 'value': 'upbound'}, {'name': 'UP_PROFILE', 'value': 'default'}], 'interactiveMode': 'IfAvailable', 'provideClusterInfo': False}}}]}", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io", "default"), ), kubernetes_object("example-observed-access-token-observed", OBSERVED_ACCESS_TOKEN_SPEC), kubernetes_object( From e3505c87c69562881ddcd3dad10d1a9d14b6f445 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Sep 2026 03:48:05 +0200 Subject: [PATCH 08/10] build: pin PyYAML and grpcio in the functions Since the kubeconfig Secrets are written with yaml.safe_dump, PyYAML decides their bytes. Left as pyyaml>=6.0, an emitter change in a new release would rewrite every kubeconfig Secret on the next function rebuild, with no source change. Pinned to 6.0.2, the version the tests build their expected kubeconfig with - the two must agree. grpcio is pinned to 1.84.0, which crossplane-function-sdk-python 0.15.1 already requires exactly; the pin makes that visible. --- functions/environments/pyproject.toml | 4 ++-- functions/sharedawssecret/pyproject.toml | 2 +- functions/upboundreposet/pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/functions/environments/pyproject.toml b/functions/environments/pyproject.toml index dbb77b5..c1b1188 100644 --- a/functions/environments/pyproject.toml +++ b/functions/environments/pyproject.toml @@ -11,8 +11,8 @@ license = "Apache-2.0" dependencies = [ "crossplane-function-sdk-python==0.15.1", "click==8.3.2", - "grpcio>=1.73.1", - "pyyaml>=6.0", + "grpcio==1.84.0", + "pyyaml==6.0.2", "crossplane-models @ file:./../../.up/python", ] dynamic = ["version"] diff --git a/functions/sharedawssecret/pyproject.toml b/functions/sharedawssecret/pyproject.toml index f59b85c..507e06c 100644 --- a/functions/sharedawssecret/pyproject.toml +++ b/functions/sharedawssecret/pyproject.toml @@ -11,7 +11,7 @@ license = "Apache-2.0" dependencies = [ "crossplane-function-sdk-python==0.15.1", "click==8.3.2", - "grpcio>=1.73.1", + "grpcio==1.84.0", "crossplane-models @ file:./../../.up/python", ] dynamic = ["version"] diff --git a/functions/upboundreposet/pyproject.toml b/functions/upboundreposet/pyproject.toml index f59b85c..507e06c 100644 --- a/functions/upboundreposet/pyproject.toml +++ b/functions/upboundreposet/pyproject.toml @@ -11,7 +11,7 @@ license = "Apache-2.0" dependencies = [ "crossplane-function-sdk-python==0.15.1", "click==8.3.2", - "grpcio>=1.73.1", + "grpcio==1.84.0", "crossplane-models @ file:./../../.up/python", ] dynamic = ["version"] From 10b0e8bd12d941a45a65da176c6edbedab72448c Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Sep 2026 03:48:05 +0200 Subject: [PATCH 09/10] docs: explain name-keyed resources precisely; describe each function The resources package docstring now says exactly why some composition keys are resource names: KCL's annotations = override operator replaced the annotation carrying the key, where annotations: (union) kept it. That is the only thing that explains two identical-looking ProviderConfigs keyed differently, and a reader should not take it for a bug to tidy. Replaces the generator's boilerplate function READMEs, and notes in the project README that the common/ symlinks need core.symlinks on Windows. --- README.md | 4 +++- functions/environments/README.md | 13 ++++++++++--- .../environments/function/resources/__init__.py | 11 +++++++++-- functions/sharedawssecret/README.md | 12 +++++++++--- functions/upboundreposet/README.md | 11 ++++++++--- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7a9445e..f790987 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,9 @@ for d in functions/*; do (cd "$d" && ../../.venv/bin/pip install -q -e .); done Code more than one function needs lives in `common/` at the project root, not in any one function. A function is packaged from its own directory alone, so each carries a `function/common` symlink to it, and `up` copies the symlink's target into the built function. -Import it as `from .common.naming import truncate_iam_name`. +Import it as `from .common.naming import truncate_iam_name`. On Windows, clone with +`git config core.symlinks true` (and Developer Mode or admin rights), or the symlinks check +out as plain text files. > Function directory names are the published package paths > (`xpkg.upbound.io//platform-ref-upbound_`) — renaming one publishes a new package. diff --git a/functions/environments/README.md b/functions/environments/README.md index 7bdbd95..f5ae41c 100644 --- a/functions/environments/README.md +++ b/functions/environments/README.md @@ -1,4 +1,11 @@ -# Composition Function +# environments -The Python `hatch` toolchain requires that projects have a README file. You may -fill in details about your composition function here. +Composition function for `Environment` (`sa.upbound.io/v1`): an Upbound group holding one +control plane, wired to AWS. + +- `function/fn.py` — initialisation from the bootstrap kubeconfig, then which resources apply +- `function/resources/` — one builder module per area: `kubernetes`, `argo`, `team_robot`, + `secret_sync`, `aws` +- `function/common` — symlink to the project's shared `common/` package + +Tests: `tests/test-environment*`. See the project README for how to build and run them. diff --git a/functions/environments/function/resources/__init__.py b/functions/environments/function/resources/__init__.py index b9670a0..88d5a07 100644 --- a/functions/environments/function/resources/__init__.py +++ b/functions/environments/function/resources/__init__.py @@ -6,8 +6,15 @@ Composition keys matter beyond this package. A changed key makes Crossplane delete the resource under the old one and create another, so every key here is the one the KCL version actually -produced - including the handful where KCL fell back to the resource's name because merging -metadata had replaced the annotation carrying the intended key. Those are marked. +produced - including the handful keyed by the resource's name instead. Those are marked. + +Why some resources are keyed by name: the KCL carried the intended key in an annotation, then +merged more metadata over it. Where that merge wrote `annotations = {...}` - KCL's override +operator - it replaced the whole annotations map, the key went with it, and function-kcl fell +back to the resource name. Where it wrote `annotations: {...}` - the union operator - the key +survived. That is the only reason two identical-looking ProviderConfigs are keyed differently: +upboundreposet's used `:` and is keyed `providerConfigUpbound`; environments' used `=` and is +keyed by its name. It is not a bug to tidy away - the keys are load-bearing. """ from ..common.policy import management_policies diff --git a/functions/sharedawssecret/README.md b/functions/sharedawssecret/README.md index 7bdbd95..f960f4f 100644 --- a/functions/sharedawssecret/README.md +++ b/functions/sharedawssecret/README.md @@ -1,4 +1,10 @@ -# Composition Function +# sharedawssecret -The Python `hatch` toolchain requires that projects have a README file. You may -fill in details about your composition function here. +Composition function for `SharedAWSSecret` (`sa.upbound.io/v1`): makes an AWS Secrets Manager +secret readable from an Upbound control plane, through an IAM user and access key, a +SharedSecretStore, and a SharedExternalSecret. + +- `function/fn.py` — the function +- `function/common` — symlink to the project's shared `common/` package + +Tests: `tests/test-sharedawssecret*`. See the project README for how to build and run them. diff --git a/functions/upboundreposet/README.md b/functions/upboundreposet/README.md index 7bdbd95..8f9d1ac 100644 --- a/functions/upboundreposet/README.md +++ b/functions/upboundreposet/README.md @@ -1,4 +1,9 @@ -# Composition Function +# upboundreposet -The Python `hatch` toolchain requires that projects have a README file. You may -fill in details about your composition function here. +Composition function for `UpboundRepoSet` (`sa.upbound.io/v1`): Upbound repositories, per-team +permissions on them, and the provider-upbound ProviderConfig they share. + +- `function/fn.py` — the function +- `function/common` — symlink to the project's shared `common/` package + +Tests: `tests/test-upboundreposet*`. See the project README for how to build and run them. From 498ee8eac0441b6665a296b46345b5774145853e Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Sep 2026 03:48:05 +0200 Subject: [PATCH 10/10] fix: address review findings, and lint Python in CI - sharedawssecret: an empty externalSecret.spec.data or target.template .data now means "not specified", as it did in the KCL version and does in Environment. Taken literally, data: [] replaced the default extract of the whole secret, and the SharedExternalSecret synced nothing. Covered by test-sharedawssecret-empty-external-secret-data. - environments: a bootstrap kubeconfig without a server URL or the Spaces extension yields no coordinates, so the Environment stays uninitialised, instead of failing every reconcile with a TypeError. Covered by test-environment-malformed-bootstrap-kubeconfig. - Lint: a ruff job in CI with the version pinned, and ruff.toml. Fixes what it reported: an unused variable, dict() calls where literals belong, unsorted imports. The generated main.py scaffolds are exempt from the two rules they break, and otherwise left as generated. --- .github/workflows/ruff.yaml | 14 ++ functions/environments/function/fn.py | 34 ++-- .../function/resources/kubernetes.py | 1 - functions/sharedawssecret/function/fn.py | 20 ++- functions/upboundreposet/function/fn.py | 1 - ruff.toml | 10 ++ .../test/__main__.py | 1 + .../test/__main__.py | 154 +++++++++++------- tests/test-environment/test/__main__.py | 1 + tests/test-sharedawssecret/test/__main__.py | 17 ++ 10 files changed, 171 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/ruff.yaml create mode 100644 ruff.toml diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 0000000..e8e4c2e --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,14 @@ +name: ruff +on: [pull_request] +jobs: + ruff: + name: runner / ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + # Pinned: ruff's default rule set changes between releases, and ruff.toml only + # adjusts it. Bump deliberately, and fix what the new version reports in the same change. + - name: Install ruff + run: python3 -m pip install --quiet ruff==0.16.9 + - name: ruff check + run: ruff check --output-format=github functions tests common diff --git a/functions/environments/function/fn.py b/functions/environments/function/fn.py index c0f7ee6..39ed185 100644 --- a/functions/environments/function/fn.py +++ b/functions/environments/function/fn.py @@ -23,7 +23,6 @@ from crossplane.function import logging, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 - from models.io.upbound.sa.environment import v1 as envv1 from models.io.upbound.sa.sharedawssecret import v1 as sasv1 @@ -45,16 +44,24 @@ def _observed(req: fnv1.RunFunctionRequest, key: str) -> dict: def parse_bootstrap_kubeconfig(encoded: str) -> dict: - """Extract the Space coordinates from the bootstrap control plane's kubeconfig.""" - kubeconfig = yaml.safe_load(base64.b64decode(encoded)) - cluster = (kubeconfig.get("clusters") or [{}])[0].get("cluster") or {} + """Extract the Space coordinates from the bootstrap control plane's kubeconfig. + + Takes only what is there. A kubeconfig without a server URL, or without the Spaces + extension naming the organization, yields no coordinates rather than an error, so the + Environment stays uninitialised and keeps waiting - a malformed Secret should not turn every + reconcile of the XR into a function failure. + """ + kubeconfig = yaml.safe_load(base64.b64decode(encoded)) or {} + cluster = dig((kubeconfig.get("clusters") or [{}])[0], "cluster") or {} + context = dig((kubeconfig.get("contexts") or [{}])[0], "context") or {} + extension = dig((context.get("extensions") or [{}])[0], "extension") or {} server = cluster.get("server") return { "serverCaData": cluster.get("certificate-authority-data"), - "spaceHost": SPACE_HOST_RE.sub(r"\1", server), - "bootstrapGroup": BOOTSTRAP_GROUP_RE.sub(r"\1", server), - "bootstrapCtp": BOOTSTRAP_CTP_RE.sub(r"\1", server), - "org": kubeconfig["contexts"][0]["context"]["extensions"][0]["extension"]["spec"]["cloud"]["organization"], + "spaceHost": SPACE_HOST_RE.sub(r"\1", server) if server else None, + "bootstrapGroup": BOOTSTRAP_GROUP_RE.sub(r"\1", server) if server else None, + "bootstrapCtp": BOOTSTRAP_CTP_RE.sub(r"\1", server) if server else None, + "org": dig(extension, "spec", "cloud", "organization"), } @@ -109,7 +116,7 @@ async def RunFunction( rsp = response.to(req) xr = envv1.Environment(**resource.struct_to_dict(req.observed.composite.resource)) - name, namespace = xr.metadata.name, xr.metadata.namespace + name = xr.metadata.name params = xr.spec.parameters up = params.upbound token_ref = {"name": up.tokenSecretRef.name, "namespace": up.tokenSecretRef.namespace, "key": up.tokenSecretRef.key} @@ -224,8 +231,13 @@ def _compose(self, req, xr, st, parsed, token_ref, desired) -> dict: space_host=st.spaceHost, ) - pc_common = dict(space_host=st.spaceHost, org=st.org, provider_config_name=bootstrap_pc, - secret_namespace=namespace, token_ref=token_ref) + pc_common = { + "space_host": st.spaceHost, + "org": st.org, + "provider_config_name": bootstrap_pc, + "secret_namespace": namespace, + "token_ref": token_ref, + } if up.createCtp: desired += r.upbound_provider_config(group=group, ctp=name, **pc_common) # Not gated on createGroup: the ControlPlane and the SharedAWSSecret both reach the diff --git a/functions/environments/function/resources/kubernetes.py b/functions/environments/function/resources/kubernetes.py index a089ea4..d06e136 100644 --- a/functions/environments/function/resources/kubernetes.py +++ b/functions/environments/function/resources/kubernetes.py @@ -3,7 +3,6 @@ import base64 import yaml - from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 diff --git a/functions/sharedawssecret/function/fn.py b/functions/sharedawssecret/function/fn.py index 222f4c7..01c7821 100644 --- a/functions/sharedawssecret/function/fn.py +++ b/functions/sharedawssecret/function/fn.py @@ -17,7 +17,6 @@ from crossplane.function import logging, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 - from models.io.crossplane.m.kubernetes.object import v1alpha1 as objectv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.m.aws.iam.accesskey import v1beta1 as accesskeyv1beta1 @@ -91,8 +90,11 @@ async def RunFunction( # so they reach the manifest exactly as written - no defaults added, nothing reordered. raw_ext = dig(raw, "spec", "parameters", "externalSecret") or {} secret_labels = dig(raw_ext, "spec", "target", "template", "metadata", "labels") or {} - secret_template_data = dig(raw_ext, "spec", "target", "template", "data") - secret_data = dig(raw_ext, "spec", "data") + # `or None`: an empty list or map means "not specified", as it did in the KCL version + # and does in Environment. Taken literally, `data: []` would replace the default + # extract of the whole secret with nothing, and the external secret would sync no keys. + secret_template_data = dig(raw_ext, "spec", "target", "template", "data") or None + secret_data = dig(raw_ext, "spec", "data") or None secret_namespace = raw_ext.get("namespace") or "default" external_secret_name = raw_ext.get("name") or ctp @@ -171,11 +173,11 @@ async def RunFunction( metadata=k8s.ObjectMeta(name=key_secret_name), spec=_object_spec( managementPolicies=mgmt, - forProvider=dict(manifest={ + forProvider={"manifest": { "apiVersion": "v1", "kind": "Secret", "metadata": {"name": key_secret_name, "namespace": group}, - }), + }}, providerConfigRef=upbound_pc, references=[objectv1alpha1.Reference( patchesFrom=objectv1alpha1.PatchesFrom( @@ -231,7 +233,7 @@ async def RunFunction( metadata=k8s.ObjectMeta(name=f"{ctp}-sss"), spec=_object_spec( managementPolicies=mgmt, - forProvider=dict(manifest={ + forProvider={"manifest": { "apiVersion": "spaces.upbound.io/v1alpha1", "kind": "SharedSecretStore", "metadata": {"name": ctp, "namespace": group}, @@ -247,7 +249,7 @@ async def RunFunction( }}, }}, }, - }), + }}, providerConfigRef=upbound_pc, ), ), @@ -279,7 +281,7 @@ async def RunFunction( metadata=k8s.ObjectMeta(name=f"{ctp}-ses"), spec=_object_spec( managementPolicies=mgmt, - forProvider=dict(manifest={ + forProvider={"manifest": { "apiVersion": "spaces.upbound.io/v1alpha1", "kind": "SharedExternalSecret", "metadata": {"name": external_secret_name, "namespace": group}, @@ -288,7 +290,7 @@ async def RunFunction( "namespaceSelector": {"names": [secret_namespace]}, "externalSecretSpec": external_secret_spec, }, - }), + }}, providerConfigRef=upbound_pc, ), ), diff --git a/functions/upboundreposet/function/fn.py b/functions/upboundreposet/function/fn.py index 12014d3..373aa2e 100644 --- a/functions/upboundreposet/function/fn.py +++ b/functions/upboundreposet/function/fn.py @@ -12,7 +12,6 @@ from crossplane.function import logging, resource, response from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 - from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.m.providerconfig import v1alpha1 as pcv1alpha1 from models.io.upbound.m.repository import v1alpha1 as repov1alpha1 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..e670e96 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,10 @@ +# Lint for the composition functions, the tests, and common/. CI pins the ruff version, so the +# rule set cannot drift under a release that changes its defaults. +target-version = "py313" +line-length = 120 +extend-exclude = [".up", "_output"] + +[lint.per-file-ignores] +# main.py is the `up function generate` scaffold, left as generated: it catches Exception +# around server startup and carries a noqa for a rule this config does not enable. +"functions/*/function/main.py" = ["BLE001", "RUF100"] diff --git a/tests/test-environment-no-cloudprovider-resource/test/__main__.py b/tests/test-environment-no-cloudprovider-resource/test/__main__.py index 8b34a21..a267e6e 100644 --- a/tests/test-environment-no-cloudprovider-resource/test/__main__.py +++ b/tests/test-environment-no-cloudprovider-resource/test/__main__.py @@ -4,6 +4,7 @@ from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + def kubeconfig(server: str, namespace: str) -> str: """The kubeconfig the composition writes for provider-kubernetes, as YAML. diff --git a/tests/test-environment-uninitialized/test/__main__.py b/tests/test-environment-uninitialized/test/__main__.py index cd78c97..0f3d1d7 100644 --- a/tests/test-environment-uninitialized/test/__main__.py +++ b/tests/test-environment-uninitialized/test/__main__.py @@ -1,71 +1,105 @@ +import base64 + import yaml from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest -# The first reconcile of any Environment happens before status.upbound exists. The function -# must emit only the bootstrap-kubeconfig observer and wait, rather than aborting the -# pipeline. Nothing else covers this: every other suite supplies a populated status, either -# in its example or inline, so the uninitialised branch was never rendered. -test = compositiontest.CompositionTest( - metadata=k8s.ObjectMeta(name="test-environment-uninitialized"), - spec=compositiontest.Spec( - assertResources=[ - { - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "metadata": {"name": "fresh-bootstrap-ctp-kubeconfig-observed"}, - "spec": { - "forProvider": { - "deletionPropagationPolicy": "Background", # Object schema default - "manifest": { - "apiVersion": "v1", - "kind": "Secret", - "metadata": {"name": "bootstrap-kubeconfig", "namespace": "default"}, - }, - }, - "managementPolicies": ["Observe"], - "providerConfigRef": {"kind": "ProviderConfig", "name": "bootstrap-ctp"}, - "watch": False, # Object schema default - }, +OBSERVER = { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": {"name": "fresh-bootstrap-ctp-kubeconfig-observed"}, + "spec": { + "forProvider": { + "deletionPropagationPolicy": "Background", # Object schema default + "manifest": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "bootstrap-kubeconfig", "namespace": "default"}, }, - ], - compositionPath="apis/environments/composition.yaml", - xrdPath="apis/environments/definition.yaml", - xr={ - "apiVersion": "sa.upbound.io/v1", - "kind": "Environment", - "metadata": {"name": "fresh", "namespace": "default"}, - # `upbound = None`, not an absent status: this is the exact shape a brand new - # XR has on a live control plane, and it is what distinguishes a correct guard - # from one written against Undefined. - "status": {"upbound": None}, - "spec": { - "parameters": { - # Environment schema defaults. - "deletionPolicy": "Orphan", - "upbound": { - "createArgoSecret": True, - "createCtp": True, - "createGroup": True, - "initKubeconfigSecretRef": { - "key": "kubeconfig", # schema default - "name": "bootstrap-kubeconfig", - "namespace": "default", - }, - "initProviderConfigName": "bootstrap-ctp", # schema default - "tokenSecretRef": { - "key": "token", # schema default - "name": "bootstrap-token", - "namespace": "default", - }, - }, + }, + "managementPolicies": ["Observe"], + "providerConfigRef": {"kind": "ProviderConfig", "name": "bootstrap-ctp"}, + "watch": False, # Object schema default + }, +} + +XR = { + "apiVersion": "sa.upbound.io/v1", + "kind": "Environment", + "metadata": {"name": "fresh", "namespace": "default"}, + # `upbound = None`, not an absent status: this is the exact shape a brand new + # XR has on a live control plane, and it is what distinguishes a correct guard + # from one written against Undefined. + "status": {"upbound": None}, + "spec": { + "parameters": { + # Environment schema defaults. + "deletionPolicy": "Orphan", + "upbound": { + "createArgoSecret": True, + "createCtp": True, + "createGroup": True, + "initKubeconfigSecretRef": { + "key": "kubeconfig", # schema default + "name": "bootstrap-kubeconfig", + "namespace": "default", + }, + "initProviderConfigName": "bootstrap-ctp", # schema default + "tokenSecretRef": { + "key": "token", # schema default + "name": "bootstrap-token", + "namespace": "default", }, }, }, - timeoutSeconds=60, - validate=False, + }, +} + + +def composition_test(name: str, **spec) -> compositiontest.CompositionTest: + return compositiontest.CompositionTest( + metadata=k8s.ObjectMeta(name=name), + spec=compositiontest.Spec( + assertResources=[OBSERVER], + compositionPath="apis/environments/composition.yaml", + xrdPath="apis/environments/definition.yaml", + xr=XR, + timeoutSeconds=60, + validate=False, + **spec, + ), + ) + + +tests = [ + # The first reconcile of any Environment happens before status.upbound exists. The function + # must emit only the bootstrap-kubeconfig observer and wait, rather than aborting the + # pipeline. Nothing else covers this: every other suite supplies a populated status, either + # in its example or inline, so the uninitialised branch was never rendered. + composition_test("test-environment-uninitialized"), + # The bootstrap kubeconfig has been observed, but it carries neither a server URL nor the + # Spaces extension naming the organization - hand-written, truncated, or for the wrong kind + # of cluster. The coordinates cannot be derived from it, so the Environment stays + # uninitialised and keeps waiting, exactly as before the Secret existed. It must not turn + # into a function error that fails every reconcile of the XR. + composition_test( + "test-environment-malformed-bootstrap-kubeconfig", + observedResources=[{ + **OBSERVER, + "metadata": { + **OBSERVER["metadata"], + "namespace": "default", + "annotations": {"crossplane.io/composition-resource-name": "observedCtpKubeconfig"}, + }, + "status": {"atProvider": {"manifest": {"data": {"kubeconfig": base64.b64encode(yaml.safe_dump({ + "apiVersion": "v1", + "kind": "Config", + "clusters": [{"name": "bootstrap", "cluster": {}}], + "contexts": [{"name": "bootstrap", "context": {"cluster": "bootstrap"}}], + }).encode()).decode()}}}}, + }], ), -) +] # The test runner expects an "items" array, one entry per test. -print(yaml.dump({"items": [test.model_dump(by_alias=True, exclude_none=True)]})) +print(yaml.dump({"items": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) diff --git a/tests/test-environment/test/__main__.py b/tests/test-environment/test/__main__.py index cbb20f9..9b429d4 100644 --- a/tests/test-environment/test/__main__.py +++ b/tests/test-environment/test/__main__.py @@ -5,6 +5,7 @@ from models.io.k8s.apimachinery.pkg.apis.meta import v1 as k8s from models.io.upbound.dev.meta.compositiontest import v1alpha1 as compositiontest + def kubeconfig(server: str, namespace: str) -> str: """The kubeconfig the composition writes for provider-kubernetes, as YAML. diff --git a/tests/test-sharedawssecret/test/__main__.py b/tests/test-sharedawssecret/test/__main__.py index b4f43f9..0920815 100644 --- a/tests/test-sharedawssecret/test/__main__.py +++ b/tests/test-sharedawssecret/test/__main__.py @@ -255,6 +255,11 @@ def shared_aws_secret(deletion_policy: str, secrets_manager_secret: dict | None } +def _with_external_secret(xr: dict, external_secret: dict) -> dict: + xr["spec"]["parameters"]["externalSecret"] = external_secret + return xr + + def composition_test(name: str, assert_resources: list[dict], **spec) -> compositiontest.CompositionTest: return compositiontest.CompositionTest( metadata=k8s.ObjectMeta(name=name), @@ -339,6 +344,18 @@ def composition_test(name: str, assert_resources: list[dict], **spec) -> composi # secretsManagerSecret deliberately omitted xr=shared_aws_secret("Orphan"), ), + # An empty externalSecret.spec.data means "nothing specified", not "sync no keys". It + # has to fall back to extracting the whole secret, as an absent one does - taking it at + # face value renders `data: []`, and the SharedExternalSecret then syncs nothing at all. + # (The same holds for an empty template.data, whose absence partial matching cannot assert.) + composition_test( + "test-sharedawssecret-empty-external-secret-data", + [SHARED_EXTERNAL_SECRET_DEFAULT], + xr=_with_external_secret( + shared_aws_secret("Orphan"), + {"spec": {"data": [], "target": {"template": {"data": {}}}}}, + ), + ), ] # The test runner expects an "items" array, one entry per test.