Skip to content

Rewrite functions and tests in Python - #38

Merged
ytsarev merged 10 commits into
mainfrom
python-rewrite
Sep 30, 2026
Merged

ytsarev merged 10 commits into
mainfrom
python-rewrite

Conversation

@ytsarev

@ytsarev ytsarev commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Rewrites the three composition functions and all twelve test modules from KCL to Python, on crossplane-function-sdk-python 0.15.1, in the SDK layout up function generate produces. 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:

  • Functions: before the rewrite I saved the full rendered output of every composition test from the KCL version, then diffed the Python version against it, resource by resource and field by field. Across 20 tests, two resources differ, both on purpose:
    • 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, a KCL None != Undefined bug
  • Tests: each Python test module generates a CompositionTest/E2ETest manifest 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:

  • materialised defaults
  • the composition keys KCL actually produced where a metadata merge had replaced the intended one. A changed key would make Crossplane replace the resource.

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.

  • Now: they're written with yaml.safe_dump, and that serializer is gone.
  • What changed: the kubeconfig is the same; only its bytes change. In the full render of every test, the kubeconfig data in the three kubeconfig Secret Objects is the only difference.
  • Proven live: the e2e passes on it. provider-kubernetes reaches the environment's group and control plane through these kubeconfigs, so the Environment could not reach Ready otherwise.

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:

  • Before: provider-upbound looks a Repository up by its external name. With none set, Crossplane defaulted it to the generated metadata.name, so the first Observe missed and the provider only recovered by running Create, an upsert that then rewrote the external name.
  • After: the external name is the repository name from the start, so an existing repository is observed directly.
  • Existing resources: already-reconciled Repositories have the correct external name, so nothing changes for them.

Commits

  • test: cover teamWithRobot — teamWithRobot had no test. Written and passing against the KCL functions, so it is part of the baseline.
  • refactor: rewrite composition functions in Python
  • ci: build functions one at a time — sets UP_MAX_CONCURRENCY=1 in ci.yaml and composition-tests.yaml. ci(e2e): build functions one at a time #39 made the same change to e2e.yaml on main.
  • test: rewrite tests in Python
  • fix: set Repository external names under the right annotation key
  • refactor: restore module boundaries and share common helpers — environments' resources are a resources/ package again, one module per area; helpers duplicated across functions live once in common/, symlinked into each function. A pure move: every test's full render is unchanged.
  • refactor: write the kubeconfig Secrets as YAML
  • build: 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 function
  • fix: address review findings, and lint Python in CI — an empty externalSecret data/template.data means "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 with mkdir ...: file exists:

  • Where it comes from: up's Python builder (internal/xpkg/functions/python_sdk.go) mounts the volume without NoCopy, over a 38 MB pip cache pre-seeded in the build image.
  • How often: it failed 6 of 6 attempts on CI; UP_MAX_CONCURRENCY=1 fixes it.
  • What isn't known: raw Docker didn't reproduce it, so the exact trigger isn't pinned down.

This belongs upstream in up. The setting can go once it's fixed there.

Testing

  • Composition tests: 22/22, locally and in CI.
  • E2E: passed on the rebased branch, the first live run of the Python functions against AWS and Upbound Spaces.
    • The Ready assertion kept polling until the environment was actually up.
    • Teardown left the e2e group empty.
  • Latest commit: ruff, yamllint, composition tests and e2e all pass on 498ee8e.

Base automatically changed from migrate-to-v2 to main September 25, 2026 18:31
ytsarev added a commit that referenced this pull request Sep 29, 2026
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.
@ytsarev
ytsarev requested a review from a team September 29, 2026 12:57
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.
@kaessert

Copy link
Copy Markdown
Collaborator

The port kept the logic split into separate builder functions, but the environments function went from 12 KCL modules (argo/, aws/, utils/, teamRobot.k, pKubernetesHelper.k, bootstrapSecretSync.k, …) to basically two files. resources.py (408 lines) now holds every area behind # --- comment headers. We'd like to bring the module boundaries back before merging, e.g.:

functions/environments/function/
  fn.py              # orchestration (as today)
  resources/
    kubernetes.py    # k8s_object, kubeconfig, provider configs, observe_secret
    argo.py
    team_robot.py
    secret_sync.py
    aws.py           # provider config, crossplane_role, TRUST_POLICY
    util.py          # management_policies, pc_ref, naming/hash helpers

This is purely a move, so the existing composition tests should pass unchanged.

