From f32a059a071702574968d9f5700a6a0f9b50eef1 Mon Sep 17 00:00:00 2001 From: mbakalarski <64490638+mbakalarski@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:41:49 +0000 Subject: [PATCH] Publish the avd.netclab.dev API as its own Configuration package A Function package cannot carry XRDs or Compositions. `xpkg build` rejects both with "object is not a CRD" -- a Function package takes only the CRDs describing its input, and this function has none, because the XRs are its API. So the API this function serves was publishable from nowhere: not from here, and not from a consumer without copying it. It is now a second package, built from `apis/` with `apis/crossplane.yaml` as its metadata and depending on the function. Nothing moves out of this repo, so `kind-up.sh` and both e2e suites keep exercising the real API rather than a fixture -- which is the whole reason the API stays where the lab is. Three supporting changes: - The `Function` object is renamed to `netclab-function-avd`, because that is what Crossplane calls a dependency-installed function (`-`). The compositions and kind-up.sh follow it, so the dev cluster now uses the same name a real install does -- previously the e2e suite exercised a name no consumer would ever have. - The dev-only Function manifests move from `apis/function/` to `dev/`. They are not API, and with them under `apis/` the Configuration build fails outright ("no kind Function is registered"). Out of the package root, the build needs no --ignore, so a future directory under `apis/` cannot silently fall out of the package. - CI builds the Configuration on every PR, and the release publishes it to GHCR and Upbound under the same version as the function. It is deliberately not an `xpkg-*` artifact: the publish job globs those into one multi-platform index, and this package must not join it. Verified: both packages build, 31 offline tests pass, and the render manifest and compositions agree on the new function name. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 32 ++++++ .github/workflows/release.yml | 102 ++++++++++++++++++++ README.md | 35 ++++++- apis/crossplane.yaml | 54 +++++++++++ apis/device/composition.yaml | 2 +- apis/fabric/composition.yaml | 2 +- {apis/function => dev}/function-render.yaml | 6 +- {apis/function => dev}/function.yaml | 2 +- scripts/kind-up.sh | 4 +- 9 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 apis/crossplane.yaml rename {apis/function => dev}/function-render.yaml (78%) rename {apis/function => dev}/function.yaml (91%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a10e01..eee261e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,38 @@ jobs: - run: uv run pytest -q + # `xpkg build` is not a lint: it parses every YAML under --package-root and + # rejects anything that is not a valid manifest of a kind the Configuration + # scheme knows. So a green build here is a real statement about both XRDs, + # both Compositions and all six examples. + # + # No --ignore: the dev-only Function manifests live in `dev/`, outside this + # package root, precisely so this build needs no exclusions. Putting a + # `Function` back under apis/ fails with "no kind Function is registered". + configuration: + name: configuration package + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Install crossplane CLI + run: | + set -euo pipefail + # renovate: datasource=github-releases depName=crossplane/crossplane + curl -sfL "https://cli.crossplane.io/install.sh" | XP_VERSION=v2.4.0 sh + sudo mv ./crossplane /usr/local/bin/crossplane + + - name: Build the Configuration package + run: | + set -euo pipefail + crossplane xpkg build \ + --package-root=apis \ + --examples-root=examples/fabric \ + -o configuration-avd-ci.xpkg + # The offline suite runs on the runner's interpreter, so it says nothing about # the image the xpkg actually embeds. Without this job a Dockerfile, uv or # Python bump is only exercised by the release build -- i.e. after the tag, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dac40f3..34b0175 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,108 @@ jobs: if-no-files-found: error retention-days: 7 + # The API this function serves, published as its own package. A Function + # package cannot carry XRDs or Compositions -- `xpkg build` rejects both with + # "object is not a CRD" -- so the only way to ship the avd.netclab.dev API is + # a Configuration, and it is released from here, beside the function it + # depends on and under the same version. + # + # Deliberately NOT an `xpkg-*` artifact: the publish job below globs every + # .xpkg it downloads into one multi-platform index, and this package must not + # be folded into the function's. + configuration: + name: configuration package + needs: test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Install crossplane CLI + run: | + set -euo pipefail + # renovate: datasource=github-releases depName=crossplane/crossplane + curl -sfL "https://cli.crossplane.io/install.sh" | XP_VERSION=v2.4.0 sh + sudo mv ./crossplane /usr/local/bin/crossplane + + - name: Build the Configuration package + run: | + set -euo pipefail + crossplane xpkg build \ + --package-root=apis \ + --examples-root=examples/fabric \ + -o "configuration-avd-${{ needs.test.outputs.version }}.xpkg" + + - uses: actions/upload-artifact@v7 + with: + name: configuration-xpkg + path: ./*.xpkg + if-no-files-found: error + + publish-configuration: + name: push configuration to GHCR + Upbound + needs: [test, configuration] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + env: + VERSION: ${{ needs.test.outputs.version }} + UP_TOKEN: ${{ secrets.UPBOUND_TOKEN }} + steps: + - uses: actions/download-artifact@v8 + with: + name: configuration-xpkg + + - name: Install crossplane CLI + run: | + set -euo pipefail + # renovate: datasource=github-releases depName=crossplane/crossplane + curl -sfL "https://cli.crossplane.io/install.sh" | XP_VERSION=v2.4.0 sh + sudo mv ./crossplane /usr/local/bin/crossplane + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push to GHCR + run: | + set -euo pipefail + # A Configuration is YAML with no runtime image, so there is nothing + # to build per architecture and this is a single-file push. + file="$(ls ./configuration-avd-*.xpkg)" + crossplane xpkg push -f "${file}" \ + "ghcr.io/netclab/configuration-avd:${VERSION}" + + - name: Install up CLI + if: env.UP_TOKEN != '' + run: | + set -euo pipefail + # renovate: datasource=github-releases depName=upbound/up + curl -sL "https://cli.upbound.io" | VERSION=v0.51.1 sh + sudo mv ./up /usr/local/bin/up + + - name: Push to the Upbound Marketplace + if: env.UP_TOKEN != '' + run: | + set -euo pipefail + up login --token="${UP_TOKEN}" + file="$(ls ./configuration-avd-*.xpkg)" + up xpkg push -f "${file}" --create \ + "xpkg.upbound.io/netclab/configuration-avd:${VERSION}" + + - name: Note a skipped Upbound push + if: env.UP_TOKEN == '' + run: | + echo "::warning::UPBOUND_TOKEN is not set -- configuration pushed to" \ + "GHCR only." + publish: name: push to GHCR + Upbound needs: [test, build] diff --git a/README.md b/README.md index 3933830..753a4db 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ provider's poll interval, the same rhythm that paces the rest of the model. uv run avd-verify # pyavd vs AVD's own golden structured configs uv run avd-verify-xr # same, through the Fabric-XR fold crossplane render examples/fabric/single-dc-l3ls.yaml \ - apis/fabric/composition.yaml apis/function/function-render.yaml + apis/fabric/composition.yaml dev/function-render.yaml ``` ## How it works @@ -201,12 +201,16 @@ either number. `workflow_dispatch` publishes too, taking no input — it release version the checked-out tree declares. The workflow reruns the offline suite, builds the runtime image for `linux/amd64` and -`linux/arm64`, embeds each into an xpkg, and pushes both as one multi-platform package: +`linux/arm64`, embeds each into an xpkg, and pushes both as one multi-platform package. +It also builds a **second package** — `configuration-avd`, the API this function serves +— and pushes it separately, under the same version: | Destination | When | |-------------|------| | `ghcr.io/netclab/function-avd:` | always — the workflow's `GITHUB_TOKEN` is enough | +| `ghcr.io/netclab/configuration-avd:` | always | | `xpkg.upbound.io/netclab/function-avd:` | only when the `UPBOUND_TOKEN` secret is set | +| `xpkg.upbound.io/netclab/configuration-avd:` | only when the `UPBOUND_TOKEN` secret is set | The second one is what feeds [marketplace.upbound.io/functions/netclab](https://marketplace.upbound.io/functions/netclab): @@ -232,6 +236,30 @@ spec: package: ghcr.io/netclab/function-avd: ``` +### The Configuration package + +A Function package **cannot carry XRDs or Compositions** — `crossplane xpkg build` +rejects both with `object is not a CRD`, because a Function package takes only the CRDs +describing its input, and this function has none (the XRs are its API). So +`apis/fabric/` and `apis/device/` ship as their own Configuration, built from `apis/` +with `apis/crossplane.yaml` as its metadata. + +Installing it pulls the function in as a dependency, so this is the one line a consumer +needs: + +```yaml +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: configuration-avd +spec: + package: ghcr.io/netclab/configuration-avd: +``` + +Crossplane names a dependency-installed function `-`, so the Function object +lands as `netclab-function-avd` — which is exactly the name both Compositions reference +and the name `kind-up.sh` installs under, so the dev cluster and a real install agree. + ## Gotchas The non-obvious things this repo encodes, each of which cost a debugging session: @@ -325,7 +353,8 @@ The non-obvious things this repo encodes, each of which cost a debugging session | `function/ansible_inputs.py` | rebuild `all_inputs` from an Ansible example (inventory + group_vars merge) | | `function/verify_example.py`, `verify_xr.py` | golden-diff harnesses (`avd-verify`, `avd-verify-xr`) | | `apis/fabric/`, `apis/device/` | XRD + Composition for each layer | -| `apis/function/` | `Function` manifests (cluster install, and local `crossplane render`) | +| `apis/crossplane.yaml` | Configuration package metadata — `apis/` is that package's root | +| `dev/` | `Function` manifests for local use (kind install, `crossplane render`); outside `apis/` so the Configuration build needs no exclusions | | `examples/fabric/` | example `Fabric` XRs (each reproduces golden) | | `examples/lab/` | kustomize overlay: the same fabric as run on the netclab lab | | `Dockerfile`, `package/crossplane.yaml` | function runtime image + package metadata | diff --git a/apis/crossplane.yaml b/apis/crossplane.yaml new file mode 100644 index 0000000..080ce08 --- /dev/null +++ b/apis/crossplane.yaml @@ -0,0 +1,54 @@ +# Configuration package metadata. `apis/` is this package's root, so everything +# beside this file -- the Fabric and Device XRDs and their Compositions -- is +# what ships. +# +# Why a second package at all: a Function package cannot carry XRDs or +# Compositions. `crossplane xpkg build` rejects both with "object is not a CRD"; +# a Function package takes only the CRDs describing its input, and this function +# has none. So the API this function serves has to be published as a +# Configuration, and this is the closest place to where it is developed and +# proven against real devices. +# +# The dev-only `Function` manifests live in `dev/`, outside this root, so the +# build needs no `--ignore` exclusions and adding a directory under `apis/` +# cannot silently fall out of the package. +apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: configuration-avd + annotations: + meta.crossplane.io/maintainer: netclab.dev + meta.crossplane.io/source: github.com/netclab/function-avd + meta.crossplane.io/license: Apache-2.0 + meta.crossplane.io/description: >- + The avd.netclab.dev API: Fabric and Device XRDs and their Compositions, + served by function-avd. + meta.crossplane.io/readme: | + The API served by + [function-avd](https://marketplace.upbound.io/functions/netclab/function-avd): + two composite kinds in the `avd.netclab.dev` group. + + * **`Fabric`** — one AVD fabric. `spec.design` carries a fabric-wide + `eos_designs` document; the function runs the fabric-wide pyavd pipeline + and composes one `Device` per host. + * **`Device`** — validates and renders its own structured config and EOS + CLI, publishes them as a ConfigMap, and with `spec.push` set also + composes a provider-http `Request` that holds the box's running config + in sync over eAPI. + + Installing this pulls the function in as a dependency. See the + [repository](https://github.com/netclab/function-avd) for the XRD schemas, + a runnable kind lab, and the design notes. +spec: + dependsOn: + # Installed by Crossplane as the Function object `netclab-function-avd` -- + # `-` from the package path -- which is the name both + # Compositions here reference, and the name `scripts/kind-up.sh` uses so the + # dev cluster matches a real install. + - apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.upbound.io/netclab/function-avd + version: ">=v0.1.2" + + crossplane: + version: ">=v2.1.1" diff --git a/apis/device/composition.yaml b/apis/device/composition.yaml index 072f4f1..8047cf9 100644 --- a/apis/device/composition.yaml +++ b/apis/device/composition.yaml @@ -14,4 +14,4 @@ spec: pipeline: - step: render-device functionRef: - name: function-avd + name: netclab-function-avd diff --git a/apis/fabric/composition.yaml b/apis/fabric/composition.yaml index 40f3ee8..1376795 100644 --- a/apis/fabric/composition.yaml +++ b/apis/fabric/composition.yaml @@ -12,4 +12,4 @@ spec: pipeline: - step: render-avd functionRef: - name: function-avd + name: netclab-function-avd diff --git a/apis/function/function-render.yaml b/dev/function-render.yaml similarity index 78% rename from apis/function/function-render.yaml rename to dev/function-render.yaml index 53ab36e..6cc8295 100644 --- a/apis/function/function-render.yaml +++ b/dev/function-render.yaml @@ -5,14 +5,14 @@ # # uv run avd-function --insecure --debug # serves on :9443 # crossplane render examples/fabric/single-dc-l3ls.yaml \ -# apis/fabric/composition.yaml apis/function/function-render.yaml +# apis/fabric/composition.yaml dev/function-render.yaml # -# For the real cluster install use apis/function/function.yaml instead (it +# For the real cluster install use dev/function.yaml instead (it # references the packaged image in the registry). apiVersion: pkg.crossplane.io/v1 kind: Function metadata: - name: function-avd + name: netclab-function-avd annotations: render.crossplane.io/runtime: Development spec: diff --git a/apis/function/function.yaml b/dev/function.yaml similarity index 91% rename from apis/function/function.yaml rename to dev/function.yaml index c6ba6e5..d54fca7 100644 --- a/apis/function/function.yaml +++ b/dev/function.yaml @@ -1,7 +1,7 @@ apiVersion: pkg.crossplane.io/v1 kind: Function metadata: - name: function-avd + name: netclab-function-avd spec: # Registry reachable by BOTH the Crossplane pod and the node's containerd # (kind-network IP of the in-cluster registry; served over plain HTTP). diff --git a/scripts/kind-up.sh b/scripts/kind-up.sh index 6073946..9290cd4 100755 --- a/scripts/kind-up.sh +++ b/scripts/kind-up.sh @@ -103,12 +103,12 @@ kubectl --context "$CTX" apply -f - <> install XRDs + Compositions (Fabric + Device)" kubectl --context "$CTX" apply -f apis/fabric/xrd.yaml -f apis/device/xrd.yaml