diff --git a/.github/workflows/starters.yml b/.github/workflows/starters.yml new file mode 100644 index 00000000..7b65353a --- /dev/null +++ b/.github/workflows/starters.yml @@ -0,0 +1,246 @@ +name: Starters + +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" + # 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: + contents: read + +env: + # 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: + validate-starters: + name: Validate starters + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 and put it on PATH + shell: bash + run: | + set -euo pipefail + make build + echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" + + - name: Generate 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 "::error::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: 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" + + echo "== ${provider}: the unedited starter must be rejected ==" + if err="$(nic validate -f "$config" 2>&1)"; then + echo "::error::nic validate accepted the unedited ${provider} starter" + echo "$err" + exit 1 + fi + # 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; } + + # 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, 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 + # 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: 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: | + set -euo pipefail + make build + echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" + + - name: Install pixi and nebi + shell: bash + run: | + set -euo pipefail + # 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 the README travelling with the + # 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. + # 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 + run: | + set -euo pipefail + ./scripts/gen-starters.sh dist/starters + + - name: Configure quay registry + shell: bash + env: + # 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_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 \ + --namespace "$QUAY_NAMESPACE" \ + --username "$QUAY_USERNAME" \ + --password-stdin + + - name: Lock and publish each starter + shell: bash + run: | + set -euo pipefail + 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. + 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 still carry their placeholders + shell: bash + run: | + set -euo pipefail + 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" + # The published bundle must be rejected unedited, same as the + # freshly generated one. + if nic validate -f "$dest/config.yaml"; then + echo "::error::published ${provider} starter validates unedited" + exit 1 + fi + echo "OK: ${provider} round-tripped and is still not deployable as-is" + done diff --git a/.gitignore b/.gitignore index cd701e02..3fcdf0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,8 @@ docs/plans/ # Test Configs test-configs/ + +# 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 new file mode 100755 index 00000000..e1ca575c --- /dev/null +++ b/scripts/gen-starters.sh @@ -0,0 +1,130 @@ +#!/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//' || true)}" +: "${NIC_VERSION:?could not determine NIC_VERSION and none was supplied}" + +# Providers in scope. Extend deliberately: each one needs its placeholder +# fields and any provider-specific dependencies below. +PROVIDERS=("local" "aws") + +# The identity-bearing fields a user must supply, declared once per provider as +# 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: ' + ;; + aws) + printf '%s\n' \ + 'project_name: ' \ + 'domain: ' \ + ' email: ' \ + ' url: ' \ + ' path: ' + ;; + 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. + # 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" + if [ ! -f "$src" ]; then + note_error "${provider}: missing ${src}" + continue + fi + dest="${OUT_DIR}/${provider}" + mkdir -p "$dest" + + cp "$src" "$dest/config.yaml" + + matched=0 + while IFS= read -r prefix; do + [ -n "$prefix" ] || continue + + # 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 + + # 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 ] && [ -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" \ + "$TEMPLATES/pixi.toml.tmpl" > "$dest/pixi.toml" + + cp "$TEMPLATES/README.${provider}.md" "$dest/README.md" + + 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 new file mode 100644 index 00000000..b2f8bce0 --- /dev/null +++ b/starters/templates/README.aws.md @@ -0,0 +1,50 @@ +# 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 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 +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.local.md b/starters/templates/README.local.md new file mode 100644 index 00000000..d688b2b1 --- /dev/null +++ b/starters/templates/README.local.md @@ -0,0 +1,37 @@ +# 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 +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 + +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 + +`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. diff --git a/starters/templates/pixi.toml.tmpl b/starters/templates/pixi.toml.tmpl new file mode 100644 index 00000000..33036981 --- /dev/null +++ b/starters/templates/pixi.toml.tmpl @@ -0,0 +1,41 @@ +[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, 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"] + +[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" +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 +# later never leaks into a republished bundle. +[tool.nebi.bundle] +include = ["config.yaml", "README.md"]