Related: ORPHAN, IAM_NAME_MAX, _simple_hash, truncate_iam_name and _dig are duplicated between environments/function/resources.py and sharedawssecret/function/fn.py. If each function has to be packaged on its own, a test asserting the copies stay in sync would help. Otherwise a shared module would be better.

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.
@ytsarev

ytsarev commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Both done in 74e3cbd.

  • resources.py is now the resources/ package you proposed. It's a pure move: all 20 tests pass, and every test's full rendered output is identical to before.
  • For the duplicates we can have a real shared module. up packages each function from its own directory but follows symlinks when it does, so the helpers now live once in common/ at the repo root, and each function has a function/common symlink to it. With a single copy there's nothing to keep in sync. I also moved two duplicates you didn't list: upboundreposet's ORPHAN, and the Object defaults kept for KCL parity.

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 jboero left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__.py says the key was lost "because merging metadata had replaced the annotation". It's specifically the = override operator on the annotations entry. Worth stating precisely, because it's the only thing that explains why upboundreposet keys its ProviderConfig providerConfigUpbound while environments keys 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:FBT001 in the scaffolding implies ruff ran locally, but only yamllint (scoped to apis/) runs in CI. A ruff check step would be cheap insurance.
  • environments/function/fn.py:47 parse_bootstrap_kubeconfig raises TypeError if clusters[0].cluster.server is absent (re.sub on None), where KCL's ?. degraded to Undefined. Narrow, but it turns a malformed kubeconfig from "environment doesn't initialise" into "function errors".
  • main.py swallows startup failures (except Exception: click.echo(...), exit 0). That's up function generate scaffolding 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=1 is well justified and correctly flagged as temporary, but there's no link to an up issue. A tracking link in the comment would make it removable later rather than permanent.
  • functions/*/README.md are still the generator boilerplate ("You may fill in details…").
  • The function/common symlinks work in CI, but will break on a Windows checkout without core.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.
@ytsarev

ytsarev commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Thanks, this was a careful review. Addressed in e3505c8, 10b0e8b and 498ee8e:

1. Empty data / template.data: fixed. Confirmed with a failing test first: data: [] replaced the default dataFrom extract, so the external secret synced nothing. Both now use or None, matching the KCL and environments. Covered by test-sharedawssecret-empty-external-secret-data.

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. grpcio is pinned to 1.84.0, which SDK 0.15.1 already required exactly.

Nits:

  • parse_bootstrap_kubeconfig: reproduced the TypeError. A kubeconfig without server or the Spaces extension now yields no coordinates, so the Environment stays uninitialised. Covered by test-environment-malformed-bootstrap-kubeconfig.
  • = vs :: the resources/ docstring now names the operators precisely, and uses the two ProviderConfigs as the example.
  • ruff: there's a CI job now, with ruff pinned and a repo ruff.toml. It found an unused variable, dict() calls and unsorted imports, all fixed.
  • main.py: left as generated, per your suggestion; ruff exempts just those two rules there.
  • Function READMEs: replaced with real ones.
  • Windows symlinks: noted in the README.
  • UP_MAX_CONCURRENCY link: I'll add it once the upstream up issue is filed.

@ytsarev
ytsarev requested review from a team and jboero September 30, 2026 09:08
@kaessert

Copy link
Copy Markdown
Collaborator

Dry-run against production inputs: Python matches KCL.

I rendered every XR that runs in production through both main's KCL functions (4bdfb81) and this PR's Python functions (498ee8e) and compared the outputs. It was fully offline: a read-only snapshot, with secrets redacted and converted to the v2 XR shape, rendered with crossplane render / up composition render.

Kind XRs Composed resources Result
Environment 5 81 intended diffs only
SharedAWSSecret 2 (+1 nested in an Environment) 15 (+8) identical
UpboundRepoSet 2 560 intended diffs only
  • Both sides produce the same composition keys, so nothing would be replaced. The desired XR status and conditions are equal, and neither side emits any results.
  • The only differences are the intended ones:
    • kubeconfig Secrets are now written with yaml.safe_dump (c8772e7). The bytes differ, but they're equal once parsed.
    • crosslane.io/external-name is now crossplane.io/external-name on Repositories (3968b2d). The values are unchanged.
    • KCL's debug annotation foo: str(oxrSpec) on the ControlPlane is gone.
    • KCL's subjects[0].name: null in one admin binding is gone.
  • Not exercised by production data: the empty external secret data fix and the malformed-kubeconfig handling. The new tests cover those.

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 kaessert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job mate! LGTM ✅

@ytsarev
ytsarev merged commit 88bcf81 into main Sep 30, 2026
4 checks passed
@ytsarev
ytsarev deleted the python-rewrite branch September 30, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants