From 0cb88964bc17e85b22f0a2d4391f5335120589bd Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 13 Aug 2026 19:51:12 -0300 Subject: [PATCH 1/8] starters: add local (kind) starter workspace + CI publish/validate Add a Nebi/Pixi starter workspace for the local (kind) provider under starters/local/, plus a starters CI workflow. The workspace ships a placeholder config.yaml (project_name: CHANGEME), a pixi.toml pinning the local toolchain, a lock file, and a README walking the import -> edit -> validate -> deploy flow. Local drives kind via an embedded Go library, so unlike the cloud starters it pins no OpenTofu; the default env has no required deps and nic runs from PATH until the prefix.dev github-releases channel is live (#579). The starters workflow validates on every PR (unedited config must be rejected, a filled copy must pass) and publishes the bundle to quay.io only on workflow_dispatch or push to main, so PRs never push. Registry namespace defaults to nebari_environments pending confirmation on #560. An .gitignore negation keeps starters/**/config.yaml visible despite the config*.yaml ignore rule. Part of #560 --- .github/workflows/starters.yml | 145 +++++++++++++++++++++++++++++++++ .gitignore | 1 + starters/local/README.md | 45 ++++++++++ starters/local/config.yaml | 24 ++++++ starters/local/pixi.lock | 111 +++++++++++++++++++++++++ starters/local/pixi.toml | 46 +++++++++++ 6 files changed, 372 insertions(+) create mode 100644 .github/workflows/starters.yml create mode 100644 starters/local/README.md create mode 100644 starters/local/config.yaml create mode 100644 starters/local/pixi.lock create mode 100644 starters/local/pixi.toml diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml new file mode 100644 index 00000000..d1dddc76 --- /dev/null +++ b/.github/workflows/starters.yml @@ -0,0 +1,145 @@ +name: Starters + +on: + pull_request: + paths: + - "starters/**" + - ".github/workflows/starters.yml" + push: + branches: [ main ] + paths: + - "starters/**" + - ".github/workflows/starters.yml" + workflow_dispatch: + +permissions: + contents: read + +# TODO confirm namespace (nebari_environments vs nebari) - open question on #560. +env: + NEBI_QUAY_NAMESPACE: nebari_environments + +jobs: + # NOTE: the "unedited config must be rejected" assertion below only holds once + # #561 (PR #583) is merged to main. Until then, nic-from-main has no CHANGEME + # check, so `nic validate` on the placeholder config SUCCEEDS and this job goes + # red. That failure is expected for this draft PR. + validate-starters: + name: Validate starters + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Build nic + run: make build + + - name: Put nic on PATH + run: sudo mv nic /usr/local/bin/nic + + - name: DoD - unedited fails, filled passes (starters/local) + run: | + set -euo pipefail + config="starters/local/config.yaml" + + # 1. The unedited placeholder config MUST be rejected. + echo "== asserting unedited config is rejected ==" + if err="$(nic validate -f "$config" 2>&1)"; then + echo "FAIL: nic validate accepted the unedited placeholder config" + echo "$err" + exit 1 + fi + echo "$err" + if ! echo "$err" | grep -q "CHANGEME"; then + echo "FAIL: validation error did not mention CHANGEME" + exit 1 + fi + echo "OK: unedited config rejected and error names CHANGEME" + + # 2. A filled copy MUST validate. + echo "== asserting filled config passes ==" + filled="$(mktemp)" + sed 's/project_name: CHANGEME/project_name: nebari-local-ci/' "$config" > "$filled" + nic validate -f "$filled" + echo "OK: filled config passed validation" + + publish-starters: + name: Publish starters to quay.io + needs: validate-starters + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} + QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Build nic + run: make build + + - name: Put nic on PATH + run: sudo mv nic /usr/local/bin/nic + + - name: Install pixi and nebi + run: | + set -euo pipefail + curl -fsSL https://pixi.sh/install.sh | bash + echo "$HOME/.pixi/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.pixi/bin:$PATH" + pixi global install "nebi>=0.10" + + - name: Skip if no quay credentials + id: creds + run: | + if [ -z "${QUAY_TOKEN:-}" ]; then + echo "no quay creds, skipping publish" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Configure quay registry + if: steps.creds.outputs.skip == 'false' + run: | + set -euo pipefail + printf '%s' "$QUAY_TOKEN" | nebi registry add --local \ + --name quay \ + --url quay.io \ + --namespace "$NEBI_QUAY_NAMESPACE" \ + --username "$QUAY_USERNAME" \ + --password-stdin + + - name: Publish starter-local + if: steps.creds.outputs.skip == 'false' + run: | + set -euo pipefail + cd starters/local + nebi init + # Let the tag auto-increment; publish straight to the registry with no + # nebi server. + nebi publish --local --registry quay --repo starter-local + + - name: Round-trip DoD - imported unedited config still fails + if: steps.creds.outputs.skip == 'false' + run: | + set -euo pipefail + rm -rf /tmp/imported + # Import the just-published starter and assert the (unedited) config it + # ships is still rejected by nic validate. + nebi import "quay.io/$NEBI_QUAY_NAMESPACE/starter-local:latest" -o /tmp/imported + if nic validate -f /tmp/imported/config.yaml; then + echo "FAIL: imported unedited config was accepted by nic validate" + exit 1 + fi + echo "OK: imported unedited config is still rejected" diff --git a/.gitignore b/.gitignore index cd701e02..fea61770 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ local*.yaml # nor .github/fixtures/deploy/*.yaml !examples/*.yaml !.github/fixtures/deploy/*.yaml +!starters/**/config.yaml # Internal design docs docs/plans/ diff --git a/starters/local/README.md b/starters/local/README.md new file mode 100644 index 00000000..0e7aaaed --- /dev/null +++ b/starters/local/README.md @@ -0,0 +1,45 @@ +# starter-local + +A Nebi/Pixi starter workspace for a **local (kind)** Nebari deployment. kind +runs the whole cluster on your machine, so this starter needs **no cloud +credentials** and nothing to fill in but the project name. + +## Import, edit, deploy + +```bash +nebi import quay.io/nebari/starter-local:vX -o ./my-nebari +cd my-nebari +# edit the one placeholder: +$EDITOR config.yaml # set project_name (it ships as CHANGEME) +nebi run validate # rejects the unedited placeholder +nebi run deploy # validate runs first, then brings up the kind cluster +``` + +`nebi import` refuses to write into a non-empty directory when the bundle +carries asset layers, so the import-then-edit cycle cannot clobber existing +work. + +## The one field to edit + +`config.yaml` ships with `project_name: CHANGEME`. That is the only field you +must change; `validate` fails until you do (`grep -n CHANGEME config.yaml`). +Everything else (self-signed certificate, `nebari.local` domain, Linux node +selectors) is preset for local development. `git_repository` is omitted on +purpose: nic auto-creates `~/.nic/gitops/` and mounts it into the +kind cluster, so local dev is zero-config. + +## nic on PATH + +The tasks call `nic` directly. Once the prefix.dev github-releases channel is +live (#579), `pixi install` will resolve nic from `[dependencies]`. Until then, +**put a nic binary on your PATH** before running the tasks. nic does not need +kubectl (it talks to the cluster via embedded client-go); the optional `tools` +env (`pixi install -e tools`) adds `kubernetes-client` and `k9s` for +convenience on the platforms that carry a current kubectl. + +## Secrets and where filled configs belong + +Local kind needs no secrets. If you adapt this workspace, keep secrets in +environment variables, **never** in `config.yaml`. A filled config is a private +artifact: publish it only to a private registry or a nebi server, never to a +public one. diff --git a/starters/local/config.yaml b/starters/local/config.yaml new file mode 100644 index 00000000..d583446e --- /dev/null +++ b/starters/local/config.yaml @@ -0,0 +1,24 @@ +# Nebari Infrastructure Core - local (kind) starter config +# +# The local provider runs everything in a kind cluster on your machine, so it +# needs no cloud credentials. Only project_name is a placeholder; edit it, then +# `nic validate` will pass. (grep -n CHANGEME config.yaml) + +project_name: CHANGEME +domain: nebari.local + +certificate: + type: selfsigned + +cluster: + local: + node_selectors: + general: + kubernetes.io/os: linux + user: + kubernetes.io/os: linux + worker: + kubernetes.io/os: linux + +# git_repository omitted on purpose: NIC auto-creates ~/.nic/gitops/ +# and mounts it into the kind cluster (zero-config for local dev). diff --git a/starters/local/pixi.lock b/starters/local/pixi.lock new file mode 100644 index 00000000..3a970ba5 --- /dev/null +++ b/starters/local/pixi.lock @@ -0,0 +1,111 @@ +version: 7 +platforms: +- name: linux-64 +- name: linux-aarch64 +- name: osx-64 +- name: osx-arm64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://prefix.dev/github-releases/ + packages: {} + tools: + channels: + - url: https://conda.anaconda.org/conda-forge/ + - url: https://prefix.dev/github-releases/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/k9s-0.51.0-h643be8f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/kubernetes-client-1.34.3-h6950dcc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/k9s-0.51.0-hfb368cc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/kubernetes-client-1.34.3-h91756fb_0.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/k9s-0.51.0-h643be8f_0.conda + sha256: a3237f76b3057d38afcb575c7486671c35794bd44e062ef7a29ff030d66fabdb + md5: 768c676ef74f1e5f1b4227b74a7917ee + depends: + - __glibc >=2.17,<3.0.a0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 72731742 + timestamp: 1780730847822 +- conda: https://conda.anaconda.org/conda-forge/linux-64/kubernetes-client-1.34.3-h6950dcc_0.conda + sha256: 1d64241b9bc5bd6513094543b98e19ce80918b9df392cee6e001aebaafadcc8e + md5: 839902381c88deb7c5524fa7c61b7aed + depends: + - __glibc >=2.17 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 26335888 + timestamp: 1765415822485 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f + md5: 5a7d954665c707c93311657cd779c705 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgomp 16.1.0 he0feb66_1 + - libgcc-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1057877 + timestamp: 1785375436766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 + md5: 88f2d91cb1533194c323534253094d23 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 640415 + timestamp: 1785375373755 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/k9s-0.51.0-hfb368cc_0.conda + sha256: 386e6400587ed43562603437af0f07a6a2187d7a96360c19f103a426278f0f22 + md5: 251f7f00e493efe79dfd745c9900d86a + depends: + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 71515912 + timestamp: 1780730928833 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/kubernetes-client-1.34.3-h91756fb_0.conda + sha256: 235f0748c94b9174d2597c4925cf2a3cbd0a2d6b97d6c30d1e7585fe6203e421 + md5: 03d17ebf4f8347030434a974c2c040b3 + depends: + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 24972079 + timestamp: 1765416015649 diff --git a/starters/local/pixi.toml b/starters/local/pixi.toml new file mode 100644 index 00000000..269f02f3 --- /dev/null +++ b/starters/local/pixi.toml @@ -0,0 +1,46 @@ +[workspace] +name = "nebari-local" +channels = ["conda-forge", "https://prefix.dev/github-releases"] +platforms = ["linux-64", "linux-aarch64", "osx-arm64", "osx-64"] +version = "0.1.0" + +[dependencies] +# nic is the centerpiece; it resolves from the prefix.dev github-releases +# channel once that channel is live (issue #579). Until then, run the tasks +# below with a nic binary on PATH. The package name is not yet settled +# (#556/#579), so keep this commented during the pilot. +# nebari-infrastructure-core = "0.11.*" +# +# The local provider does NOT use OpenTofu: it drives kind via an embedded Go +# library, so there is no `opentofu` dependency here (unlike the cloud +# starters). The default env therefore has no required deps. + +# Optional operator convenience tools. Kept OUT of the required deps because +# kubernetes-client (the kubectl binary; there is no separate `kubectl` package +# on conda-forge) lags badly on linux-aarch64 (1.24 vs 1.34 elsewhere). The +# feature is therefore scoped to the platforms that carry a current kubectl, so +# the CORE workspace still solves on all four. nic does not need kubectl - it +# talks to the cluster via embedded client-go. Enable with: pixi install -e tools +[feature.tools] +platforms = ["linux-64", "osx-arm64"] + +[feature.tools.dependencies] +kubernetes-client = ">=1.30" +k9s = ">=0.32" + +[environments] +tools = ["tools"] + +[tasks] +validate = "nic validate" +deploy = { cmd = "nic deploy", depends-on = ["validate"] } +destroy = "nic destroy" +kubeconfig = "nic kubeconfig -o kubeconfig.yaml" + +# nebi bundles workspace files as OCI asset layers (nebi >= 0.10). pixi.toml and +# pixi.lock are always included; `include` is a strict allowlist for everything +# else, so the starter ships its placeholder config.yaml and README and nothing +# a user might add later (a filled config, a kubeconfig, tfstate) leaks by +# accident. Requires nebi >= 0.10 on the publisher/importer. +[tool.nebi.bundle] +include = ["config.yaml", "README.md"] From 095d986c0e16bd8994894458f1f76999e93cb0a8 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Sat, 15 Aug 2026 12:15:04 -0300 Subject: [PATCH 2/8] ci(starters): publish under quay.io/nebari/starters, not nebari_environments Per #560: nebari_environments is reserved for nebi's own workspaces. NIC starters publish to the existing nebari project with a starters/ subdir, mirroring how charts live at quay.io/nebari/charts. Part of #560. --- .github/workflows/starters.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index d1dddc76..dc938d5f 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -15,9 +15,12 @@ on: permissions: contents: read -# TODO confirm namespace (nebari_environments vs nebari) - open question on #560. +# Per #560: nebari_environments is reserved for nebi's own workspaces. NIC +# starters publish under the existing nebari project with a starters/ subdir, +# mirroring how charts live at quay.io/nebari/charts. env: - NEBI_QUAY_NAMESPACE: nebari_environments + NEBI_QUAY_NAMESPACE: nebari + NEBI_STARTER_REPO: starters/starter-local jobs: # NOTE: the "unedited config must be rejected" assertion below only holds once @@ -128,7 +131,7 @@ jobs: nebi init # Let the tag auto-increment; publish straight to the registry with no # nebi server. - nebi publish --local --registry quay --repo starter-local + nebi publish --local --registry quay --repo "$NEBI_STARTER_REPO" - name: Round-trip DoD - imported unedited config still fails if: steps.creds.outputs.skip == 'false' @@ -137,7 +140,7 @@ jobs: rm -rf /tmp/imported # Import the just-published starter and assert the (unedited) config it # ships is still rejected by nic validate. - nebi import "quay.io/$NEBI_QUAY_NAMESPACE/starter-local:latest" -o /tmp/imported + nebi import "quay.io/$NEBI_QUAY_NAMESPACE/$NEBI_STARTER_REPO:latest" -o /tmp/imported if nic validate -f /tmp/imported/config.yaml; then echo "FAIL: imported unedited config was accepted by nic validate" exit 1 From 814010e2bfab07139ac821b78cef172154cf801e Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Wed, 19 Aug 2026 18:08:38 -0300 Subject: [PATCH 3/8] starters: generate workspaces instead of committing them Starters are published as OCI bundles to a registry, so the rendered workspaces do not belong in the tree. Replace the committed starters/local/ files with a generator and templates: examples/ stays the single source of truth for config content, and there is nothing to drift. scripts/gen-starters.sh renders examples/-config.yaml into a workspace by replacing the identity-bearing values with the CHANGEME sentinel and filling the pixi.toml and README templates. Line edits keep the examples' inline comments intact. Scope is local and aws for now. The local provider drives kind through a Go library so it needs no OpenTofu; aws pins it, which is the point of a pinned toolchain. CI generates the starters, asserts each is rejected while unedited and passes once filled, and on dispatch publishes both to quay and re-imports them to confirm the placeholders survive the round trip. Part of #560. --- .github/workflows/starters.yml | 162 +++++++++++++++++------------- .gitignore | 4 +- scripts/gen-starters.sh | 124 +++++++++++++++++++++++ starters/local/README.md | 45 --------- starters/local/config.yaml | 24 ----- starters/local/pixi.lock | 111 -------------------- starters/local/pixi.toml | 46 --------- starters/templates/README.md.tmpl | 34 +++++++ starters/templates/pixi.toml.tmpl | 38 +++++++ 9 files changed, 289 insertions(+), 299 deletions(-) create mode 100755 scripts/gen-starters.sh delete mode 100644 starters/local/README.md delete mode 100644 starters/local/config.yaml delete mode 100644 starters/local/pixi.lock delete mode 100644 starters/local/pixi.toml create mode 100644 starters/templates/README.md.tmpl create mode 100644 starters/templates/pixi.toml.tmpl diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index dc938d5f..5296ecc3 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -4,72 +4,84 @@ on: pull_request: paths: - "starters/**" + - "scripts/gen-starters.sh" + - "examples/local-config.yaml" + - "examples/aws-config.yaml" - ".github/workflows/starters.yml" push: branches: [ main ] paths: - "starters/**" + - "scripts/gen-starters.sh" + - "examples/local-config.yaml" + - "examples/aws-config.yaml" - ".github/workflows/starters.yml" workflow_dispatch: permissions: contents: read -# Per #560: nebari_environments is reserved for nebi's own workspaces. NIC -# starters publish under the existing nebari project with a starters/ subdir, -# mirroring how charts live at quay.io/nebari/charts. env: - NEBI_QUAY_NAMESPACE: nebari - NEBI_STARTER_REPO: starters/starter-local + # Starters publish under the existing nebari project, in a starters/ subdir, + # mirroring how the charts live at quay.io/nebari/charts. + QUAY_NAMESPACE: nebari + STARTER_REPO_PREFIX: starters jobs: - # NOTE: the "unedited config must be rejected" assertion below only holds once - # #561 (PR #583) is merged to main. Until then, nic-from-main has no CHANGEME - # check, so `nic validate` on the placeholder config SUCCEEDS and this job goes - # red. That failure is expected for this draft PR. + # NOTE: the "unedited config is rejected" assertion below only holds once the + # CHANGEME check (#561) is merged to main. Until then nic-from-main accepts + # placeholder configs and this job goes red. Expected while that PR is open. validate-starters: name: Validate starters runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - - name: Build nic - run: make build + - name: Build nic and put it on PATH + run: | + make build + echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" - - name: Put nic on PATH - run: sudo mv nic /usr/local/bin/nic + - name: Generate starters + run: ./scripts/gen-starters.sh dist/starters - - name: DoD - unedited fails, filled passes (starters/local) + - name: Unedited starters must be rejected, filled ones must pass run: | set -euo pipefail - config="starters/local/config.yaml" - - # 1. The unedited placeholder config MUST be rejected. - echo "== asserting unedited config is rejected ==" - if err="$(nic validate -f "$config" 2>&1)"; then - echo "FAIL: nic validate accepted the unedited placeholder config" + for provider in local aws; do + config="dist/starters/${provider}/config.yaml" + echo "== ${provider}: asserting the unedited config is rejected ==" + if err="$(nic validate -f "$config" 2>&1)"; then + echo "FAIL: nic validate accepted the unedited ${provider} starter" + echo "$err" + exit 1 + fi echo "$err" - exit 1 - fi - echo "$err" - if ! echo "$err" | grep -q "CHANGEME"; then - echo "FAIL: validation error did not mention CHANGEME" - exit 1 - fi - echo "OK: unedited config rejected and error names CHANGEME" - - # 2. A filled copy MUST validate. - echo "== asserting filled config passes ==" - filled="$(mktemp)" - sed 's/project_name: CHANGEME/project_name: nebari-local-ci/' "$config" > "$filled" - nic validate -f "$filled" - echo "OK: filled config passed validation" + echo "$err" | grep -q CHANGEME || { + echo "FAIL: ${provider} validation error did not mention CHANGEME"; exit 1; } + + echo "== ${provider}: asserting a filled config passes ==" + filled="$(mktemp)" + sed -e "s|project_name: CHANGEME|project_name: nebari-${provider}-ci|" \ + -e "s|^domain: CHANGEME|domain: nebari.example.com|" \ + -e "s|^ email: CHANGEME| email: admin@example.com|" \ + -e "s|^ url: CHANGEME| url: git@github.com:example-org/example-gitops.git|" \ + "$config" > "$filled" + if grep -q CHANGEME "$filled"; then + echo "FAIL: ${provider} still has placeholders after filling" + exit 1 + fi + nic validate -f "$filled" + echo "OK: ${provider} starter behaves correctly" + done publish-starters: name: Publish starters to quay.io @@ -82,35 +94,35 @@ jobs: steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: go.mod + fetch-depth: 0 - - name: Build nic - run: make build - - - name: Put nic on PATH - run: sudo mv nic /usr/local/bin/nic + - name: Skip when no quay credentials are configured + id: creds + run: | + if [ -z "${QUAY_TOKEN:-}" ]; then + echo "no quay credentials, skipping publish" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - name: Install pixi and nebi + if: steps.creds.outputs.skip == 'false' run: | set -euo pipefail - curl -fsSL https://pixi.sh/install.sh | bash + curl -fsSL https://pixi.sh/install.sh -o /tmp/pixi-install.sh + sh /tmp/pixi-install.sh echo "$HOME/.pixi/bin" >> "$GITHUB_PATH" export PATH="$HOME/.pixi/bin:$PATH" + # Asset-layer bundling (config.yaml and README travelling with the + # workspace) needs nebi >= 0.10; older versions silently ship only + # pixi.toml and pixi.lock. pixi global install "nebi>=0.10" - - name: Skip if no quay credentials - id: creds - run: | - if [ -z "${QUAY_TOKEN:-}" ]; then - echo "no quay creds, skipping publish" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi + - name: Generate starters + if: steps.creds.outputs.skip == 'false' + run: ./scripts/gen-starters.sh dist/starters - name: Configure quay registry if: steps.creds.outputs.skip == 'false' @@ -119,30 +131,36 @@ jobs: printf '%s' "$QUAY_TOKEN" | nebi registry add --local \ --name quay \ --url quay.io \ - --namespace "$NEBI_QUAY_NAMESPACE" \ + --namespace "$QUAY_NAMESPACE" \ --username "$QUAY_USERNAME" \ --password-stdin - - name: Publish starter-local + - name: Lock and publish each starter if: steps.creds.outputs.skip == 'false' run: | set -euo pipefail - cd starters/local - nebi init - # Let the tag auto-increment; publish straight to the registry with no - # nebi server. - nebi publish --local --registry quay --repo "$NEBI_STARTER_REPO" - - - name: Round-trip DoD - imported unedited config still fails + version="$(git describe --tags --abbrev=0 | sed 's/^v//')" + for provider in local aws; do + pushd "dist/starters/${provider}" > /dev/null + # Resolve the toolchain now so the published bundle carries a lock. + pixi lock + nebi init + nebi publish --local \ + --registry quay \ + --repo "${STARTER_REPO_PREFIX}/starter-${provider}" \ + --tag "v${version}" + popd > /dev/null + done + + - name: Imported starters must still be rejected unedited if: steps.creds.outputs.skip == 'false' run: | set -euo pipefail - rm -rf /tmp/imported - # Import the just-published starter and assert the (unedited) config it - # ships is still rejected by nic validate. - nebi import "quay.io/$NEBI_QUAY_NAMESPACE/$NEBI_STARTER_REPO:latest" -o /tmp/imported - if nic validate -f /tmp/imported/config.yaml; then - echo "FAIL: imported unedited config was accepted by nic validate" - exit 1 - fi - echo "OK: imported unedited config is still rejected" + version="$(git describe --tags --abbrev=0 | sed 's/^v//')" + for provider in local aws; do + dest="/tmp/imported-${provider}" + nebi import "quay.io/${QUAY_NAMESPACE}/${STARTER_REPO_PREFIX}/starter-${provider}:v${version}" -o "$dest" + test -f "$dest/config.yaml" + grep -q CHANGEME "$dest/config.yaml" + echo "OK: ${provider} round-tripped with its placeholders intact" + done diff --git a/.gitignore b/.gitignore index fea61770..d0d09139 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ local*.yaml # nor .github/fixtures/deploy/*.yaml !examples/*.yaml !.github/fixtures/deploy/*.yaml -!starters/**/config.yaml # Internal design docs docs/plans/ @@ -65,3 +64,6 @@ docs/plans/ # Test Configs test-configs/ + +# Generated starter workspaces (published to a registry, never committed) +dist/ diff --git a/scripts/gen-starters.sh b/scripts/gen-starters.sh new file mode 100755 index 00000000..006fe4c8 --- /dev/null +++ b/scripts/gen-starters.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Render Nebi starter workspaces from examples/-config.yaml. +# +# Starters are published as OCI bundles to a registry; they are deliberately +# NOT committed to this repository. Only the templates and this generator live +# in the tree, so examples/ stays the single source of truth for config content +# and there is nothing to drift. +# +# Usage: scripts/gen-starters.sh [output-dir] (default: dist/starters) +set -euo pipefail + +OUT_DIR="${1:-dist/starters}" +TEMPLATES="starters/templates" +# Version the starter pins nic to. Defaults to the latest tag, minus the "v". +NIC_VERSION="${NIC_VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}" +: "${NIC_VERSION:?could not determine NIC_VERSION and none was supplied}" + +# Providers in scope. Extend deliberately: each one needs its placeholder rules +# and any provider-specific dependencies below. +PROVIDERS=("local" "aws") + +# Which fields become CHANGEME, per provider. These are the identity-bearing +# values a user must supply; everything else stays a working default. Applied +# as line edits so the examples' inline comments survive intact. +placeholder_rules() { + case "$1" in + local) + printf '%s\n' \ + 's|^project_name: .*|project_name: CHANGEME|' + ;; + aws) + printf '%s\n' \ + 's|^project_name: .*|project_name: CHANGEME|' \ + 's|^domain: .*|domain: CHANGEME|' \ + 's|^ email: .*| email: CHANGEME|' \ + 's|^ url: .*| url: CHANGEME|' + ;; + esac +} + +# Extra conda dependencies a provider needs beyond nic itself. +provider_deps() { + case "$1" in + # The local provider drives kind through a Go library, so no OpenTofu. + local) printf '' ;; + # AWS runs OpenTofu. Pinning it here is the point of a pinned toolchain: + # without it nic falls back to downloading an unpinned tofu at deploy time. + aws) printf 'opentofu = ">=1.11,<2"' ;; + esac +} + +provider_title() { + case "$1" in + local) printf 'local (kind)' ;; + aws) printf 'AWS' ;; + esac +} + +provider_notes() { + case "$1" in + local) + cat <<'EOF' +## What you must edit + +Only `project_name`. The local provider runs everything in a kind cluster on +your machine, so it needs no cloud credentials, the certificate is self-signed +and the GitOps repository is created for you. +EOF + ;; + aws) + cat <<'EOF' +## What you must edit + +`project_name`, `domain`, `certificate.acme.email` and `git_repository.url`. +The infrastructure defaults (region, availability zones, instance types, +Longhorn, EFS) are working values, not placeholders. **If you change `region`, +change `availability_zones` to match**: `nic validate` does not cross-check +them and a mismatch fails mid-deploy. + +`nic validate` runs offline and needs no AWS credentials. It will not catch a +bad region, a nonexistent instance type or an availability zone that does not +exist in your region; those surface at deploy time. + +## Cost note + +This starter inherits the production-recommended shape, including dedicated +Longhorn storage nodes with large gp3 volumes and EFS. That is a real monthly +bill; trim the node groups for experiments. +EOF + ;; + esac +} + +mkdir -p "$OUT_DIR" +for provider in "${PROVIDERS[@]}"; do + src="examples/${provider}-config.yaml" + [ -f "$src" ] || { echo "missing $src" >&2; exit 1; } + dest="${OUT_DIR}/${provider}" + mkdir -p "$dest" + + # config.yaml: the example with identity-bearing values replaced. + cp "$src" "$dest/config.yaml" + while IFS= read -r rule; do + [ -n "$rule" ] && sed -i "$rule" "$dest/config.yaml" + done < <(placeholder_rules "$provider") + + # Fail loudly rather than publishing a starter that would validate as-is. + grep -q CHANGEME "$dest/config.yaml" || { + echo "no placeholders were substituted for $provider" >&2; exit 1; } + + sed -e "s|__PROVIDER__|${provider}|g" \ + -e "s|__NIC_VERSION__|${NIC_VERSION}|g" \ + -e "s|__PROVIDER_DEPS__|$(provider_deps "$provider")|g" \ + "$TEMPLATES/pixi.toml.tmpl" > "$dest/pixi.toml" + + notes="$(provider_notes "$provider")" + awk -v notes="$notes" \ + -v provider="$provider" \ + -v title="$(provider_title "$provider")" \ + '{gsub(/__PROVIDER_NOTES__/, notes); gsub(/__PROVIDER_TITLE__/, title); gsub(/__PROVIDER__/, provider); print}' \ + "$TEMPLATES/README.md.tmpl" > "$dest/README.md" + + echo "generated $dest (nic ${NIC_VERSION})" +done diff --git a/starters/local/README.md b/starters/local/README.md deleted file mode 100644 index 0e7aaaed..00000000 --- a/starters/local/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# starter-local - -A Nebi/Pixi starter workspace for a **local (kind)** Nebari deployment. kind -runs the whole cluster on your machine, so this starter needs **no cloud -credentials** and nothing to fill in but the project name. - -## Import, edit, deploy - -```bash -nebi import quay.io/nebari/starter-local:vX -o ./my-nebari -cd my-nebari -# edit the one placeholder: -$EDITOR config.yaml # set project_name (it ships as CHANGEME) -nebi run validate # rejects the unedited placeholder -nebi run deploy # validate runs first, then brings up the kind cluster -``` - -`nebi import` refuses to write into a non-empty directory when the bundle -carries asset layers, so the import-then-edit cycle cannot clobber existing -work. - -## The one field to edit - -`config.yaml` ships with `project_name: CHANGEME`. That is the only field you -must change; `validate` fails until you do (`grep -n CHANGEME config.yaml`). -Everything else (self-signed certificate, `nebari.local` domain, Linux node -selectors) is preset for local development. `git_repository` is omitted on -purpose: nic auto-creates `~/.nic/gitops/` and mounts it into the -kind cluster, so local dev is zero-config. - -## nic on PATH - -The tasks call `nic` directly. Once the prefix.dev github-releases channel is -live (#579), `pixi install` will resolve nic from `[dependencies]`. Until then, -**put a nic binary on your PATH** before running the tasks. nic does not need -kubectl (it talks to the cluster via embedded client-go); the optional `tools` -env (`pixi install -e tools`) adds `kubernetes-client` and `k9s` for -convenience on the platforms that carry a current kubectl. - -## Secrets and where filled configs belong - -Local kind needs no secrets. If you adapt this workspace, keep secrets in -environment variables, **never** in `config.yaml`. A filled config is a private -artifact: publish it only to a private registry or a nebi server, never to a -public one. diff --git a/starters/local/config.yaml b/starters/local/config.yaml deleted file mode 100644 index d583446e..00000000 --- a/starters/local/config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Nebari Infrastructure Core - local (kind) starter config -# -# The local provider runs everything in a kind cluster on your machine, so it -# needs no cloud credentials. Only project_name is a placeholder; edit it, then -# `nic validate` will pass. (grep -n CHANGEME config.yaml) - -project_name: CHANGEME -domain: nebari.local - -certificate: - type: selfsigned - -cluster: - local: - node_selectors: - general: - kubernetes.io/os: linux - user: - kubernetes.io/os: linux - worker: - kubernetes.io/os: linux - -# git_repository omitted on purpose: NIC auto-creates ~/.nic/gitops/ -# and mounts it into the kind cluster (zero-config for local dev). diff --git a/starters/local/pixi.lock b/starters/local/pixi.lock deleted file mode 100644 index 3a970ba5..00000000 --- a/starters/local/pixi.lock +++ /dev/null @@ -1,111 +0,0 @@ -version: 7 -platforms: -- name: linux-64 -- name: linux-aarch64 -- name: osx-64 -- name: osx-arm64 -environments: - default: - channels: - - url: https://conda.anaconda.org/conda-forge/ - - url: https://prefix.dev/github-releases/ - packages: {} - tools: - channels: - - url: https://conda.anaconda.org/conda-forge/ - - url: https://prefix.dev/github-releases/ - packages: - linux-64: - - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/k9s-0.51.0-h643be8f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/kubernetes-client-1.34.3-h6950dcc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/k9s-0.51.0-hfb368cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/kubernetes-client-1.34.3-h91756fb_0.conda -packages: -- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - build_number: 20 - sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 - md5: a9f577daf3de00bca7c3c76c0ecbd1de - depends: - - __glibc >=2.17,<3.0.a0 - - libgomp >=7.5.0 - constrains: - - openmp_impl <0.0a0 - license: BSD-3-Clause - license_family: BSD - run_exports: - strong: - - _openmp_mutex >=4.5 - size: 28948 - timestamp: 1770939786096 -- conda: https://conda.anaconda.org/conda-forge/linux-64/k9s-0.51.0-h643be8f_0.conda - sha256: a3237f76b3057d38afcb575c7486671c35794bd44e062ef7a29ff030d66fabdb - md5: 768c676ef74f1e5f1b4227b74a7917ee - depends: - - __glibc >=2.17,<3.0.a0 - license: Apache-2.0 - license_family: APACHE - run_exports: {} - size: 72731742 - timestamp: 1780730847822 -- conda: https://conda.anaconda.org/conda-forge/linux-64/kubernetes-client-1.34.3-h6950dcc_0.conda - sha256: 1d64241b9bc5bd6513094543b98e19ce80918b9df392cee6e001aebaafadcc8e - md5: 839902381c88deb7c5524fa7c61b7aed - depends: - - __glibc >=2.17 - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: Apache-2.0 - license_family: APACHE - run_exports: {} - size: 26335888 - timestamp: 1765415822485 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f - md5: 5a7d954665c707c93311657cd779c705 - depends: - - __glibc >=2.17,<3.0.a0 - - _openmp_mutex >=4.5 - constrains: - - libgomp 16.1.0 he0feb66_1 - - libgcc-ng ==16.1.0=*_1 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: {} - size: 1057877 - timestamp: 1785375436766 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 - md5: 88f2d91cb1533194c323534253094d23 - depends: - - __glibc >=2.17,<3.0.a0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: - strong: - - _openmp_mutex >=4.5 - size: 640415 - timestamp: 1785375373755 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/k9s-0.51.0-hfb368cc_0.conda - sha256: 386e6400587ed43562603437af0f07a6a2187d7a96360c19f103a426278f0f22 - md5: 251f7f00e493efe79dfd745c9900d86a - depends: - - __osx >=11.0 - license: Apache-2.0 - license_family: APACHE - run_exports: {} - size: 71515912 - timestamp: 1780730928833 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/kubernetes-client-1.34.3-h91756fb_0.conda - sha256: 235f0748c94b9174d2597c4925cf2a3cbd0a2d6b97d6c30d1e7585fe6203e421 - md5: 03d17ebf4f8347030434a974c2c040b3 - depends: - - __osx >=11.0 - license: Apache-2.0 - license_family: APACHE - run_exports: {} - size: 24972079 - timestamp: 1765416015649 diff --git a/starters/local/pixi.toml b/starters/local/pixi.toml deleted file mode 100644 index 269f02f3..00000000 --- a/starters/local/pixi.toml +++ /dev/null @@ -1,46 +0,0 @@ -[workspace] -name = "nebari-local" -channels = ["conda-forge", "https://prefix.dev/github-releases"] -platforms = ["linux-64", "linux-aarch64", "osx-arm64", "osx-64"] -version = "0.1.0" - -[dependencies] -# nic is the centerpiece; it resolves from the prefix.dev github-releases -# channel once that channel is live (issue #579). Until then, run the tasks -# below with a nic binary on PATH. The package name is not yet settled -# (#556/#579), so keep this commented during the pilot. -# nebari-infrastructure-core = "0.11.*" -# -# The local provider does NOT use OpenTofu: it drives kind via an embedded Go -# library, so there is no `opentofu` dependency here (unlike the cloud -# starters). The default env therefore has no required deps. - -# Optional operator convenience tools. Kept OUT of the required deps because -# kubernetes-client (the kubectl binary; there is no separate `kubectl` package -# on conda-forge) lags badly on linux-aarch64 (1.24 vs 1.34 elsewhere). The -# feature is therefore scoped to the platforms that carry a current kubectl, so -# the CORE workspace still solves on all four. nic does not need kubectl - it -# talks to the cluster via embedded client-go. Enable with: pixi install -e tools -[feature.tools] -platforms = ["linux-64", "osx-arm64"] - -[feature.tools.dependencies] -kubernetes-client = ">=1.30" -k9s = ">=0.32" - -[environments] -tools = ["tools"] - -[tasks] -validate = "nic validate" -deploy = { cmd = "nic deploy", depends-on = ["validate"] } -destroy = "nic destroy" -kubeconfig = "nic kubeconfig -o kubeconfig.yaml" - -# nebi bundles workspace files as OCI asset layers (nebi >= 0.10). pixi.toml and -# pixi.lock are always included; `include` is a strict allowlist for everything -# else, so the starter ships its placeholder config.yaml and README and nothing -# a user might add later (a filled config, a kubeconfig, tfstate) leaks by -# accident. Requires nebi >= 0.10 on the publisher/importer. -[tool.nebi.bundle] -include = ["config.yaml", "README.md"] diff --git a/starters/templates/README.md.tmpl b/starters/templates/README.md.tmpl new file mode 100644 index 00000000..a864929a --- /dev/null +++ b/starters/templates/README.md.tmpl @@ -0,0 +1,34 @@ +# Nebari on __PROVIDER_TITLE__ - starter workspace + +A pinned Pixi/Nebi workspace for deploying Nebari Infrastructure Core (NIC). +It ships the toolchain, a placeholder `config.yaml`, and the deploy tasks, so a +whole deployment travels together as one versioned, lock-pinned unit. + +## Quick start + +```bash +# Fill in the placeholders (everything set to CHANGEME): +grep -n CHANGEME config.yaml +$EDITOR config.yaml + +# Install the pinned toolchain: +pixi install + +# Validate, then deploy: +pixi run validate +pixi run deploy # runs validate first (task depends-on) +``` + +`nic validate` rejects any value still containing `CHANGEME`, so an unedited +workspace fails fast instead of attempting a real deploy. + +__PROVIDER_NOTES__ + +## Pinning + +`pixi.lock` pins the exact toolchain. `nic` pins OpenTofu, and the embedded +`.terraform.lock.hcl` pins provider versions, so one lockfile transitively pins +the whole stack. Commit `pixi.lock`. + +Generated from `examples/__PROVIDER__-config.yaml`. Do not edit by hand in the +NIC repository; change the example or the templates under `starters/templates/`. diff --git a/starters/templates/pixi.toml.tmpl b/starters/templates/pixi.toml.tmpl new file mode 100644 index 00000000..9b42a73b --- /dev/null +++ b/starters/templates/pixi.toml.tmpl @@ -0,0 +1,38 @@ +[workspace] +name = "nebari-__PROVIDER__" +channels = ["conda-forge", "https://prefix.dev/github-releases"] +platforms = ["linux-64", "linux-aarch64", "osx-arm64", "osx-64"] +version = "0.1.0" + +[dependencies] +# The pinned toolchain. nic resolves from the prefix.dev github-releases +# channel; see the distribution issue for how that channel is populated. +nebari-infrastructure-core = "__NIC_VERSION__" +__PROVIDER_DEPS__ + +# Optional operator convenience tools. Kept out of the required dependencies +# because kubernetes-client (the kubectl binary) lags badly on linux-aarch64, +# which would make the whole workspace unsolvable there. nic does not need +# kubectl, it talks to the cluster via embedded client-go. +# Enable with: pixi install -e tools +[feature.tools] +platforms = ["linux-64", "osx-arm64"] + +[feature.tools.dependencies] +kubernetes-client = ">=1.30" +k9s = ">=0.32" + +[environments] +tools = ["tools"] + +[tasks] +validate = "nic validate" +deploy = { cmd = "nic deploy", depends-on = ["validate"] } +destroy = "nic destroy" +kubeconfig = "nic kubeconfig -o kubeconfig.yaml" + +# pixi.toml and pixi.lock are always bundled; include is a strict allowlist for +# everything else, so a filled config, a kubeconfig or tfstate a user creates +# later never leaks into a republished bundle. +[tool.nebi.bundle] +include = ["config.yaml", "README.md"] From e7aab8df02f27ae5c082fab2bcc6f41529a6708e Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 20 Aug 2026 08:42:47 -0300 Subject: [PATCH 4/8] starters: make CI honest and single-source the placeholder fields The validate job asserted that nic rejects an unedited starter, which is only true once the CHANGEME check (#561) is on main, so the job sat permanently red. Derive that expectation instead: the filled config is always required to validate, and since the only difference between the two is the placeholders, a passing validate on the unedited one means the feature is absent rather than the starter being wrong. Report that and carry on, and start enforcing automatically once #561 merges. Also fixes a silent generator bug. #439 moved the GitOps URL from git_repository.url to repository.existing.url, so the substitution rule stopped matching and the aws starter shipped a real repository URL. The only guard was 'some CHANGEME exists', which the other three fields satisfied. Every field must now match or generation fails loudly. The placeholder fields are declared once and both the substitution and the fill-for-CI script are derived from them, so CI no longer keeps its own copy of the field list to drift out of sync. Part of #560. --- .github/workflows/starters.yml | 52 +++++++++++++++---------- scripts/gen-starters.sh | 69 +++++++++++++++++++++------------- 2 files changed, 75 insertions(+), 46 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index 5296ecc3..2bf6dcbf 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -28,9 +28,6 @@ env: STARTER_REPO_PREFIX: starters jobs: - # NOTE: the "unedited config is rejected" assertion below only holds once the - # CHANGEME check (#561) is merged to main. Until then nic-from-main accepts - # placeholder configs and this job goes red. Expected while that PR is open. validate-starters: name: Validate starters runs-on: ubuntu-latest @@ -53,34 +50,49 @@ jobs: - name: Generate starters run: ./scripts/gen-starters.sh dist/starters - - name: Unedited starters must be rejected, filled ones must pass + - name: Starters carry placeholders and validate once filled run: | set -euo pipefail for provider in local aws; do config="dist/starters/${provider}/config.yaml" - echo "== ${provider}: asserting the unedited config is rejected ==" - if err="$(nic validate -f "$config" 2>&1)"; then - echo "FAIL: nic validate accepted the unedited ${provider} starter" - echo "$err" - exit 1 - fi - echo "$err" - echo "$err" | grep -q CHANGEME || { - echo "FAIL: ${provider} validation error did not mention CHANGEME"; exit 1; } - echo "== ${provider}: asserting a filled config passes ==" + # 1. The generator must have substituted placeholders. + echo "== ${provider}: starter carries placeholders ==" + grep -q CHANGEME "$config" || { + echo "FAIL: ${provider} starter has no CHANGEME placeholder"; exit 1; } + + # 2. A filled copy must validate. The fill script is emitted by + # the generator from the same field list it used to insert the + # placeholders, so CI never keeps its own copy to drift. + echo "== ${provider}: filled starter validates ==" filled="$(mktemp)" - sed -e "s|project_name: CHANGEME|project_name: nebari-${provider}-ci|" \ - -e "s|^domain: CHANGEME|domain: nebari.example.com|" \ - -e "s|^ email: CHANGEME| email: admin@example.com|" \ - -e "s|^ url: CHANGEME| url: git@github.com:example-org/example-gitops.git|" \ - "$config" > "$filled" + sed -f "dist/starters/${provider}/.fill-for-ci.sed" "$config" > "$filled" if grep -q CHANGEME "$filled"; then echo "FAIL: ${provider} still has placeholders after filling" + grep -n CHANGEME "$filled" exit 1 fi nic validate -f "$filled" - echo "OK: ${provider} starter behaves correctly" + + # 3. The unedited starter must not be deployable. The CHANGEME + # rejection lands with #561; until it is on main nic accepts + # placeholder configs. Since step 2 just proved the only + # difference is the placeholders, a passing validate here means + # the feature is absent rather than that the starter is wrong, + # so report it instead of failing. This starts enforcing on its + # own the moment #561 merges. + echo "== ${provider}: unedited starter is not deployable ==" + if err="$(nic validate -f "$config" 2>&1)"; then + echo "PENDING: nic on this ref does not reject placeholders yet (#561)." + echo " Placeholder presence and the filled config were still checked." + elif echo "$err" | grep -q CHANGEME; then + echo "$err" + echo "OK: unedited ${provider} starter rejected, error names CHANGEME" + else + echo "FAIL: ${provider} unedited starter failed for an unexpected reason" + echo "$err" + exit 1 + fi done publish-starters: diff --git a/scripts/gen-starters.sh b/scripts/gen-starters.sh index 006fe4c8..f9bcc23b 100755 --- a/scripts/gen-starters.sh +++ b/scripts/gen-starters.sh @@ -15,25 +15,28 @@ TEMPLATES="starters/templates" NIC_VERSION="${NIC_VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}" : "${NIC_VERSION:?could not determine NIC_VERSION and none was supplied}" -# Providers in scope. Extend deliberately: each one needs its placeholder rules -# and any provider-specific dependencies below. +# Providers in scope. Extend deliberately: each one needs its placeholder +# fields and any provider-specific dependencies below. PROVIDERS=("local" "aws") -# Which fields become CHANGEME, per provider. These are the identity-bearing -# values a user must supply; everything else stays a working default. Applied -# as line edits so the examples' inline comments survive intact. -placeholder_rules() { +# The identity-bearing fields a user must supply, declared once per provider as +# "###" +# Both the CHANGEME substitution and the fill-for-CI script are derived from +# this one list, so they cannot drift apart. Everything else in the example +# stays a working default. Applied as line edits, so the inline comments that +# make the examples useful survive into the starter. +placeholder_fields() { case "$1" in local) printf '%s\n' \ - 's|^project_name: .*|project_name: CHANGEME|' + 'project_name: ###nebari-local-ci' ;; aws) printf '%s\n' \ - 's|^project_name: .*|project_name: CHANGEME|' \ - 's|^domain: .*|domain: CHANGEME|' \ - 's|^ email: .*| email: CHANGEME|' \ - 's|^ url: .*| url: CHANGEME|' + 'project_name: ###nebari-aws-ci' \ + 'domain: ###nebari.example.com' \ + ' email: ###admin@example.com' \ + ' url: ###git@github.com:example-org/example-gitops.git' ;; esac } @@ -71,11 +74,11 @@ EOF cat <<'EOF' ## What you must edit -`project_name`, `domain`, `certificate.acme.email` and `git_repository.url`. -The infrastructure defaults (region, availability zones, instance types, -Longhorn, EFS) are working values, not placeholders. **If you change `region`, -change `availability_zones` to match**: `nic validate` does not cross-check -them and a mismatch fails mid-deploy. +`project_name`, `domain`, the ACME email and the GitOps repository URL. The +infrastructure defaults (region, availability zones, instance types, Longhorn, +EFS) are working values, not placeholders. **If you change `region`, change +`availability_zones` to match**: `nic validate` does not cross-check them and a +mismatch fails mid-deploy. `nic validate` runs offline and needs no AWS credentials. It will not catch a bad region, a nonexistent instance type or an availability zone that does not @@ -98,23 +101,37 @@ for provider in "${PROVIDERS[@]}"; do dest="${OUT_DIR}/${provider}" mkdir -p "$dest" - # config.yaml: the example with identity-bearing values replaced. cp "$src" "$dest/config.yaml" - while IFS= read -r rule; do - [ -n "$rule" ] && sed -i "$rule" "$dest/config.yaml" - done < <(placeholder_rules "$provider") - - # Fail loudly rather than publishing a starter that would validate as-is. - grep -q CHANGEME "$dest/config.yaml" || { - echo "no placeholders were substituted for $provider" >&2; exit 1; } + fill_script="${dest}/.fill-for-ci.sed" + : > "$fill_script" + + while IFS= read -r field; do + [ -n "$field" ] || continue + prefix="${field%%###*}" + value="${field##*###}" + + before="$(cat "$dest/config.yaml")" + sed -i "s|^${prefix}.*|${prefix}CHANGEME|" "$dest/config.yaml" + # Every field must match something. examples/ is upstream of this + # generator, so a restructure there (a key moving or being renamed) would + # otherwise silently ship a starter with a real value left in it. + if [ "$before" = "$(cat "$dest/config.yaml")" ]; then + echo "placeholder field matched nothing for ${provider}: '${prefix}'" >&2 + echo "examples/${provider}-config.yaml has probably been restructured" >&2 + exit 1 + fi + + # The inverse edit, so CI can prove a filled starter validates without + # keeping its own copy of the field list. + printf 's|^%sCHANGEME$|%s%s|\n' "$prefix" "$prefix" "$value" >> "$fill_script" + done < <(placeholder_fields "$provider") sed -e "s|__PROVIDER__|${provider}|g" \ -e "s|__NIC_VERSION__|${NIC_VERSION}|g" \ -e "s|__PROVIDER_DEPS__|$(provider_deps "$provider")|g" \ "$TEMPLATES/pixi.toml.tmpl" > "$dest/pixi.toml" - notes="$(provider_notes "$provider")" - awk -v notes="$notes" \ + awk -v notes="$(provider_notes "$provider")" \ -v provider="$provider" \ -v title="$(provider_title "$provider")" \ '{gsub(/__PROVIDER_NOTES__/, notes); gsub(/__PROVIDER_TITLE__/, title); gsub(/__PROVIDER__/, provider); print}' \ From 9fbf5ed4bebf9f693e09cf1c26c0e52d2d3be687 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 20 Aug 2026 08:59:33 -0300 Subject: [PATCH 5/8] starters: commit the READMEs and fix generator portability bugs The starter READMEs have no version-varying content, so generating them bought nothing: two committed files replace a template, three of the five substitution tokens, an awk render, and two case statements. The generator copies them verbatim. This also removes the awk gsub hazard where an ampersand in the prose would silently rewrite itself, and the stale line telling readers to edit a template that no longer exists. Three bugs found while reviewing: The NIC_VERSION fallback message was unreachable. Under set -e the failed command substitution aborts the script with exit 128 before the :? guard runs, so a clone without tags failed silently. sed -i without a backup suffix is GNU-only, so every run failed for contributors on macOS. Write to a temporary file and move it into place. repository.existing.path embedded the project name (clusters/my-nebari-aws) with no placeholder rule, so the aws starter shipped CHANGEME for project_name next to a hardcoded one. It is now a placeholder like the rest. Adding a provider to PROVIDERS without a placeholder_fields arm used to produce a starter with every real value intact; generation now fails when a provider declares no fields. Part of #560. --- scripts/gen-starters.sh | 65 +++++-------------- starters/templates/README.aws.md | 49 ++++++++++++++ .../{README.md.tmpl => README.local.md} | 11 ++-- 3 files changed, 71 insertions(+), 54 deletions(-) create mode 100644 starters/templates/README.aws.md rename starters/templates/{README.md.tmpl => README.local.md} (71%) diff --git a/scripts/gen-starters.sh b/scripts/gen-starters.sh index f9bcc23b..7bbddc57 100755 --- a/scripts/gen-starters.sh +++ b/scripts/gen-starters.sh @@ -12,7 +12,7 @@ set -euo pipefail OUT_DIR="${1:-dist/starters}" TEMPLATES="starters/templates" # Version the starter pins nic to. Defaults to the latest tag, minus the "v". -NIC_VERSION="${NIC_VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}" +NIC_VERSION="${NIC_VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)}" : "${NIC_VERSION:?could not determine NIC_VERSION and none was supplied}" # Providers in scope. Extend deliberately: each one needs its placeholder @@ -36,7 +36,8 @@ placeholder_fields() { 'project_name: ###nebari-aws-ci' \ 'domain: ###nebari.example.com' \ ' email: ###admin@example.com' \ - ' url: ###git@github.com:example-org/example-gitops.git' + ' url: ###git@github.com:example-org/example-gitops.git' \ + ' path: ###clusters/nebari-aws-ci' ;; esac } @@ -52,48 +53,6 @@ provider_deps() { esac } -provider_title() { - case "$1" in - local) printf 'local (kind)' ;; - aws) printf 'AWS' ;; - esac -} - -provider_notes() { - case "$1" in - local) - cat <<'EOF' -## What you must edit - -Only `project_name`. The local provider runs everything in a kind cluster on -your machine, so it needs no cloud credentials, the certificate is self-signed -and the GitOps repository is created for you. -EOF - ;; - aws) - cat <<'EOF' -## What you must edit - -`project_name`, `domain`, the ACME email and the GitOps repository URL. The -infrastructure defaults (region, availability zones, instance types, Longhorn, -EFS) are working values, not placeholders. **If you change `region`, change -`availability_zones` to match**: `nic validate` does not cross-check them and a -mismatch fails mid-deploy. - -`nic validate` runs offline and needs no AWS credentials. It will not catch a -bad region, a nonexistent instance type or an availability zone that does not -exist in your region; those surface at deploy time. - -## Cost note - -This starter inherits the production-recommended shape, including dedicated -Longhorn storage nodes with large gp3 volumes and EFS. That is a real monthly -bill; trim the node groups for experiments. -EOF - ;; - esac -} - mkdir -p "$OUT_DIR" for provider in "${PROVIDERS[@]}"; do src="examples/${provider}-config.yaml" @@ -105,13 +64,15 @@ for provider in "${PROVIDERS[@]}"; do fill_script="${dest}/.fill-for-ci.sed" : > "$fill_script" + matched=0 while IFS= read -r field; do [ -n "$field" ] || continue prefix="${field%%###*}" value="${field##*###}" before="$(cat "$dest/config.yaml")" - sed -i "s|^${prefix}.*|${prefix}CHANGEME|" "$dest/config.yaml" + sed "s|^${prefix}.*|${prefix}CHANGEME|" "$dest/config.yaml" > "$dest/config.yaml.tmp" + mv "$dest/config.yaml.tmp" "$dest/config.yaml" # Every field must match something. examples/ is upstream of this # generator, so a restructure there (a key moving or being renamed) would # otherwise silently ship a starter with a real value left in it. @@ -124,18 +85,22 @@ for provider in "${PROVIDERS[@]}"; do # The inverse edit, so CI can prove a filled starter validates without # keeping its own copy of the field list. printf 's|^%sCHANGEME$|%s%s|\n' "$prefix" "$prefix" "$value" >> "$fill_script" + matched=$((matched + 1)) done < <(placeholder_fields "$provider") + # A provider added to PROVIDERS without a placeholder_fields arm would + # otherwise ship a starter with every real value intact. + if [ "$matched" -eq 0 ]; then + echo "no placeholder fields declared for ${provider}" >&2 + exit 1 + fi + sed -e "s|__PROVIDER__|${provider}|g" \ -e "s|__NIC_VERSION__|${NIC_VERSION}|g" \ -e "s|__PROVIDER_DEPS__|$(provider_deps "$provider")|g" \ "$TEMPLATES/pixi.toml.tmpl" > "$dest/pixi.toml" - awk -v notes="$(provider_notes "$provider")" \ - -v provider="$provider" \ - -v title="$(provider_title "$provider")" \ - '{gsub(/__PROVIDER_NOTES__/, notes); gsub(/__PROVIDER_TITLE__/, title); gsub(/__PROVIDER__/, provider); print}' \ - "$TEMPLATES/README.md.tmpl" > "$dest/README.md" + cp "$TEMPLATES/README.${provider}.md" "$dest/README.md" echo "generated $dest (nic ${NIC_VERSION})" done diff --git a/starters/templates/README.aws.md b/starters/templates/README.aws.md new file mode 100644 index 00000000..75c72860 --- /dev/null +++ b/starters/templates/README.aws.md @@ -0,0 +1,49 @@ +# Nebari on AWS - starter workspace + +A pinned Pixi/Nebi workspace for deploying Nebari Infrastructure Core (NIC). +It ships the toolchain, a placeholder `config.yaml`, and the deploy tasks, so a +whole deployment travels together as one versioned, lock-pinned unit. + +## Quick start + +```bash +# Fill in the placeholders (everything set to CHANGEME): +grep -n CHANGEME config.yaml +$EDITOR config.yaml + +# Install the pinned toolchain: +pixi install + +# Validate, then deploy: +pixi run validate +pixi run deploy # runs validate first (task depends-on) +``` + +`nic validate` rejects any value still containing `CHANGEME`, so an unedited +workspace fails fast instead of attempting a real deploy. + +## What you must edit + +`project_name`, `domain`, the ACME email and the GitOps repository URL. The +infrastructure defaults (region, availability zones, instance types, Longhorn, +EFS) are working values, not placeholders. **If you change `region`, change +`availability_zones` to match**: `nic validate` does not cross-check them and a +mismatch fails mid-deploy. + +`nic validate` runs offline and needs no AWS credentials. It will not catch a +bad region, a nonexistent instance type or an availability zone that does not +exist in your region; those surface at deploy time. + +## Cost note + +This starter inherits the production-recommended shape, including dedicated +Longhorn storage nodes with large gp3 volumes and EFS. That is a real monthly +bill; trim the node groups for experiments. + +## Pinning + +`pixi.lock` pins the exact toolchain. `nic` pins OpenTofu, and the embedded +`.terraform.lock.hcl` pins provider versions, so one lockfile transitively pins +the whole stack. Commit `pixi.lock`. + +This file is copied verbatim into the published starter. Edit it here. diff --git a/starters/templates/README.md.tmpl b/starters/templates/README.local.md similarity index 71% rename from starters/templates/README.md.tmpl rename to starters/templates/README.local.md index a864929a..21083f5e 100644 --- a/starters/templates/README.md.tmpl +++ b/starters/templates/README.local.md @@ -1,4 +1,4 @@ -# Nebari on __PROVIDER_TITLE__ - starter workspace +# Nebari on local (kind) - starter workspace A pinned Pixi/Nebi workspace for deploying Nebari Infrastructure Core (NIC). It ships the toolchain, a placeholder `config.yaml`, and the deploy tasks, so a @@ -22,7 +22,11 @@ pixi run deploy # runs validate first (task depends-on) `nic validate` rejects any value still containing `CHANGEME`, so an unedited workspace fails fast instead of attempting a real deploy. -__PROVIDER_NOTES__ +## What you must edit + +Only `project_name`. The local provider runs everything in a kind cluster on +your machine, so it needs no cloud credentials, the certificate is self-signed +and the GitOps repository is created for you. ## Pinning @@ -30,5 +34,4 @@ __PROVIDER_NOTES__ `.terraform.lock.hcl` pins provider versions, so one lockfile transitively pins the whole stack. Commit `pixi.lock`. -Generated from `examples/__PROVIDER__-config.yaml`. Do not edit by hand in the -NIC repository; change the example or the templates under `starters/templates/`. +This file is copied verbatim into the published starter. Edit it here. From b868442907f2888005fe86cb123fac326bb2a6eb Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 20 Aug 2026 09:05:37 -0300 Subject: [PATCH 6/8] ci(starters): publish on tags, gate the credentialed job, assert plainly Publishing ran on every main push and tagged with git describe, which resolves to the PREVIOUS tag, so any post-release edit to an example silently republished an already-released bundle with different bytes. A version tag has to keep meaning the same thing, so publishing is now driven by the tag event and takes its version from GITHUB_REF_NAME. The publish job holds registry credentials and had none of the protections every other credentialed job here uses. It now runs under an environment gate, declares a concurrency group that is never cancelled mid-push so a tag cannot end up with one starter published and the other not, and takes its secrets at step level rather than leaving them in scope for every step. The pixi installer is pinned to a commit and an exact version, matching the k3d pin in the deployment tests, and nebi is pinned rather than floating. The credentials check that skipped publishing when QUAY_TOKEN was absent made a rotated or renamed secret look like a passing build; the job now fails if the environment does not supply them. Dropped the conditional that reported PENDING when nic could not yet reject placeholders. It described a temporary state as a permanent branch, and the check it stood in for is the whole point of the job. This is red until the CHANGEME rejection lands and green immediately after. Added the two checks that were missing: no unsubstituted template tokens reach the registry, and every generated pixi.toml parses before publish. The round trip now also re-validates the imported config rather than only grepping it. Part of #560. --- .github/workflows/starters.yml | 158 +++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 57 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index 2bf6dcbf..a971eda2 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -16,6 +16,11 @@ on: - "examples/local-config.yaml" - "examples/aws-config.yaml" - ".github/workflows/starters.yml" + # Publishing is tag-driven: a starter tagged v must keep meaning the + # same bytes forever, and a main push resolves to the PREVIOUS tag, so + # publishing from main would silently rewrite an already-released bundle. + tags: + - "v*" workflow_dispatch: permissions: @@ -31,6 +36,7 @@ jobs: validate-starters: name: Validate starters runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -43,29 +49,59 @@ jobs: go-version-file: go.mod - name: Build nic and put it on PATH + shell: bash run: | + set -euo pipefail make build echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" - name: Generate starters - run: ./scripts/gen-starters.sh dist/starters + shell: bash + run: | + set -euo pipefail + ./scripts/gen-starters.sh dist/starters + + - name: Rendered starters are complete + shell: bash + run: | + set -euo pipefail + # A substitution token that failed to expand would otherwise ship to + # the registry, and a malformed pixi.toml would not surface until the + # publish job runs pixi lock. + if grep -rn '__[A-Z_]*__' dist/starters/; then + echo "FAIL: unsubstituted template tokens in the rendered starters" + exit 1 + fi + for provider in local aws; do + python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' \ + "dist/starters/${provider}/pixi.toml" + done + echo "OK: no leftover tokens, every pixi.toml parses" - - name: Starters carry placeholders and validate once filled + - name: Unedited starters are rejected, filled ones validate + shell: bash run: | set -euo pipefail for provider in local aws; do config="dist/starters/${provider}/config.yaml" - # 1. The generator must have substituted placeholders. - echo "== ${provider}: starter carries placeholders ==" - grep -q CHANGEME "$config" || { - echo "FAIL: ${provider} starter has no CHANGEME placeholder"; exit 1; } + echo "== ${provider}: the unedited starter must be rejected ==" + if err="$(nic validate -f "$config" 2>&1)"; then + echo "FAIL: nic validate accepted the unedited ${provider} starter" + echo "$err" + exit 1 + fi + echo "$err" | grep -q CHANGEME || { + echo "FAIL: ${provider} validation error did not mention CHANGEME" + echo "$err" + exit 1; } - # 2. A filled copy must validate. The fill script is emitted by - # the generator from the same field list it used to insert the - # placeholders, so CI never keeps its own copy to drift. - echo "== ${provider}: filled starter validates ==" + echo "== ${provider}: a filled starter must validate ==" + # The fill script is emitted by the generator from the same field + # list it used to insert the placeholders, so CI keeps no copy of + # its own to drift. filled="$(mktemp)" + trap 'rm -f "$filled"' EXIT sed -f "dist/starters/${provider}/.fill-for-ci.sed" "$config" > "$filled" if grep -q CHANGEME "$filled"; then echo "FAIL: ${provider} still has placeholders after filling" @@ -73,73 +109,74 @@ jobs: exit 1 fi nic validate -f "$filled" - - # 3. The unedited starter must not be deployable. The CHANGEME - # rejection lands with #561; until it is on main nic accepts - # placeholder configs. Since step 2 just proved the only - # difference is the placeholders, a passing validate here means - # the feature is absent rather than that the starter is wrong, - # so report it instead of failing. This starts enforcing on its - # own the moment #561 merges. - echo "== ${provider}: unedited starter is not deployable ==" - if err="$(nic validate -f "$config" 2>&1)"; then - echo "PENDING: nic on this ref does not reject placeholders yet (#561)." - echo " Placeholder presence and the filled config were still checked." - elif echo "$err" | grep -q CHANGEME; then - echo "$err" - echo "OK: unedited ${provider} starter rejected, error names CHANGEME" - else - echo "FAIL: ${provider} unedited starter failed for an unexpected reason" - echo "$err" - exit 1 - fi + echo "OK: ${provider} starter behaves correctly" done publish-starters: name: Publish starters to quay.io needs: validate-starters - if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + # Tag builds only. workflow_dispatch is allowed for a manual re-publish but + # the environment below restricts which refs may actually run it. + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - env: - QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} - QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} + timeout-minutes: 20 + # Approval + deployment-branch gate, matching every other credentialed job + # in this repo (release.yml, the deployment-tests cloud jobs). + environment: quay-publish + concurrency: + # Publishing mutates a shared registry; never cancel a run mid-push or a + # tag can end up with one starter published and the other not. + group: publish-starters + cancel-in-progress: false steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - name: Skip when no quay credentials are configured - id: creds + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Build nic and put it on PATH + shell: bash run: | - if [ -z "${QUAY_TOKEN:-}" ]; then - echo "no quay credentials, skipping publish" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi + set -euo pipefail + make build + echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" - name: Install pixi and nebi - if: steps.creds.outputs.skip == 'false' + shell: bash run: | set -euo pipefail - curl -fsSL https://pixi.sh/install.sh -o /tmp/pixi-install.sh - sh /tmp/pixi-install.sh + # Installer pinned to the v0.77.0 tag commit; PIXI_VERSION pins the + # binary it fetches. Same shape as the k3d pin in deployment-tests. + curl -fsSL https://raw.githubusercontent.com/prefix-dev/pixi/e3c26fbf7d8294d4ddbe7c913b4f8e60bcbafe5a/install/install.sh \ + | PIXI_VERSION=v0.77.0 bash echo "$HOME/.pixi/bin" >> "$GITHUB_PATH" export PATH="$HOME/.pixi/bin:$PATH" - # Asset-layer bundling (config.yaml and README travelling with the + # Asset-layer bundling (config.yaml and the README travelling with the # workspace) needs nebi >= 0.10; older versions silently ship only - # pixi.toml and pixi.lock. - pixi global install "nebi>=0.10" + # pixi.toml and pixi.lock. Pinned so a credentialed job never resolves + # a floating dependency at run time. + pixi global install "nebi==0.13" - name: Generate starters - if: steps.creds.outputs.skip == 'false' - run: ./scripts/gen-starters.sh dist/starters + shell: bash + run: | + set -euo pipefail + ./scripts/gen-starters.sh dist/starters - name: Configure quay registry - if: steps.creds.outputs.skip == 'false' + shell: bash + env: + QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} + QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} run: | set -euo pipefail + : "${QUAY_USERNAME:?QUAY_USERNAME is not set for this environment}" + : "${QUAY_TOKEN:?QUAY_TOKEN is not set for this environment}" printf '%s' "$QUAY_TOKEN" | nebi registry add --local \ --name quay \ --url quay.io \ @@ -148,10 +185,10 @@ jobs: --password-stdin - name: Lock and publish each starter - if: steps.creds.outputs.skip == 'false' + shell: bash run: | set -euo pipefail - version="$(git describe --tags --abbrev=0 | sed 's/^v//')" + version="${GITHUB_REF_NAME#v}" for provider in local aws; do pushd "dist/starters/${provider}" > /dev/null # Resolve the toolchain now so the published bundle carries a lock. @@ -164,15 +201,22 @@ jobs: popd > /dev/null done - - name: Imported starters must still be rejected unedited - if: steps.creds.outputs.skip == 'false' + - name: Imported starters still carry their placeholders + shell: bash run: | set -euo pipefail - version="$(git describe --tags --abbrev=0 | sed 's/^v//')" + version="${GITHUB_REF_NAME#v}" for provider in local aws; do dest="/tmp/imported-${provider}" nebi import "quay.io/${QUAY_NAMESPACE}/${STARTER_REPO_PREFIX}/starter-${provider}:v${version}" -o "$dest" test -f "$dest/config.yaml" + test -f "$dest/README.md" grep -q CHANGEME "$dest/config.yaml" - echo "OK: ${provider} round-tripped with its placeholders intact" + # The published bundle must be rejected unedited, same as the + # freshly generated one. + if nic validate -f "$dest/config.yaml"; then + echo "FAIL: published ${provider} starter validates unedited" + exit 1 + fi + echo "OK: ${provider} round-tripped and is still not deployable as-is" done From baf0d6691385c64ab4e352cba4d85a459813dd2c Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Fri, 21 Aug 2026 07:41:21 -0300 Subject: [PATCH 7/8] ci(starters): close the dispatch publish hole, fix the pins and the assertions Addresses the review findings on this branch. Publishing is now tag-only. workflow_dispatch could target any ref: from a branch it published starter-*:vmain and pinned a version read from the PREVIOUS tag, and from an existing tag it overwrote a released bundle - the rewrite the trigger comment says must never happen. Deployment-branch rules live in repo settings and cannot be reviewed from the workflow file, so the guard belongs in the `if`. A manual re-publish is a re-run of the tag's run. Wires up the real credentials: QUAY_OCI_STARTERS_USERNAME is a variable, so it reads from vars, and QUAY_OCI_STARTERS_TOKEN from secrets. The OpenTofu floor was wrong in a way that defeats its own purpose. `>=1.11,<2` admits 1.11.0-1.11.2, which pkg/tofu.MinVersion (1.11.3) rejects - and rejection is a silent fallback to downloading tofu, not an error, so a workspace that re-solves against the lower bound phones home despite pinning its toolchain. Floor raised to 1.11.3. The anti-drift guard only proved a prefix matched SOMETHING. It now requires exactly one match: zero means a key was renamed and a real value would ship untouched, more than one means a same-named key appeared at the same indent and blanking both would bury a real value under a plausible-looking placeholder. Failures accumulate and report together, per the report-and-block convention - a restructure of examples/ usually moves more than one key. Placeholder edits now keep the trailing comment on the line they replace, so `path: CHANGEME # Optional subdirectory within the repository` survives. The comments were being stripped from precisely the five lines the reader has to edit, while the header comment claimed they survived. Replaces the .fill-for-ci.sed mechanism with assertions that are not derived from the generator's own field list. Filling the placeholders back in and checking the result validates mostly re-proved pkg/nic/examples_test.go plus "sed round-trips", and both halves came from the same list, so a prefix that matched the wrong line passed. CI now asserts the error comes from the placeholder gate (`placeholder value`, which only appears after a successful unmarshal) and names exactly the expected field paths, which nic derives from the parsed config. Note that grepping the message for CHANGEME alone cannot carry this: go-yaml quotes the offending source line, so a parse error on a CHANGEME line mentions it too. Also: `make starters`, README.local.md no longer documents OpenTofu for the one provider that has none, README.aws.md lists the fifth placeholder field it was missing, ::error:: annotations instead of bare FAIL echoes, an explicit channel for the nebi install in the credentialed job (with a floor-plus-ceiling so patch releases resolve), and a note that dist/ is also GoReleaser's output dir. --- .github/workflows/starters.yml | 78 ++++++++++++++++--------- .gitignore | 4 +- Makefile | 6 +- scripts/gen-starters.sh | 94 +++++++++++++++++++----------- starters/templates/README.aws.md | 3 +- starters/templates/README.local.md | 6 +- 6 files changed, 123 insertions(+), 68 deletions(-) diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml index a971eda2..7b65353a 100644 --- a/.github/workflows/starters.yml +++ b/.github/workflows/starters.yml @@ -69,7 +69,7 @@ jobs: # the registry, and a malformed pixi.toml would not surface until the # publish job runs pixi lock. if grep -rn '__[A-Z_]*__' dist/starters/; then - echo "FAIL: unsubstituted template tokens in the rendered starters" + echo "::error::unsubstituted template tokens in the rendered starters" exit 1 fi for provider in local aws; do @@ -87,37 +87,56 @@ jobs: echo "== ${provider}: the unedited starter must be rejected ==" if err="$(nic validate -f "$config" 2>&1)"; then - echo "FAIL: nic validate accepted the unedited ${provider} starter" + echo "::error::nic validate accepted the unedited ${provider} starter" echo "$err" exit 1 fi - echo "$err" | grep -q CHANGEME || { - echo "FAIL: ${provider} validation error did not mention CHANGEME" + # nic logs errors as JSON, so the quotes in the message arrive + # backslash-escaped; drop the escapes before matching on them. + plain="$(printf '%s' "$err" | tr -d '\\')" + + echo "$plain" | grep -q CHANGEME || { + echo "::error::${provider} validation error did not mention CHANGEME" echo "$err" exit 1; } - echo "== ${provider}: a filled starter must validate ==" - # The fill script is emitted by the generator from the same field - # list it used to insert the placeholders, so CI keeps no copy of - # its own to drift. - filled="$(mktemp)" - trap 'rm -f "$filled"' EXIT - sed -f "dist/starters/${provider}/.fill-for-ci.sed" "$config" > "$filled" - if grep -q CHANGEME "$filled"; then - echo "FAIL: ${provider} still has placeholders after filling" - grep -n CHANGEME "$filled" - exit 1 - fi - nic validate -f "$filled" - echo "OK: ${provider} starter behaves correctly" + # Rejection alone is not enough, and neither is grepping the + # message for CHANGEME: go-yaml quotes the offending source line in + # a parse error, so sed surgery that breaks the YAML produces an + # error that mentions CHANGEME too. "placeholder value" only comes + # from the placeholder gate, which runs after a successful + # unmarshal, so matching it proves the config still parses. + echo "$plain" | grep -q 'placeholder value' || { + echo "::error::${provider} was rejected, but not by the placeholder gate - the config probably no longer parses" + echo "$err" + exit 1; } + + # The expected fields are written out here on purpose. nic derives + # these paths from the parsed config, so asserting them is + # independent of the generator's own field list - a prefix that + # matched the wrong line lands on a different path and fails here. + case "$provider" in + local) want='field "project_name"' ;; + aws) want='fields "certificate.acme.email", "domain", "project_name", "repository.existing.path", "repository.existing.url"' ;; + esac + echo "$plain" | grep -qF "$want" || { + echo "::error::${provider} placeholdered the wrong fields; wanted ${want}" + echo "$err" + exit 1; } + echo "OK: ${provider} is rejected unedited, parses, and placeholders exactly the expected fields" done publish-starters: name: Publish starters to quay.io needs: validate-starters - # Tag builds only. workflow_dispatch is allowed for a manual re-publish but - # the environment below restricts which refs may actually run it. - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + # Tag builds only, and deliberately NOT workflow_dispatch. A dispatch can + # target any ref: from a branch it would publish starter-*:vmain (and pin a + # version from the PREVIOUS tag, since gen-starters.sh reads git describe), + # and from an existing tag it would overwrite a released bundle - the exact + # rewrite the trigger comment above says must never happen. Deployment-branch + # rules live in repo settings and cannot be reviewed from this file, so the + # guard belongs here. A manual re-publish is a re-run of the tag's own run. + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 20 # Approval + deployment-branch gate, matching every other credentialed job @@ -160,7 +179,10 @@ jobs: # workspace) needs nebi >= 0.10; older versions silently ship only # pixi.toml and pixi.lock. Pinned so a credentialed job never resolves # a floating dependency at run time. - pixi global install "nebi==0.13" + # Floor plus ceiling rather than ==, so patch releases are allowed. + # Channel pinned explicitly: this job holds the Quay push token, and + # an unpinned channel decides which build of nebi runs beside it. + pixi global install -c conda-forge "nebi>=0.13,<0.14" - name: Generate starters shell: bash @@ -171,12 +193,14 @@ jobs: - name: Configure quay registry shell: bash env: - QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} - QUAY_TOKEN: ${{ secrets.QUAY_TOKEN }} + # Username is a variable, token is a secret; both scoped to the + # quay-publish environment. + QUAY_USERNAME: ${{ vars.QUAY_OCI_STARTERS_USERNAME }} + QUAY_TOKEN: ${{ secrets.QUAY_OCI_STARTERS_TOKEN }} run: | set -euo pipefail - : "${QUAY_USERNAME:?QUAY_USERNAME is not set for this environment}" - : "${QUAY_TOKEN:?QUAY_TOKEN is not set for this environment}" + : "${QUAY_USERNAME:?QUAY_OCI_STARTERS_USERNAME is not set for this environment}" + : "${QUAY_TOKEN:?QUAY_OCI_STARTERS_TOKEN is not set for this environment}" printf '%s' "$QUAY_TOKEN" | nebi registry add --local \ --name quay \ --url quay.io \ @@ -215,7 +239,7 @@ jobs: # The published bundle must be rejected unedited, same as the # freshly generated one. if nic validate -f "$dest/config.yaml"; then - echo "FAIL: published ${provider} starter validates unedited" + echo "::error::published ${provider} starter validates unedited" exit 1 fi echo "OK: ${provider} round-tripped and is still not deployable as-is" diff --git a/.gitignore b/.gitignore index d0d09139..3fcdf0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,7 @@ docs/plans/ # Test Configs test-configs/ -# Generated starter workspaces (published to a registry, never committed) +# Generated starter workspaces (published to a registry, never committed). +# Also GoReleaser's output dir, so `make release-snapshot` (--clean) wipes any +# starters generated here - regenerate them with `make starters` afterwards. dist/ diff --git a/Makefile b/Makefile index 77f55ba4..7ca11c0a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build test test-unit test-integration test-coverage test-race clean fmt vet lint vuln install pre-commit release-snapshot docs +.PHONY: help build test test-unit test-integration test-coverage test-race clean fmt vet lint vuln install pre-commit release-snapshot docs starters # Variables BINARY_NAME=nic @@ -27,6 +27,10 @@ docs: ## Generate CLI and configuration reference documentation @rm -f docs/reference/cli/*.md docs/configuration/*.md go run ./cmd/docgen +starters: ## Generate the Nebi starter workspaces into dist/starters + @echo "Generating starters..." + ./scripts/gen-starters.sh dist/starters + build-all: ## Build binaries for all platforms @echo "Building for all platforms..." CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath $(LDFLAGS) -o $(BINARY_NAME)-linux-amd64 $(CMD_DIR) diff --git a/scripts/gen-starters.sh b/scripts/gen-starters.sh index 7bbddc57..e1ca575c 100755 --- a/scripts/gen-starters.sh +++ b/scripts/gen-starters.sh @@ -20,24 +20,26 @@ NIC_VERSION="${NIC_VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed ' PROVIDERS=("local" "aws") # The identity-bearing fields a user must supply, declared once per provider as -# "###" -# Both the CHANGEME substitution and the fill-for-CI script are derived from -# this one list, so they cannot drift apart. Everything else in the example -# stays a working default. Applied as line edits, so the inline comments that -# make the examples useful survive into the starter. +# the line prefix up to and including the colon and space. Everything else in +# the example stays a working default. +# +# Applied as line edits rather than a YAML round trip, so the inline comments +# that make the examples useful survive into the starter - including the comment +# on a replaced line, which is preserved and reattached (those five lines are +# exactly the ones whose hint the reader needs most). placeholder_fields() { case "$1" in local) printf '%s\n' \ - 'project_name: ###nebari-local-ci' + 'project_name: ' ;; aws) printf '%s\n' \ - 'project_name: ###nebari-aws-ci' \ - 'domain: ###nebari.example.com' \ - ' email: ###admin@example.com' \ - ' url: ###git@github.com:example-org/example-gitops.git' \ - ' path: ###clusters/nebari-aws-ci' + 'project_name: ' \ + 'domain: ' \ + ' email: ' \ + ' url: ' \ + ' path: ' ;; esac } @@ -49,52 +51,68 @@ provider_deps() { local) printf '' ;; # AWS runs OpenTofu. Pinning it here is the point of a pinned toolchain: # without it nic falls back to downloading an unpinned tofu at deploy time. - aws) printf 'opentofu = ">=1.11,<2"' ;; + # The floor must stay >= pkg/tofu.MinVersion (1.11.3): below that, + # compatibleVersion rejects the PATH binary and nic downloads one anyway, + # silently, which defeats the pin. + aws) printf 'opentofu = ">=1.11.3,<2"' ;; esac } mkdir -p "$OUT_DIR" + +# Report-and-block, accumulating: a restructure of examples/ usually moves more +# than one key, and exiting on the first would make the author re-run once per +# field to discover them. Held as a newline-delimited string rather than an +# array so an empty accumulator is safe under `set -u` on bash 3.2 (macOS). +errors="" +note_error() { errors="${errors}${1}"$'\n'; } + for provider in "${PROVIDERS[@]}"; do src="examples/${provider}-config.yaml" - [ -f "$src" ] || { echo "missing $src" >&2; exit 1; } + if [ ! -f "$src" ]; then + note_error "${provider}: missing ${src}" + continue + fi dest="${OUT_DIR}/${provider}" mkdir -p "$dest" cp "$src" "$dest/config.yaml" - fill_script="${dest}/.fill-for-ci.sed" - : > "$fill_script" matched=0 - while IFS= read -r field; do - [ -n "$field" ] || continue - prefix="${field%%###*}" - value="${field##*###}" + while IFS= read -r prefix; do + [ -n "$prefix" ] || continue - before="$(cat "$dest/config.yaml")" - sed "s|^${prefix}.*|${prefix}CHANGEME|" "$dest/config.yaml" > "$dest/config.yaml.tmp" - mv "$dest/config.yaml.tmp" "$dest/config.yaml" - # Every field must match something. examples/ is upstream of this - # generator, so a restructure there (a key moving or being renamed) would - # otherwise silently ship a starter with a real value left in it. - if [ "$before" = "$(cat "$dest/config.yaml")" ]; then - echo "placeholder field matched nothing for ${provider}: '${prefix}'" >&2 - echo "examples/${provider}-config.yaml has probably been restructured" >&2 - exit 1 + # Every field must match EXACTLY ONE line. examples/ is upstream of this + # generator, so a restructure there can break this two ways, and counting + # is what tells them apart: zero matches means a key was renamed or moved + # and a real value would ship untouched; more than one means a same-named + # key appeared at the same indent, and blanking both would bury a real + # value under a placeholder that looks correct. + hits="$(grep -c "^${prefix}" "$dest/config.yaml" || true)" + if [ "$hits" -ne 1 ]; then + note_error "${provider}: '${prefix}' matched ${hits} lines in examples/${provider}-config.yaml, want exactly 1" + continue fi - # The inverse edit, so CI can prove a filled starter validates without - # keeping its own copy of the field list. - printf 's|^%sCHANGEME$|%s%s|\n' "$prefix" "$prefix" "$value" >> "$fill_script" + # Replace the value but reattach any trailing comment, so the hint on a + # line the reader must edit survives (examples/aws-config.yaml's path: key + # documents itself as optional, and that is worth keeping in the starter). + sed "s|^\(${prefix}\)[^#]*\(#.*\)\{0,1\}$|\1CHANGEME \2|" \ + "$dest/config.yaml" > "$dest/config.yaml.tmp" + mv "$dest/config.yaml.tmp" "$dest/config.yaml" matched=$((matched + 1)) done < <(placeholder_fields "$provider") # A provider added to PROVIDERS without a placeholder_fields arm would # otherwise ship a starter with every real value intact. - if [ "$matched" -eq 0 ]; then - echo "no placeholder fields declared for ${provider}" >&2 - exit 1 + if [ "$matched" -eq 0 ] && [ -z "$errors" ]; then + note_error "${provider}: no placeholder fields declared; add an arm to placeholder_fields()" fi + # Trailing whitespace from a replaced line that carried no comment. + sed 's|[[:space:]]*$||' "$dest/config.yaml" > "$dest/config.yaml.tmp" + mv "$dest/config.yaml.tmp" "$dest/config.yaml" + sed -e "s|__PROVIDER__|${provider}|g" \ -e "s|__NIC_VERSION__|${NIC_VERSION}|g" \ -e "s|__PROVIDER_DEPS__|$(provider_deps "$provider")|g" \ @@ -104,3 +122,9 @@ for provider in "${PROVIDERS[@]}"; do echo "generated $dest (nic ${NIC_VERSION})" done + +if [ -n "$errors" ]; then + echo "gen-starters failed:" >&2 + printf '%s' "$errors" | sed 's|^| - |' >&2 + exit 1 +fi diff --git a/starters/templates/README.aws.md b/starters/templates/README.aws.md index 75c72860..b2f8bce0 100644 --- a/starters/templates/README.aws.md +++ b/starters/templates/README.aws.md @@ -24,7 +24,8 @@ workspace fails fast instead of attempting a real deploy. ## What you must edit -`project_name`, `domain`, the ACME email and the GitOps repository URL. The +`project_name`, `domain`, the ACME email and the GitOps repository URL and +path. The infrastructure defaults (region, availability zones, instance types, Longhorn, EFS) are working values, not placeholders. **If you change `region`, change `availability_zones` to match**: `nic validate` does not cross-check them and a diff --git a/starters/templates/README.local.md b/starters/templates/README.local.md index 21083f5e..d688b2b1 100644 --- a/starters/templates/README.local.md +++ b/starters/templates/README.local.md @@ -30,8 +30,8 @@ and the GitOps repository is created for you. ## Pinning -`pixi.lock` pins the exact toolchain. `nic` pins OpenTofu, and the embedded -`.terraform.lock.hcl` pins provider versions, so one lockfile transitively pins -the whole stack. Commit `pixi.lock`. +`pixi.lock` pins the exact toolchain. Commit it. The local provider drives kind +through an embedded Go library, so there is no OpenTofu in this workspace and +nothing else to pin. This file is copied verbatim into the published starter. Edit it here. From aefc2331b4340caf295945fafa396473ce5757ec Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Tue, 25 Aug 2026 13:03:44 -0300 Subject: [PATCH 8/8] starters(pixi): add an outputs task, correct the tools platform rationale nic outputs landed (#606) and belongs beside the other entry points in the workspace's task list. The comment on [feature.tools] named only linux-aarch64 and described kubernetes-client as lagging there. Checked against conda-forge with pixi lock: there are no kubernetes-client candidates for linux-aarch64 OR osx-64, so the narrower platform list is right but was under-explaining itself - it drops two platforms and the comment accounted for one. linux-64 and osx-arm64 both solve (k9s 0.51.0, kubernetes-client 1.34.3). --- starters/templates/pixi.toml.tmpl | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/starters/templates/pixi.toml.tmpl b/starters/templates/pixi.toml.tmpl index 9b42a73b..33036981 100644 --- a/starters/templates/pixi.toml.tmpl +++ b/starters/templates/pixi.toml.tmpl @@ -10,10 +10,12 @@ version = "0.1.0" nebari-infrastructure-core = "__NIC_VERSION__" __PROVIDER_DEPS__ -# Optional operator convenience tools. Kept out of the required dependencies -# because kubernetes-client (the kubectl binary) lags badly on linux-aarch64, -# which would make the whole workspace unsolvable there. nic does not need -# kubectl, it talks to the cluster via embedded client-go. +# Optional operator convenience tools, and a narrower platform list than the +# workspace above: conda-forge publishes no kubernetes-client (the kubectl +# binary) for linux-aarch64 or osx-64 at all, so including either would make +# the whole workspace unsolvable there. nic itself does not need kubectl - it +# talks to the cluster via embedded client-go - which is why this is a separate +# feature rather than a dependency. # Enable with: pixi install -e tools [feature.tools] platforms = ["linux-64", "osx-arm64"] @@ -30,6 +32,7 @@ validate = "nic validate" deploy = { cmd = "nic deploy", depends-on = ["validate"] } destroy = "nic destroy" kubeconfig = "nic kubeconfig -o kubeconfig.yaml" +outputs = "nic outputs" # pixi.toml and pixi.lock are always bundled; include is a strict allowlist for # everything else, so a filled config, a kubeconfig or tfstate a user creates