Skip to content

Support pre-built function runtimes and per-language schema generation#24

Merged
adamwg merged 4 commits into
crossplane:mainfrom
negz:diy
Jun 5, 2026
Merged

Support pre-built function runtimes and per-language schema generation#24
adamwg merged 4 commits into
crossplane:mainfrom
negz:diy

Conversation

@negz

@negz negz commented May 21, 2026

Copy link
Copy Markdown
Member

Description of your changes

This PR bundles two small improvements I wanted while taking the new Crossplane CLI for a test drive on a complex project. Each is in its own commit.

1. Pre-built function runtime images (fixes #21)

Crossplane projects today discover embedded functions by convention: every subdirectory of paths.functions is treated as a function, and the CLI auto-detects the language and builds the runtime image. This works well for simple projects but blocks projects that have outgrown the built-in builders or that need to coordinate function builds with an existing build system (make, nix, Bazel, CI pipelines).

This PR adds an optional functions list to ProjectSpec. When the list is present it disables auto-discovery and is the sole source of truth for which functions to build. Each entry uses a source discriminator (Directory or Tarball):

spec:
  architectures: [amd64, arm64]
  functions:
    - source: Directory
      directory:
        name: function-a
    - source: Tarball
      tarball:
        name: function-b
        pathPrefix: build/function-b

Directory-source functions follow the existing build path. Tarball-source functions skip language detection and load one pre-built single-platform OCI image tarball per target architecture, following the naming convention <pathPrefix>-<arch>.tar or <pathPrefix>-<arch>.tar.gz (preferring the plain .tar when both exist). Per-architecture tarballs match what build tools naturally produce without bundling: docker save, Nix's dockerTools.buildImage, Bazel's oci_tarball, ko build --tarball, etc. all emit one single-platform tarball at a time. Packaging is inherently per-architecture too — each runtime image gets its own crossplane.yaml layer before they're tied together into a multi-arch package index — so the CLI would have to split a multi-arch input apart anyway. The gzipped variant is split into a separate commit; it's needed because Nix's image builders emit gzipped tarballs by default.

2. Per-language schema generation (fixes #29)

By default crossplane project build and crossplane dependency update-cache generate schemas for all four supported languages (Go, JSON, KCL, Python). For a project that only consumes one of them, every build generates language bindings the project never imports.

This commit adds an optional schemas block to ProjectSpec:

spec:
  schemas:
    languages: [python]

When languages is set, schema generation is restricted to the listed languages, both for the project's own XRDs and for its declared dependencies. The filter flows through project build/run and dependency update-cache/clean-cache. The block is nested rather than flat to leave room for future schema-related knobs.

Reviewers may want to focus on loadTarballRuntime and loadRuntimeImage in internal/project/build.go (the new tarball loading path) and on ProjectSchemas.Validate in apis/dev/v1alpha1/validate.go together with generator.Filter in internal/schemas/generator/interface.go (the schema language filter). The language identifiers are now defined as constants in the API package and consumed by the generators directly, so the two can't drift.

I have:

Comment thread internal/project/build.go Fixed
@negz negz force-pushed the diy branch 2 times, most recently from ca74952 to d261f8d Compare May 21, 2026 22:32
@negz negz changed the title Support pre-built function runtime images in projects Support pre-built function runtimes and per-language schema generation May 22, 2026
negz added a commit to modelplaneai/modelplane that referenced this pull request May 22, 2026
The Crossplane CLI handles simple projects end-to-end, but for a project
like Modelplane — nine composition functions, unit tests, linters, type
checking — it's not enough on its own. The Crossplane CLI can't run
tests, check types, or lint code. A project this size needs a real build
system layered on top.

This commit adopts uv as the Python workspace manager and Nix as the
build orchestrator, with uv2nix bridging the two. uv.lock is the single
source of truth for Python dependencies. Nix reads the lockfile via
uv2nix, builds per-function OCI image tarballs (including cross-arch),
and runs all checks in a sandbox. The Crossplane CLI assembles the final
project from pre-built tarballs via source: Tarball (crossplane/cli#24).

The principle is that the Crossplane CLI should integrate with language
ecosystems, not replace them. Each tool does what it's best at: uv
manages Python packages, Nix orchestrates builds and CI, the Crossplane
CLI packages the result.

nix flake check is the one-stop CI gate: Python lint (ruff), shell lint
(shellcheck, shfmt), Nix lint (statix, deadnix, nixfmt), and unit tests
for all nine functions. nix run .#fix auto-fixes everything those checks
verify. nix run .#generate regenerates Python schemas from XRDs and
dependency CRDs.

Depends on crossplane/cli#24.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to modelplaneai/modelplane that referenced this pull request May 22, 2026
The Crossplane CLI handles simple projects end-to-end, but for a project
like Modelplane — nine composition functions, unit tests, linters, type
checking — it's not enough on its own. The Crossplane CLI can't run
tests, check types, or lint code. A project this size needs a real build
system layered on top.

This commit adopts uv as the Python workspace manager and Nix as the
build orchestrator, with uv2nix bridging the two. uv.lock is the single
source of truth for Python dependencies. Nix reads the lockfile via
uv2nix, builds per-function OCI image tarballs (including cross-arch),
and runs all checks in a sandbox. The Crossplane CLI assembles the final
project from pre-built tarballs via source: Tarball (crossplane/cli#24).

The principle is that the Crossplane CLI should integrate with language
ecosystems, not replace them. Each tool does what it's best at: uv
manages Python packages, Nix orchestrates builds and CI, the Crossplane
CLI packages the result.

nix flake check is the one-stop CI gate: Python lint (ruff), shell lint
(shellcheck, shfmt), Nix lint (statix, deadnix, nixfmt), and unit tests
for all nine functions. nix run .#fix auto-fixes everything those checks
verify. nix run .#generate regenerates Python schemas from XRDs and
dependency CRDs.

Depends on crossplane/cli#24.

Signed-off-by: Nic Cope <nicc@rk0n.org>
@negz negz marked this pull request as ready for review May 22, 2026 21:32
@negz negz requested review from a team, jcogilvie and tampakrap as code owners May 22, 2026 21:32
@negz negz requested review from jbw976 and removed request for a team May 22, 2026 21:32
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@negz, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 33 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 00ab1505-3962-4bee-8754-a917f2d3e1b0

📥 Commits

Reviewing files that changed from the base of the PR and between fd39e0f and 0a780a5.

⛔ Files ignored due to path filters (1)
  • apis/dev/v1alpha1/zz_generated.deepcopy.go is excluded by !**/zz_generated*.go and included by **/*.go
📒 Files selected for processing (17)
  • apis/dev/v1alpha1/project_types.go
  • apis/dev/v1alpha1/validate.go
  • apis/dev/v1alpha1/validate_test.go
  • cmd/crossplane/dependency/cache.go
  • cmd/crossplane/function/generate.go
  • cmd/crossplane/function/generate_test.go
  • cmd/crossplane/project/build.go
  • cmd/crossplane/project/run.go
  • internal/project/build.go
  • internal/project/build_test.go
  • internal/schemas/generator/go.go
  • internal/schemas/generator/interface.go
  • internal/schemas/generator/interface_test.go
  • internal/schemas/generator/json.go
  • internal/schemas/generator/kcl.go
  • internal/schemas/generator/python.go
  • internal/schemas/manager/manager.go
📝 Walkthrough

Walkthrough

Adds ProjectSchemas and Function types (directory or tarball sources), validation for schemas and functions, resolves functions at build time, loads per-arch tarball runtimes (.tar/.tar.gz) with gzip support, centralizes schema language constants, and wires CLI commands to filter generators by project config.

Changes

Function sources and schema language configuration

Layer / File(s) Summary
Function and schema configuration types
apis/dev/v1alpha1/project_types.go
Adds function source discriminators (FunctionSourceDirectory, FunctionSourceTarball), schema language constants (SchemaLanguageGo, SchemaLanguageJSON, SchemaLanguageKCL, SchemaLanguagePython), SupportedSchemaLanguages(), ProjectSchemas with GetLanguages(), and Function/FunctionDirectory/FunctionTarball types plus Function.Name().
Configuration validation
apis/dev/v1alpha1/validate.go, apis/dev/v1alpha1/validate_test.go
Adds ProjectSchemas.Validate(), Function.Validate() and per-source validators; enforces nil vs explicit-empty semantics for schema languages, unique DNS-1123 subdomain function names, exactly-one source, and relative non-empty tarball PathPrefix.
Schema generator language centralization
internal/schemas/generator/interface.go, internal/schemas/generator/*, internal/schemas/generator/interface_test.go, internal/schemas/manager/manager.go
Replaces hard-coded generator language strings with dev API constants, adds Filter(all, langs) to select generators by configured languages, and adds tests asserting API alignment and filter behavior.
CLI command integration
cmd/crossplane/function/generate.go, cmd/crossplane/dependency/cache.go, cmd/crossplane/project/build.go, cmd/crossplane/project/run.go, cmd/crossplane/function/generate_test.go
Validates requested function languages against project schema config, and filters schema generators used by project build, project run, and dependency update-cache to the languages returned by ProjectSpec.Schemas.GetLanguages().
Function resolution and build orchestration
internal/project/build.go
Resolves functions up-front (explicit or auto-discover), dispatches directory functions to language builders, loads per-architecture tarball runtimes (.tar / .tar.gz) with gzip support and architecture validation, adjusts examples loading by source type, and updates concurrency/error handling.
Build tests and helpers
internal/project/build_test.go
Adds tests for resolveFunctions precedence, explicit-function builds, tarball-function image creation per-architecture, and tarball runtime loading formats/precedence; includes helpers to write and inspect runtime tarballs and extract image architectures.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • crossplane/crossplane#7251 — Implements pre-built function runtime images as tarball sources, matching the feature request to accept user-provided OCI runtime inputs.

Do you want a separate pass that flags specific lines for API stability/compatibility checks or that lists follow-up TODOs?

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: pre-built function runtimes and per-language schema generation, both core features introduced in the PR.
Description check ✅ Passed The description thoroughly explains both features, their motivation, implementation details, and includes links to the fixed issues.
Linked Issues check ✅ Passed All primary coding objectives from issues #21 and #29 are met: support for pre-built function runtimes (Directory/Tarball source discriminator, per-arch tarball loading), per-language schema generation (spec.schemas configuration, filtering through build/dependency commands), and validation logic.
Out of Scope Changes check ✅ Passed All code changes align with the two core features: function source configuration, schema language filtering, validation, and supporting generator/manager updates. No unrelated changes detected.
Breaking Changes ✅ Passed All changes are additions: new optional fields in ProjectSpec (Functions, Schemas), new types, constants, and methods. No removals, renames, required fields added, or behavior removed.
Feature Gate Requirement ✅ Passed Features are in v1alpha1 API (already experimental). Functions and Schemas fields are optional with backward compatibility: omitted fields preserve legacy behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
internal/project/build.go (1)

607-642: 💤 Low value

Minor: Consider returning both errors from gzipReadCloser.Close().

Currently, if g.Reader.Close() succeeds but g.file.Close() fails, we return the file error. But if both fail, we only return the gzip error and lose the file error. This is probably fine in practice since file close errors are rare, but I wanted to mention it.

Would it be worth using errors.Join to return both errors when they both occur? That said, if you've considered this and decided the current behavior is sufficient, I'm happy to defer to your judgment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/build.go` around lines 607 - 642, The Close method on
gzipReadCloser currently returns only the first non-nil error (g.Reader.Close)
or the file error (g.file.Close), losing the other error if both fail; change
gzipReadCloser.Close to combine both errors when present (use errors.Join(gerr,
ferr) or equivalent) so callers receive both failures, keeping existing return
behavior when only one error exists; update the gzipReadCloser.Close
implementation to import and use errors.Join while preserving the current call
ordering and semantics used in gzipOpener and gzipReadCloser.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apis/dev/v1alpha1/project_types.go`:
- Around line 272-286: The Function.Name method should be nil-safe like
GetLanguages: add a nil-receiver guard at the start of Function.Name (check if f
== nil and return an empty string) so callers can safely call
(*Function)(nil).Name() without panicking; update the Function.Name method to
return "" immediately when f is nil and keep the existing switch logic
unchanged.

In `@apis/dev/v1alpha1/validate.go`:
- Around line 121-122: Update the user-facing validation messages that are
currently appended to errs for unsupported schema languages and invalid function
names: replace technical phrasing like "is not a supported schema language" with
clear, actionable text that states what the user tried to provide, what valid
options are, and exactly what to change and retry (e.g., "The schema language
'X' is not supported. Please choose one of [A,B,C] and update schemas.languages
to one of these values, then retry."). Apply the same style to the other related
error appends (the ones that reference schema languages, the variable supported,
lang, and the checks for invalid function names) so each error suggests the
corrective action and shows valid examples or accepted patterns.
- Around line 241-247: The validation error wrapping uses fmt.Errorf("...: %w",
err) for the Directory and Tarball cases; replace those with
crossplane-runtime's errors.Wrap to match project conventions: import
"github.com/crossplane/crossplane-runtime/pkg/errors" (or add to existing
imports) and change the two append lines to errs = append(errs, errors.Wrap(err,
"directory")) for f.Directory.Validate() and errs = append(errs,
errors.Wrap(err, "tarball")) for f.Tarball.Validate(), leaving the rest of the
switch logic unchanged.

---

Nitpick comments:
In `@internal/project/build.go`:
- Around line 607-642: The Close method on gzipReadCloser currently returns only
the first non-nil error (g.Reader.Close) or the file error (g.file.Close),
losing the other error if both fail; change gzipReadCloser.Close to combine both
errors when present (use errors.Join(gerr, ferr) or equivalent) so callers
receive both failures, keeping existing return behavior when only one error
exists; update the gzipReadCloser.Close implementation to import and use
errors.Join while preserving the current call ordering and semantics used in
gzipOpener and gzipReadCloser.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6a7b1fe0-a0d4-41ce-9bbf-203928089248

📥 Commits

Reviewing files that changed from the base of the PR and between 5a1ea69 and 5208019.

⛔ Files ignored due to path filters (1)
  • apis/dev/v1alpha1/zz_generated.deepcopy.go is excluded by !**/zz_generated*.go and included by **/*.go
📒 Files selected for processing (15)
  • apis/dev/v1alpha1/project_types.go
  • apis/dev/v1alpha1/validate.go
  • apis/dev/v1alpha1/validate_test.go
  • cmd/crossplane/dependency/cache.go
  • cmd/crossplane/project/build.go
  • cmd/crossplane/project/run.go
  • internal/project/build.go
  • internal/project/build_test.go
  • internal/schemas/generator/go.go
  • internal/schemas/generator/interface.go
  • internal/schemas/generator/interface_test.go
  • internal/schemas/generator/json.go
  • internal/schemas/generator/kcl.go
  • internal/schemas/generator/python.go
  • internal/schemas/manager/manager.go

Comment thread apis/dev/v1alpha1/project_types.go
Comment thread apis/dev/v1alpha1/validate.go Outdated
Comment thread apis/dev/v1alpha1/validate.go
negz added a commit to modelplaneai/modelplane that referenced this pull request May 22, 2026
The Crossplane CLI handles simple projects end-to-end, but for a project
like Modelplane — nine composition functions, unit tests, linters, type
checking — it's not enough on its own. The Crossplane CLI can't run
tests, check types, or lint code. A project this size needs a real build
system layered on top.

This commit adopts uv as the Python workspace manager and Nix as the
build orchestrator, with uv2nix bridging the two. uv.lock is the single
source of truth for Python dependencies. Nix reads the lockfile via
uv2nix, builds per-function OCI image tarballs (including cross-arch),
and runs all checks in a sandbox. The Crossplane CLI assembles the final
project from pre-built tarballs via source: Tarball (crossplane/cli#24).

The principle is that the Crossplane CLI should integrate with language
ecosystems, not replace them. Each tool does what it's best at: uv
manages Python packages, Nix orchestrates builds and CI, the Crossplane
CLI packages the result.

nix flake check is the one-stop CI gate: Python lint (ruff), shell lint
(shellcheck, shfmt), Nix lint (statix, deadnix, nixfmt), and unit tests
for all nine functions. nix run .#fix auto-fixes everything those checks
verify. nix run .#generate regenerates Python schemas from XRDs and
dependency CRDs.

Depends on crossplane/cli#24.

Signed-off-by: Nic Cope <nicc@rk0n.org>

@adamwg adamwg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Couple of small comments, but I like both of these features overall.

I wonder if crossplane function generate should refuse to generate functions in languages that aren't specified in spec.languages. Feels like it would be surprising to generate a function and find you don't have any schemas to use in it.

Comment thread internal/project/build.go Outdated
Comment thread internal/project/build.go Outdated
negz added a commit to modelplaneai/modelplane that referenced this pull request Jun 4, 2026
The previous CLI (negz/cli:diy) pinned datamodel-code-generator 0.31.2,
which generated broken Python models for fields named int/bool - it
emitted undefined int_aliased/bool_aliased type references across every
model file. This forced workarounds like naming DRA attribute fields
boolean/integer instead of their wire names bool/int.

This pins the CLI to negz/cli:mp (crossplane/cli#24 and #64 cherry-picked
onto main), which bumps datamodel-code-generator past the fix in 0.54.0.
Builtin-conflicting field names now generate a trailing-underscore Python
attribute with the original name preserved as a Pydantic alias.

This commit only bumps the CLI and regenerates schemas/python/models. The
regen reflows every model with the newer generator (mostly Optional[X] ->
X | None), so the diff is large but mechanical.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Crossplane projects today discover embedded functions by convention:
every subdirectory of paths.functions is treated as a function, and the
CLI auto-detects the language and builds the runtime image. This works
well for simple projects but blocks projects that have outgrown the
built-in builders or that need to coordinate function builds with an
existing build system (make, nix, Bazel, CI pipelines).

Per crossplane#21, users want to supply
pre-built OCI runtime images alongside source-based functions, so the
CLI handles packaging while the user owns the build.

This commit adds an optional functions list to ProjectSpec. When the
list is present it disables auto-discovery and is the sole source of
truth for which functions to build. Each entry uses a Source
discriminator (Directory or Tarball) and a corresponding sub-field:

  spec:
    architectures: [amd64, arm64]
    functions:
      - source: Directory
        directory:
          name: function-a
      - source: Tarball
        tarball:
          name: function-b
          pathPrefix: build/function-b

Directory-source functions follow the existing build path. Tarball-
source functions skip language detection and load one pre-built
single-platform OCI image tarball per target architecture, following
the naming convention `<pathPrefix>-<arch>.tar`. So the example above
loads `build/function-b-amd64.tar` and `build/function-b-arm64.tar`.

Per-architecture tarballs match what build tools naturally produce
without bundling: `docker save`, Nix's dockerTools.buildImage,
Bazel's oci_tarball, `ko build --tarball`, etc. all emit one
single-platform tarball at a time. Packaging is inherently per-
architecture too — each runtime image gets its own crossplane.yaml
layer before they're tied together into a multi-arch package index —
so the CLI would have to split a multi-arch input apart anyway.

The CLI verifies that each tarball's image config records the
architecture its filename promises, and adds the package metadata
layer (crossplane.yaml) before assembling the multi-arch package
index. The on-disk output is identical to a CLI-built function.

When the functions list is omitted, the existing auto-discovery
behaviour is preserved unchanged.

Fixes crossplane#21.

Signed-off-by: Nic Cope <nicc@rk0n.org>
@negz negz force-pushed the diy branch 2 times, most recently from fd39e0f to e46389d Compare June 5, 2026 00:03
negz added 3 commits June 4, 2026 17:13
Nix's dockerTools.buildImage produces gzipped tarballs by default. Some
other build tools (Bazel rules_oci's oci_load, certain ko invocations)
do the same. With only plain .tar accepted, users of these tools had to
add a decompress step to their build pipeline just to feed images to
the Crossplane CLI.

This commit teaches the function tarball loader to fall back to
`<pathPrefix>-<arch>.tar.gz` when `<pathPrefix>-<arch>.tar` is not
present, preferring the plain tar when both exist. The gzipped tarball
is streamed through gzip.NewReader into go-containerregistry's
tarball.Image; no temporary files are written.

Signed-off-by: Nic Cope <nicc@rk0n.org>
By default crossplane project build and crossplane dependency
update-cache generate schemas for all four supported languages (Go,
JSON, KCL, Python). Per crossplane#29
this is wasteful for projects that only consume some of them: every
build generates language bindings the project never imports.

This commit adds an optional schemas block to ProjectSpec:

  spec:
    schemas:
      languages: [python]

When languages is set, schema generation is restricted to the listed
languages. The filter applies both to the project's own XRD schemas
and to its declared dependencies, and flows through project
build/run and dependency update-cache/clean-cache. When schemas is
omitted (the default), all languages are generated as before.

The schemas block is nested rather than flat to leave room for
future schema-related knobs (output paths, generator-specific
options) without scattering schema config across ProjectSpec.

The supported language identifiers are defined as constants
(SchemaLanguageGo, SchemaLanguageJSON, SchemaLanguageKCL,
SchemaLanguagePython) in the API package, with SupportedSchemaLanguages
returning the canonical set. The schema generator package consumes
these constants directly so the two cannot drift, and a test in the
generator package asserts that AllLanguages covers exactly the API's
declared set.

Fixes crossplane#29.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The function build path threaded two filesystems through its call
chain: the project root, and a separate afero.BasePathFs rooted at the
project's functions directory. Both pointed at the same underlying
tree, so most function operations were addressed relative to the
functions directory while runtime tarballs and the language builder's
ProjectFS were addressed relative to the project root. Carrying both
meant every function in the chain took two afero.Fs arguments, and the
per-function builder filesystem was a BasePathFs wrapped over another
BasePathFs.

This change drops the functions-rooted filesystem and addresses
everything relative to the project root, joining spec.paths.functions
inline where the functions directory was previously the root. The
build chain now takes a single afero.Fs, and the per-function builder
filesystem wraps the project filesystem once instead of twice.

Signed-off-by: Nic Cope <nicc@rk0n.org>
@negz

negz commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

@adamwg I think I've addressed everything now.

@negz negz requested a review from adamwg June 5, 2026 04:18

@adamwg adamwg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the updates, this looks great.

@adamwg adamwg merged commit 5ed17c9 into crossplane:main Jun 5, 2026
10 checks passed
negz added a commit to modelplaneai/modelplane that referenced this pull request Jun 6, 2026
The repo pinned the Crossplane CLI to negz/cli:diy, a fork branch carrying
an unreleased datamodel-code-generator bump. That bump (crossplane/cli#24
and #64) has since merged to crossplane/cli main, so this repins the CLI to
crossplane/cli directly and regenerates the Python models. The regen reflows
the affected models with the newer generator (mostly Optional[X] -> X | None).

The newer generator (datamodel-code-generator 0.59.0) emits object-typed
field defaults as a default_factory rather than a plain value. The Crossplane
SDK's resource.update serializes composed resources with
model_dump(exclude_defaults=True), which no longer recognizes the
factory-built default as equal to the declared default, so unset fields leak
into composed resources. This keeps crossplane-function-sdk-python pinned to
#208, which serializes with exclude_unset instead - "did the caller set this
field?" rather than "is it different from its default?" - which is the correct
question under server-side apply and immune to how a default is represented.

Switching the whole repo to exclude_unset surfaces a few places that
explicitly set fields to None or to a defaulted value, which exclude_defaults
previously dropped. compose-serving-stack built provider-kubernetes Objects
and Helm Releases with metadata=None and ObjectMeta(namespace=None); those now
only set the field when it's present. The compose-inference-cluster and
compose-model-deployment test fixtures are updated to reflect that explicitly
set values (a node pool's kubernetesVersion and diskSizeGb, a replica's worker
count and pipeline) now appear in composed resources.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to modelplaneai/modelplane that referenced this pull request Jun 9, 2026
The repo pinned the Crossplane CLI to negz/cli:diy, a fork branch carrying
an unreleased datamodel-code-generator bump. That bump (crossplane/cli#24
and #64) has since merged to crossplane/cli main, so this repins the CLI to
crossplane/cli directly and regenerates the Python models. The regen reflows
the affected models with the newer generator (mostly Optional[X] -> X | None).

The newer generator (datamodel-code-generator 0.59.0) emits object-typed
field defaults as a default_factory rather than a plain value. The Crossplane
SDK's resource.update serializes composed resources with
model_dump(exclude_defaults=True), which no longer recognizes the
factory-built default as equal to the declared default, so unset fields leak
into composed resources. This keeps crossplane-function-sdk-python pinned to
#208, which serializes with exclude_unset instead - "did the caller set this
field?" rather than "is it different from its default?" - which is the correct
question under server-side apply and immune to how a default is represented.

Switching the whole repo to exclude_unset surfaces a few places that
explicitly set fields to None or to a defaulted value, which exclude_defaults
previously dropped. compose-serving-stack built provider-kubernetes Objects
and Helm Releases with metadata=None and ObjectMeta(namespace=None); those now
only set the field when it's present. The compose-inference-cluster and
compose-model-deployment test fixtures are updated to reflect that explicitly
set values (a node pool's kubernetesVersion and diskSizeGb, a replica's worker
count and pipeline) now appear in composed resources.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to modelplaneai/modelplane that referenced this pull request Jun 10, 2026
The repo pinned the Crossplane CLI to negz/cli:diy, a fork branch carrying
an unreleased datamodel-code-generator bump. That bump (crossplane/cli#24
and #64) has since merged to crossplane/cli main, so this repins the CLI to
crossplane/cli directly and regenerates the Python models. The regen reflows
the affected models with the newer generator (mostly Optional[X] -> X | None).

The newer generator (datamodel-code-generator 0.59.0) emits object-typed
field defaults as a default_factory rather than a plain value. The Crossplane
SDK's resource.update serializes composed resources with
model_dump(exclude_defaults=True), which no longer recognizes the
factory-built default as equal to the declared default, so unset fields leak
into composed resources. This keeps crossplane-function-sdk-python pinned to
field?" rather than "is it different from its default?" - which is the correct
question under server-side apply and immune to how a default is represented.

Switching the whole repo to exclude_unset surfaces a few places that
explicitly set fields to None or to a defaulted value, which exclude_defaults
previously dropped. compose-serving-stack built provider-kubernetes Objects
and Helm Releases with metadata=None and ObjectMeta(namespace=None); those now
only set the field when it's present. The compose-inference-cluster and
compose-model-deployment test fixtures are updated to reflect that explicitly
set values (a node pool's kubernetesVersion and diskSizeGb, a replica's worker
count and pipeline) now appear in composed resources.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 18, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 18, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 18, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 18, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 19, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 19, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
negz added a commit to negz/cli that referenced this pull request Jun 19, 2026
PR crossplane#24 added support for gzipped function runtime image tarballs by
streaming each one through gzip.NewReader directly into
go-containerregistry's tarball.Image, writing no temporary files.

go-containerregistry calls the tarball.Opener it's given once per layer,
plus once each for the manifest and config. Because the gzip opener
re-opened and re-decompressed the whole file from the start on every
call, loading a single image decompressed it once per layer. Nix's
dockerTools emits one layer per store path, so a typical function image
has ~50 layers and was fully gunzipped ~54 times. Computing the image
digest then re-reads every layer again. With functions built
concurrently, a project with a dozen multi-arch functions spent over ten
minutes pegging every core in this loop.

This change decompresses each gzipped tarball once into a temporary file
and serves every opener call from that plain tar, turning ~54 full
decompressions per image into one. The temporary files back the returned
images lazily, so they must outlive Build; the builder now creates them
under a per-build temporary directory and exposes a Close method that
removes the directory once the caller has finished consuming the images.
NewBuilder returns the concrete *realBuilder so callers can defer Close,
and the build, run, and render entry points do so after they have
written, sideloaded, or loaded the images.

On a project with twelve functions built for amd64 and arm64, loading
all twenty-four images drops from over ten minutes to roughly eighty
seconds.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support generating schemas for specific languages Support pre-built function runtime images in control plane projects

3 participants