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 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/.gitignore b/.gitignore index 259b303..f9f4fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ _output .venv .up .agents +.vscode +__pycache__ diff --git a/README.md b/README.md index e1354a6..f790987 100644 --- a/README.md +++ b/README.md @@ -505,12 +505,44 @@ 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 +``` + +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`. 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. + +> 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/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/README.md b/functions/environments/README.md new file mode 100644 index 0000000..f5ae41c --- /dev/null +++ b/functions/environments/README.md @@ -0,0 +1,11 @@ +# environments + +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/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/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/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/fn.py b/functions/environments/function/fn.py new file mode 100644 index 0000000..39ed185 --- /dev/null +++ b/functions/environments/function/fn.py @@ -0,0 +1,353 @@ +"""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 +from .common.dicts import dig + +# 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 _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. + + 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) 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"), + } + + +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 = xr.metadata.name + 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 = { + "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/__init__.py b/functions/environments/function/resources/__init__.py new file mode 100644 index 0000000..88d5a07 --- /dev/null +++ b/functions/environments/function/resources/__init__.py @@ -0,0 +1,39 @@ +"""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 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 +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..d06e136 --- /dev/null +++ b/functions/environments/function/resources/kubernetes.py @@ -0,0 +1,138 @@ +"""provider-kubernetes Objects, kubeconfigs and ProviderConfigs for Upbound Spaces.""" + +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 ..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: + 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 = 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. + "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/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/model b/functions/environments/model deleted file mode 120000 index faff6e4..0000000 --- a/functions/environments/model +++ /dev/null @@ -1 +0,0 @@ -../../.up/kcl/models \ No newline at end of file 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/pyproject.toml b/functions/environments/pyproject.toml new file mode 100644 index 0000000..c1b1188 --- /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.84.0", + "pyyaml==6.0.2", + "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/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/README.md b/functions/sharedawssecret/README.md new file mode 100644 index 0000000..f960f4f --- /dev/null +++ b/functions/sharedawssecret/README.md @@ -0,0 +1,10 @@ +# sharedawssecret + +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/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/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 new file mode 100644 index 0000000..01c7821 --- /dev/null +++ b/functions/sharedawssecret/function/fn.py @@ -0,0 +1,300 @@ +"""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 + +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. 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"} + + +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_FOR_PROVIDER_DEFAULTS, **kwargs["forProvider"]) + return objectv1alpha1.Spec(**OBJECT_SPEC_DEFAULTS, **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 = 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 + + 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 {} + # `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 + + # --- 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={"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={"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={"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/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/sharedawssecret/pyproject.toml b/functions/sharedawssecret/pyproject.toml new file mode 100644 index 0000000..507e06c --- /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.84.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/upboundreposet/README.md b/functions/upboundreposet/README.md new file mode 100644 index 0000000..8f9d1ac --- /dev/null +++ b/functions/upboundreposet/README.md @@ -0,0 +1,9 @@ +# upboundreposet + +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. 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/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 new file mode 100644 index 0000000..373aa2e --- /dev/null +++ b/functions/upboundreposet/function/fn.py @@ -0,0 +1,120 @@ +"""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 + +from .common.policy import ORPHAN + + +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( + # 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( + # Orphan on delete: a repository outlives the UpboundRepoSet. + 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/kcl.mod b/functions/upboundreposet/kcl.mod deleted file mode 100644 index 994c00a..0000000 --- a/functions/upboundreposet/kcl.mod +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "upboundreposet" -version = "0.0.1" - -[dependencies] -models = { path = "./model" } diff --git a/functions/upboundreposet/kcl.mod.lock b/functions/upboundreposet/kcl.mod.lock deleted file mode 100644 index 2063f93..0000000 --- a/functions/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/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/pyproject.toml b/functions/upboundreposet/pyproject.toml new file mode 100644 index 0000000..507e06c --- /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.84.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/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/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/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..a267e6e --- /dev/null +++ b/tests/test-environment-no-cloudprovider-resource/test/__main__.py @@ -0,0 +1,312 @@ +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 + + +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"} +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", + 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, + "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", + 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"), + _usage("example-space"), + _usage("solutions-non-prod-default-example-group"), + _usage("example-ctp"), + _kubeconfig_secret( + "example-space-kubeconfig", + kubeconfig("https://upbound-aws-us-east-1.space.mxe.upbound.io", "default"), + ), + _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/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..0f3d1d7 --- /dev/null +++ b/tests/test-environment-uninitialized/test/__main__.py @@ -0,0 +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 + +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"}, + }, + }, + "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", + }, + }, + }, + }, +} + + +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": [t.model_dump(by_alias=True, exclude_none=True) for t in tests]})) 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..9b429d4 --- /dev/null +++ b/tests/test-environment/test/__main__.py @@ -0,0 +1,542 @@ +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 + + +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"] + + +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", + 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", + { + "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", + 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 ### + { + "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", + 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( + "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..0920815 --- /dev/null +++ b/tests/test-sharedawssecret/test/__main__.py @@ -0,0 +1,362 @@ +"""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 _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), + 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"), + ), + # 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. +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..59d5ec5 --- /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": { + "crossplane.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..94c00d1 --- /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": { + "crossplane.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)]}))