Rewrite functions and tests in Python - #38
Conversation
bf4d8ca to
59631f4
Compare
e2e.yaml runs on pull_request_target, so a pull request is tested with main's copy of this workflow, not its own. #38 moves the composition functions to Python and needs UP_MAX_CONCURRENCY=1 here: every Python function build mounts the same pip-cache Docker volume, and on a fresh runner concurrent builds fail creating it with "mkdir ...: file exists". Its e2e hit that on 6 of 6 attempts. It cannot pick the fix up from its own branch, so it has to land here first. No effect on the current KCL functions, which have no such build step.
The teamWithRobot path - Team, Robot, Token, membership and the group admin binding - had no test at all, and it is what solutions-gitops-prod's ci and production-upbound-deploy Environments run. Two cases: a new Team whose Upbound ID the binding reads off the observed Team, and an adopted Team by external name inside a group someone else manages. Written and passing against the KCL function, so that its output is part of the baseline the Python port is compared against.
Ports environments, sharedawssecret and upboundreposet from KCL to the Python function SDK, in the SDK layout `up function generate` produces, on crossplane-function-sdk-python 0.15.1. Function directory names are unchanged, so the published packages keep their paths. The port reproduces the KCL functions' rendered output, not just what the tests assert: every composition test's full render was diffed resource by resource and field by field against the KCL baseline. Across 20 tests two resources differ, both deliberately: - the ControlPlane manifest no longer carries a `foo` annotation holding KCL's own rendering of the whole spec - a debug leftover with no Python equivalent - a Secrets Manager secret with no secretsManagerSecret block no longer sets `recoveryWindowInDays: null`; KCL's `None != Undefined` let it through, where the intent was to omit it Getting to parity meant reproducing KCL behaviour Python does not have: defaults KCL's typed models materialise into the output, KCL's str() format for the kubeconfig Secrets, and the composition keys KCL actually produced where merging metadata had replaced the annotation carrying the intended one. A changed key would make Crossplane replace the resource. Also preserved, and worth a separate look: the Repository external-name annotation is misspelled `crosslane.io/external-name`.
Every Python function build mounts the same up-python-sdk-pip-cache Docker volume. On a fresh runner that volume starts empty, and concurrent builds race creating its directories: "mkdir ...: file exists". Reproduced locally by removing the volume first - one of two builds failed at the default concurrency, three of three passed at UP_MAX_CONCURRENCY=1. e2e.yaml runs on pull_request_target, so its copy takes effect only once this reaches main.
Converts all twelve test modules - eleven composition suites and the e2e
test - from KCL to the Python SDK layout (`test/__main__.py`, printing
the CompositionTest or E2ETest as YAML).
Each Python module generates a manifest identical to the one its KCL
module generated: every assertion, input, observed resource and setting,
compared field by field. That includes values KCL's typed models had
been materialising without the source ever writing them - Object
defaults, XRD defaults on inline XRs, `spec.parameters: {}` - which are
assertions the suite really made, so they are now spelled out.
Run against the Python functions, all 20 composition tests pass, and
the full render matches the original KCL-functions-and-KCL-tests
baseline except for the two deviations recorded in the function rewrite.
The README's development section now describes the Python toolchain.
59631f4 to
fde8cb6
Compare
Repositories carried `crosslane.io/external-name`, a misspelling that Crossplane ignores. provider-upbound looks a Repository up by its external name, so with none set Crossplane defaulted it to the generated metadata.name, the first Observe found nothing, and the provider only recovered by running Create - an upsert on forProvider.name - which then rewrote the external name to the repository name. With the key spelled correctly, the external name is the repository name from the start and an existing repository is observed directly. Resources already reconciled have that external name, so this changes nothing for them.
|
The port kept the logic split into separate builder functions, but the This is purely a move, so the existing composition tests should pass unchanged. Related: |
environments' resources.py had grown to hold every area of the composition behind comment headers. It is now a resources/ package with one module per area, mirroring the KCL modules it replaced: kubernetes (Objects, kubeconfigs, ProviderConfigs), argo, team_robot, secret_sync, aws, and util. The helpers duplicated across functions - ORPHAN, management_policies, the IAM name truncation and its hash, dig, and the Object defaults kept for KCL parity - now live once, in common/ at the project root. A function is packaged from its own directory only, so each carries a function/common symlink; up follows symlinks when it packages a function's source, so every built function gets its own copy. A pure move: all 20 composition tests pass, and the full render of every test is identical to before the change.
|
Both done in 74e3cbd.
CI is green on 74e3cbd, e2e included, so the symlinked package builds on a clean checkout too. |
The kubeconfig Secrets held KCL's str() rendering of a dict - single quotes, True/False, unquoted strings in lists - which the Python port reproduced byte for byte through a KCL-format serializer. It only worked because that text happens to parse as a YAML flow mapping. Write real YAML with yaml.safe_dump instead, and drop compat.py: the serializer was all it held besides object_spec, which now lives with k8s_object, its only caller. The kubeconfig is the same; only its bytes change. The tests now state the expected kubeconfig as data rather than as a KCL-format string. Of the full render of every test, the kubeconfig data in the three kubeconfig Secret Objects is the only thing that changed.
jboero
left a comment
There was a problem hiding this comment.
Reviewed the whole PR against the KCL it replaces — all three functions, the shared common/ package, and the original modules on main.
Review assisted by Claude (Claude Code). I read and verified the findings below myself before posting.
Verdict
High-quality port. The riskiest thing about a KCL→Python rewrite of a composition is silently changing a composition resource key, because Crossplane then deletes and recreates the resource. I checked every key independently and they are all correct. Two minor findings below, the rest are nits. Nothing I'd consider blocking.
What I verified
KCL's utils._metadata(name) carries the key in an annotation, and whether a later metadata merge keeps or drops it depends on the entry operator in the right operand (= override vs : union), not on | itself:
| Resource | KCL right-hand annotations op |
Key KCL produced | Python key |
|---|---|---|---|
upboundreposet Repository |
annotations: (union) |
{org}-{repo} |
f"{org}-{repo}" ✅ |
upboundreposet ProviderConfig |
annotations: |
providerConfigUpbound |
"providerConfigUpbound" ✅ |
environments AWS ProviderConfig |
annotations = (override) |
resource name | env_name ✅ |
teamRobot ProviderConfig |
annotations = |
resource name | f"{group}-upbound" ✅ |
pKubernetesHelper ProviderConfig |
annotations = |
resource name | config_name ✅ |
crossplaneRole OIDC provider |
annotations = |
resource name | oidc_name ✅ |
sharedawssecret SM Secret |
annotations = |
resource name | f"{aws_secret_name}-secretsmanager-secret" ✅ |
teamRobot Team |
annotations: |
envTeam — both with and without teamExternalName |
"envTeam" ✅ |
The KCL-era tests confirm this independently in three places (test-upboundreposet/main.k:23,48, test-environment/main.k:200), including the case where the same-looking block in two different functions yields different keys. That's an easy thing to get wrong and it's right here.
The two deliberate behaviour changes also check out: the crosslane.io typo was real (upboundreposet/main.k:40), and the recoveryWindowInDays: null really was KCL's None != Undefined quirk leaking through.
Minor findings
1. (minor) sharedawssecret/function/fn.py:94-95 — presence vs. truthiness, inconsistent with the KCL and with its sibling function
secret_template_data = dig(raw_ext, "spec", "target", "template", "data")
secret_data = dig(raw_ext, "spec", "data")Both are gated on is not None later (lines 260, 267). The KCL used ... or Undefined, so an explicitly empty data: [] fell through to the dataFrom.extract branch. Here [] takes the data branch and emits data: [], and the SharedExternalSecret syncs nothing. Same for template.data: {}.
Worth noting environments/function/fn.py::_external_secret_spec uses plain truthiness (if spec.data:, if template.data:) for the same fields, so the two functions in this PR disagree with each other. Only reachable for a hand-written SharedAWSSecret XR, so the blast radius is small — or None on both lines would restore parity and internal consistency.
2. (minor) pyyaml is unpinned in the functions' pyproject.toml (pyyaml>=6.0), and the kubeconfig Secret's bytes now come from yaml.safe_dump
The tests pin pyyaml==6.0.2 exactly; the functions don't. Since c8772e7, a PyYAML emitter change would alter the base64 payload of the three kubeconfig Secrets on the next function rebuild — a live diff with no source change. That argument didn't exist before this commit; it does now. Suggest pinning pyyaml (and probably grpcio) to match the tests.
Nits
- The
=vs:explanation is slightly imprecise.resources/__init__.pysays the key was lost "because merging metadata had replaced the annotation". It's specifically the=override operator on theannotationsentry. Worth stating precisely, because it's the only thing that explains whyupboundreposetkeys its ProviderConfigproviderConfigUpboundwhileenvironmentskeys the identical-looking one by name — a future reader will otherwise read that as a bug. - No Python linting in CI. ~1,500 lines of new Python, and
# noqa:FBT001in the scaffolding implies ruff ran locally, but onlyyamllint(scoped toapis/) runs in CI. Aruff checkstep would be cheap insurance. environments/function/fn.py:47parse_bootstrap_kubeconfigraisesTypeErrorifclusters[0].cluster.serveris absent (re.subonNone), where KCL's?.degraded toUndefined. Narrow, but it turns a malformed kubeconfig from "environment doesn't initialise" into "function errors".main.pyswallows startup failures (except Exception: click.echo(...), exit 0). That'sup function generatescaffolding and identical in all three, so probably leave it — just noting that a broken function container exits cleanly instead of crash-looping.UP_MAX_CONCURRENCY=1is well justified and correctly flagged as temporary, but there's no link to anupissue. A tracking link in the comment would make it removable later rather than permanent.functions/*/README.mdare still the generator boilerplate ("You may fill in details…").- The
function/commonsymlinks work in CI, but will break on a Windows checkout withoutcore.symlinks. Probably fine for this repo.
Since the kubeconfig Secrets are written with yaml.safe_dump, PyYAML decides their bytes. Left as pyyaml>=6.0, an emitter change in a new release would rewrite every kubeconfig Secret on the next function rebuild, with no source change. Pinned to 6.0.2, the version the tests build their expected kubeconfig with - the two must agree. grpcio is pinned to 1.84.0, which crossplane-function-sdk-python 0.15.1 already requires exactly; the pin makes that visible.
The resources package docstring now says exactly why some composition keys are resource names: KCL's annotations = override operator replaced the annotation carrying the key, where annotations: (union) kept it. That is the only thing that explains two identical-looking ProviderConfigs keyed differently, and a reader should not take it for a bug to tidy. Replaces the generator's boilerplate function READMEs, and notes in the project README that the common/ symlinks need core.symlinks on Windows.
- sharedawssecret: an empty externalSecret.spec.data or target.template .data now means "not specified", as it did in the KCL version and does in Environment. Taken literally, data: [] replaced the default extract of the whole secret, and the SharedExternalSecret synced nothing. Covered by test-sharedawssecret-empty-external-secret-data. - environments: a bootstrap kubeconfig without a server URL or the Spaces extension yields no coordinates, so the Environment stays uninitialised, instead of failing every reconcile with a TypeError. Covered by test-environment-malformed-bootstrap-kubeconfig. - Lint: a ruff job in CI with the version pinned, and ruff.toml. Fixes what it reported: an unused variable, dict() calls where literals belong, unsorted imports. The generated main.py scaffolds are exempt from the two rules they break, and otherwise left as generated.
|
Thanks, this was a careful review. Addressed in e3505c8, 10b0e8b and 498ee8e: 1. Empty 2. PyYAML pin: done. It's pinned to 6.0.2, the version the tests build their expected kubeconfig with, since the two have to agree. The kubeconfig byte assertions still pass, so the pin didn't move the bytes. Nits:
|
|
Dry-run against production inputs: Python matches KCL. I rendered every XR that runs in production through both main's KCL functions (
From a behaviour point of view, I see nothing blocking this PR. The same run turned up v1→v2 migration issues that are already on main, independent of this PR. I'll raise those separately. |
kaessert
left a comment
There was a problem hiding this comment.
Great job mate! LGTM ✅
Rewrites the three composition functions and all twelve test modules from KCL to Python, on
crossplane-function-sdk-python0.15.1, in the SDK layoutup function generateproduces. Function directory names are unchanged, so the published packages keep their paths.Behaviour is unchanged, and that is checked, not assumed
Test assertions are partial, so a green suite alone doesn't show a rewrite preserved behaviour. Two stronger checks:
fooannotation holding KCL's own rendering of the whole spec, a debug leftover with no Python equivalentsecretsManagerSecretblock no longer setsrecoveryWindowInDays: null, a KCLNone != UndefinedbugCompositionTest/E2ETestmanifest identical to the one its KCL module generated. That includes values KCL's typed models were materialising without the source writing them — Object defaults, XRD defaults on inline XRs,spec.parameters: {}— which are assertions the suite really made, so they are now spelled out.Reaching parity meant reproducing KCL behaviour Python lacks:
Intentional changes beyond the port
Each is its own commit, on top of the parity-verified port.
The kubeconfig Secrets are real YAML
The kubeconfig Secrets held KCL's
str()rendering of a dict: single quotes,True/False, unquoted strings in lists. It only worked because that text happens to parse as a YAML flow mapping, and the port first reproduced it byte for byte through a KCL-format serializer.yaml.safe_dump, and that serializer is gone.Repository external names
Repositories carried the misspelled
crosslane.io/external-name, which Crossplane ignores. The port first reproduced it for parity, and a separate commit corrects it:metadata.name, so the first Observe missed and the provider only recovered by running Create, an upsert that then rewrote the external name.Commits
test: cover teamWithRobot—teamWithRobothad no test. Written and passing against the KCL functions, so it is part of the baseline.refactor: rewrite composition functions in Pythonci: build functions one at a time— setsUP_MAX_CONCURRENCY=1inci.yamlandcomposition-tests.yaml. ci(e2e): build functions one at a time #39 made the same change toe2e.yamlon main.test: rewrite tests in Pythonfix: set Repository external names under the right annotation keyrefactor: restore module boundaries and share common helpers—environments' resources are aresources/package again, one module per area; helpers duplicated across functions live once incommon/, symlinked into each function. A pure move: every test's full render is unchanged.refactor: write the kubeconfig Secrets as YAMLbuild: pin PyYAML and grpcio in the functions— PyYAML now decides the kubeconfig Secrets' bytes, so it's pinned to the version the tests use.docs: explain name-keyed resources precisely; describe each functionfix: address review findings, and lint Python in CI— an emptyexternalSecretdata/template.datameans "not specified" again; a malformed bootstrap kubeconfig leaves the Environment uninitialised instead of failing; a pinned ruff job in CI.Why builds are serialised
Every Python function build mounts one shared pip-cache Docker volume,
up-python-sdk-pip-cache. On a fresh runner, concurrent builds fail withmkdir ...: file exists:up's Python builder (internal/xpkg/functions/python_sdk.go) mounts the volume withoutNoCopy, over a 38 MB pip cache pre-seeded in the build image.UP_MAX_CONCURRENCY=1fixes it.This belongs upstream in
up. The setting can go once it's fixed there.Testing
498ee8e.