From b63a352aa53859468ca1bbed3f861c85faa269bf Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sun, 23 Aug 2026 09:10:55 -0700 Subject: [PATCH] refactor!: use ONEBOX_ for product-facing environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onebox's exported environment namespace moves from OB_ to ONEBOX_. The executable stays `ob`, the project file stays `ob.yml`, and the API version stays `onebox.run/v1`. Those three are typed constantly, so brevity earns its keep. An environment variable is not typed — it is written once into a CI settings page, a secrets store, or a hook script, and then read by someone who may not know what produced it. There, `OB_` is a puzzle: unsearchable, and indistinguishable from any other two-letter prefix in the same environment. The product calls itself Onebox everywhere else, and the sibling product already uses FANOUT_. Application-owned variables are untouched. A workload expecting FANOUT_*, DATABASE_URL, or OTEL_* receives exactly those keys — Onebox passes workload environment through rather than namespacing it. No aliases, no fallback reads, no dual writes. Carrying two namespaces would make every lookup a question about which one won, permanently, to spare a one-line search and replace once. A stray old name is simply never read: a hook using $OB_SERVER gets an empty string. The rename is anchored on a word boundary, because the unanchored pattern also matches the tail of identifiers like JOB_SECRET and POST_RELEASE_JOB_MARKER, which have nothing to do with the namespace. The encrypted backup fixture was decrypted, rekeyed, and re-encrypted rather than edited in place, so its MAC stays valid; the secret values are byte-identical. A `just env-namespace` check, wired into `just ci`, fails on any surviving OB_ reference outside the migration table that documents them. Documentation gains an environment-variables guide: what Onebox injects into local hooks, the job result protocol, backup helper keys, what it reads from a shell or CI, and the full before/after migration table. Closes #105 --- Justfile | 69 +++++++-- cmd/ob/commands.go | 4 +- e2e/apps/README.md | 6 +- e2e/apps/one-app-one-host.sh | 18 +-- e2e/e2e_test.go | 10 +- e2e/ops_test.go | 8 +- e2e/server_harness_test.go | 16 +- e2e/server_probe_test.go | 4 +- e2e/testdata/postgres/secrets/backup.env | 12 +- internal/app/backup_walg.go | 20 ++- internal/app/backup_walg_test.go | 102 +++++++++++++ internal/app/equivalence_test.go | 4 +- internal/app/purity_test.go | 2 +- internal/app/testdata/contract-verdicts.json | 2 +- internal/app/testdata/corpus/README.md | 2 +- .../app/testdata/corpus/ext-plausible.yml | 2 +- internal/app/testdata/corpus/goal.yml | 2 +- internal/app/testdata/corpus/monk.yml | 6 +- internal/compose/compose_test.go | 4 +- internal/engine/backup_restore.go | 6 +- internal/engine/deploy_test.go | 2 +- internal/engine/finalize_test.go | 2 +- internal/engine/gate.go | 8 +- internal/engine/gate_test.go | 8 +- internal/engine/hooks_test.go | 2 +- internal/engine/job_test.go | 4 +- internal/engine/migration_backup_test.go | 6 +- internal/engine/recreate.go | 16 +- internal/engine/recreate_test.go | 12 +- internal/engine/resume_test.go | 2 +- internal/onebox/job_plan_test.go | 4 +- scripts/lima.sh | 8 +- scripts/release.sh | 6 +- scripts/release_workflow_test.go | 2 +- site/astro.config.mjs | 1 + .../docs/guides/back-up-a-database.mdx | 4 +- .../guides/deploy-through-a-jump-host.mdx | 7 +- .../docs/guides/environment-variables.mdx | 137 ++++++++++++++++++ .../content/docs/guides/run-migrations.mdx | 4 +- site/src/content/docs/start/install.mdx | 2 +- 40 files changed, 415 insertions(+), 121 deletions(-) create mode 100644 site/src/content/docs/guides/environment-variables.mdx diff --git a/Justfile b/Justfile index bdad817f..e8a3b4d5 100644 --- a/Justfile +++ b/Justfile @@ -11,10 +11,10 @@ default: build build: #!/bin/bash set -euo pipefail - ob_build_dir="${OB_BIN_DIR:-bin}" - ob_build_version="${OB_VERSION:-}" + ob_build_dir="${ONEBOX_BIN_DIR:-bin}" + ob_build_version="${ONEBOX_VERSION:-}" if [ -n "$ob_build_version" ] && [[ ! "$ob_build_version" =~ ^v[1-9][0-9]{3}\.([1-9]|1[0-2])\.(0|[1-9][0-9]{0,18})$ ]]; then - echo "OB_VERSION must match vYYYY.M.REVISION" >&2; exit 1 + echo "ONEBOX_VERSION must match vYYYY.M.REVISION" >&2; exit 1 fi if [ -z "$ob_build_version" ]; then # --long keeps the commit suffix even on a tagged commit, so a checkout build @@ -33,9 +33,9 @@ build: install: build #!/bin/bash set -euo pipefail - ob_install_dir="${OB_INSTALL_DIR:-${HOME}/.local/bin}" + ob_install_dir="${ONEBOX_INSTALL_DIR:-${HOME}/.local/bin}" mkdir -p "$ob_install_dir" - install -m 0755 "${OB_BIN_DIR:-bin}/ob" "${ob_install_dir}/ob" + install -m 0755 "${ONEBOX_BIN_DIR:-bin}/ob" "${ob_install_dir}/ob" echo "installed ${ob_install_dir}/ob ($("${ob_install_dir}/ob" --version))" # Run the test suite. @@ -66,7 +66,7 @@ check: _mod-tidy _fmt-check vet test docs-generate-check site-build # They are separate from `check` because each needs a tool the repository does # not vendor; a contributor without them should still be able to run `just check` # and get a truthful answer about their change. -ci: check lint vuln workflow-check +ci: check lint vuln workflow-check env-namespace @echo "CI checks passed" [private] @@ -135,6 +135,53 @@ dead-exports: done <<< "${names}" echo "checked $(echo "${names}" | wc -l | tr -d ' ') exported identifiers, ${dead} unreferenced" +# Fail on any Onebox-owned environment variable still using the retired OB_ +# prefix. The namespace is a contract other people write into CI settings, +# secrets stores, and hook scripts, so a single stray reference is a contract +# that disagrees with itself. +env-namespace: + #!/usr/bin/env bash + set -euo pipefail + # \bOB_ rather than OB_: the unanchored pattern also matches the tail of + # identifiers like JOB_SECRET, which have nothing to do with the namespace. + # + # Each stage is checked on its own rather than chained into one pipeline, + # because only the last command's status survives a pipeline: a scan whose + # file list failed to build reports a clean tree, which is the one answer a + # check like this must never give by accident. + listing=$(mktemp) + scanned=$(mktemp) + trap 'rm -f "${listing}" "${scanned}"' EXIT + git ls-files -z > "${listing}" + if [ ! -s "${listing}" ]; then + echo "no tracked files listed — the scan checked nothing" >&2 + exit 1 + fi + # The migration table in the environment-variables guide is the one place + # the old names are allowed, because naming them is the whole point of it. + grep -zZv '^site/src/content/docs/guides/environment-variables.mdx$' < "${listing}" > "${scanned}" || true + if [ ! -s "${scanned}" ]; then + echo "the exemption matched every tracked file — the scan checked nothing" >&2 + exit 1 + fi + set +e + stray=$(xargs -0 grep -nHE '\bOB_[A-Z0-9_]+' < "${scanned}") + status=$? + set -e + case "${status}" in + 0) + echo "${stray}" + echo "retired OB_ prefix found — Onebox environment variables use ONEBOX_" >&2 + exit 1 + ;; + 1|123) ;; # no match, reported by grep itself or relayed by xargs + *) + echo "the namespace scan failed (exit ${status}) — it did not check anything" >&2 + exit 1 + ;; + esac + echo "no retired OB_ environment references" + # Scan reachable code against the official vulnerability database. vuln: govulncheck ./... @@ -155,7 +202,7 @@ workflow-check: # The Docker end-to-end suite. Opt-in locally because it needs a working daemon; # CI runs it as its own job so a slow suite never hides a fast failure. e2e: - OB_E2E=1 go test ./e2e/ -count=1 -timeout 20m + ONEBOX_E2E=1 go test ./e2e/ -count=1 -timeout 20m # Boot the throwaway server the `server-e2e` suite deploys to. # @@ -190,11 +237,11 @@ server-env: # just built, named explicitly rather than resolved from PATH — otherwise an # older `ob` installed elsewhere documents a tree it did not come from. docs-generate: build - go run ./cmd/ob-docgen --ob "${OB_BIN_DIR:-bin}/ob" + go run ./cmd/ob-docgen --ob "${ONEBOX_BIN_DIR:-bin}/ob" # Fail when a generated documentation page is behind the binary. docs-generate-check: build - go run ./cmd/ob-docgen --check --ob "${OB_BIN_DIR:-bin}/ob" + go run ./cmd/ob-docgen --check --ob "${ONEBOX_BIN_DIR:-bin}/ob" # Install the documentation site's dependencies. site-install: @@ -230,12 +277,12 @@ release: clean: #!/bin/bash set -euo pipefail - rm -f "${OB_BIN_DIR:-bin}/ob" + rm -f "${ONEBOX_BIN_DIR:-bin}/ob" # Remove the copy `just install` placed on PATH. uninstall: #!/bin/bash set -euo pipefail - ob_install_dir="${OB_INSTALL_DIR:-${HOME}/.local/bin}" + ob_install_dir="${ONEBOX_INSTALL_DIR:-${HOME}/.local/bin}" rm -f "${ob_install_dir}/ob" echo "removed ${ob_install_dir}/ob" diff --git a/cmd/ob/commands.go b/cmd/ob/commands.go index 79337d98..cb3e7204 100644 --- a/cmd/ob/commands.go +++ b/cmd/ob/commands.go @@ -515,10 +515,10 @@ func newUI(cmd *cobra.Command, g *globalFlags) *ui.UI { return ui.New(commandOutput(cmd, g), g.Verbose && !isStructuredOutput(g)) } -// cliConnector is replaceable by in-package tests and honors OB_LOCAL for the +// cliConnector is replaceable by in-package tests and honors ONEBOX_LOCAL for the // existing local-docker workflow. Production uses cancellable SSH dialing. var cliConnector onebox.Connector = func(ctx context.Context, route transport.Route) (transport.Transport, error) { - if value := strings.TrimSpace(strings.ToLower(os.Getenv("OB_LOCAL"))); value == "1" || value == "true" { + if value := strings.TrimSpace(strings.ToLower(os.Getenv("ONEBOX_LOCAL"))); value == "1" || value == "true" { return transport.NewLocal(), nil } return transport.NewSSHRoute(ctx, route) diff --git a/e2e/apps/README.md b/e2e/apps/README.md index 58461b3e..9d3ccf4a 100644 --- a/e2e/apps/README.md +++ b/e2e/apps/README.md @@ -35,14 +35,14 @@ scope — putting several on one box would not test what this contract describes. It provisions against your own Hetzner account and costs real money, so it is -never run by CI. `hcloud` must be authenticated, and `OB_E2E_SSH_KEY` must name +never run by CI. `hcloud` must be authenticated, and `ONEBOX_E2E_SSH_KEY` must name a key from `hcloud ssh-key list` that can reach the new host: ```sh -OB_E2E_SSH_KEY='my-key' ./e2e/apps/one-app-one-host.sh umami 3000 /api/heartbeat +ONEBOX_E2E_SSH_KEY='my-key' ./e2e/apps/one-app-one-host.sh umami 3000 /api/heartbeat ``` -`OB_E2E_SERVER_TYPE`, `OB_E2E_IMAGE`, and `OB_E2E_LOCATION` override the +`ONEBOX_E2E_SERVER_TYPE`, `ONEBOX_E2E_IMAGE`, and `ONEBOX_E2E_LOCATION` override the defaults (`cpx22`, `ubuntu-24.04`, `fsn1`). | App | Bare image to serving | HTTP | Containers | Volumes | diff --git a/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index 024b63d3..a12a3bf7 100755 --- a/e2e/apps/one-app-one-host.sh +++ b/e2e/apps/one-app-one-host.sh @@ -4,26 +4,26 @@ set -uo pipefail export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" APP="$1"; PORT="$2"; PATHQ="$3"; WL="${4:-}" NAME="ob-e2e-$APP" -S=${OB_E2E_SCRATCH:-/tmp} +S=${ONEBOX_E2E_SCRATCH:-/tmp} REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # The provisioning account is the operator's, not the repository's. The SSH key # is whatever name `hcloud ssh-key list` shows for the key that can reach the # host; the rest have defaults that are only a starting point. -OB_E2E_SSH_KEY="${OB_E2E_SSH_KEY:-}" -if [ -z "$OB_E2E_SSH_KEY" ]; then - echo "OB_E2E_SSH_KEY must name an SSH key registered with hcloud (see: hcloud ssh-key list)" >&2 +ONEBOX_E2E_SSH_KEY="${ONEBOX_E2E_SSH_KEY:-}" +if [ -z "$ONEBOX_E2E_SSH_KEY" ]; then + echo "ONEBOX_E2E_SSH_KEY must name an SSH key registered with hcloud (see: hcloud ssh-key list)" >&2 exit 2 fi -OB_E2E_SERVER_TYPE="${OB_E2E_SERVER_TYPE:-cpx22}" -OB_E2E_IMAGE="${OB_E2E_IMAGE:-ubuntu-24.04}" -OB_E2E_LOCATION="${OB_E2E_LOCATION:-fsn1}" +ONEBOX_E2E_SERVER_TYPE="${ONEBOX_E2E_SERVER_TYPE:-cpx22}" +ONEBOX_E2E_IMAGE="${ONEBOX_E2E_IMAGE:-ubuntu-24.04}" +ONEBOX_E2E_LOCATION="${ONEBOX_E2E_LOCATION:-fsn1}" cleanup() { hcloud server delete "$NAME" >/dev/null 2>&1; } trap cleanup EXIT -hcloud server create --name "$NAME" --type "$OB_E2E_SERVER_TYPE" --image "$OB_E2E_IMAGE" --location "$OB_E2E_LOCATION" \ - --ssh-key "$OB_E2E_SSH_KEY" --label purpose=onebox-e2e --label ephemeral=true >/dev/null 2>&1 || { echo " provision FAILED"; exit 1; } +hcloud server create --name "$NAME" --type "$ONEBOX_E2E_SERVER_TYPE" --image "$ONEBOX_E2E_IMAGE" --location "$ONEBOX_E2E_LOCATION" \ + --ssh-key "$ONEBOX_E2E_SSH_KEY" --label purpose=onebox-e2e --label ephemeral=true >/dev/null 2>&1 || { echo " provision FAILED"; exit 1; } IP=$(hcloud server ip "$NAME") # Cloud providers recycle addresses. A stale host key from a destroyed server # makes ob refuse the connection, which is correct of it and unhelpful here. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5816f42d..3fcf1e0a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -1,5 +1,5 @@ // Package e2e proves the core live-deploy contract mechanically under -// load with zero failed requests. Gated: OB_E2E=1 + local docker. +// load with zero failed requests. Gated: ONEBOX_E2E=1 + local docker. package e2e import ( @@ -42,13 +42,13 @@ func TestV1ConfigFixturesLoad(t *testing.T) { } func TestZeroDowntimeDeploy(t *testing.T) { - if os.Getenv("OB_E2E") != "1" { - t.Skip("set OB_E2E=1 (requires local docker)") + if os.Getenv("ONEBOX_E2E") != "1" { + t.Skip("set ONEBOX_E2E=1 (requires local docker)") } // Opting in is a promise that Docker is here. Skipping past a broken daemon - // once OB_E2E=1 is set turns a gate into a green tick for work nobody did. + // once ONEBOX_E2E=1 is set turns a gate into a green tick for work nobody did. if err := exec.CommandContext(t.Context(), "docker", "info").Run(); err != nil { - t.Fatalf("OB_E2E=1 was set but docker is not usable: %v", err) + t.Fatalf("ONEBOX_E2E=1 was set but docker is not usable: %v", err) } dir, err := filepath.Abs("testdata/app") if err != nil { diff --git a/e2e/ops_test.go b/e2e/ops_test.go index 96a0e024..4b50652b 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -27,13 +27,13 @@ import ( func gate(t *testing.T) { t.Helper() - if os.Getenv("OB_E2E") != "1" { - t.Skip("set OB_E2E=1 (requires local docker)") + if os.Getenv("ONEBOX_E2E") != "1" { + t.Skip("set ONEBOX_E2E=1 (requires local docker)") } // Opting in is a promise that Docker is here. Skipping past a broken daemon - // once OB_E2E=1 is set turns a gate into a green tick for work nobody did. + // once ONEBOX_E2E=1 is set turns a gate into a green tick for work nobody did. if err := exec.CommandContext(t.Context(), "docker", "info").Run(); err != nil { - t.Fatalf("OB_E2E=1 was set but docker is not usable: %v", err) + t.Fatalf("ONEBOX_E2E=1 was set but docker is not usable: %v", err) } } diff --git a/e2e/server_harness_test.go b/e2e/server_harness_test.go index f1733692..689b9d72 100644 --- a/e2e/server_harness_test.go +++ b/e2e/server_harness_test.go @@ -48,19 +48,19 @@ type server struct { func requireServer(t *testing.T) *server { t.Helper() - if os.Getenv("OB_SERVER_E2E") != "1" { - t.Skip("set OB_SERVER_E2E=1 (see `just server-e2e`)") + if os.Getenv("ONEBOX_SERVER_E2E") != "1" { + t.Skip("set ONEBOX_SERVER_E2E=1 (see `just server-e2e`)") } // Opting in is a promise the machine is there. Skipping past an // unreachable one turns a gate into a green tick for work nobody did. - target := os.Getenv("OB_E2E_SERVER") - key := os.Getenv("OB_E2E_SERVER_KEY") + target := os.Getenv("ONEBOX_E2E_SERVER") + key := os.Getenv("ONEBOX_E2E_SERVER_KEY") if target == "" || key == "" { - t.Fatal("OB_SERVER_E2E=1 without OB_E2E_SERVER and OB_E2E_SERVER_KEY") + t.Fatal("ONEBOX_SERVER_E2E=1 without ONEBOX_E2E_SERVER and ONEBOX_E2E_SERVER_KEY") } user, rest, ok := strings.Cut(target, "@") if !ok { - t.Fatalf("OB_E2E_SERVER %q is not user@host[:port]", target) + t.Fatalf("ONEBOX_E2E_SERVER %q is not user@host[:port]", target) } host, port, ok := strings.Cut(rest, ":") if !ok { @@ -71,12 +71,12 @@ func requireServer(t *testing.T) *server { // under /etc/systemd/system. A server it cannot reach as root fails later, // in a place that looks like a deploy bug. if user != "root" { - t.Fatalf("OB_E2E_SERVER is %q; ob writes to /etc/systemd/system and does not elevate, so it must be root", target) + t.Fatalf("ONEBOX_E2E_SERVER is %q; ob writes to /etc/systemd/system and does not elevate, so it must be root", target) } s := &server{target: target, user: user, host: host, port: port, key: key, bootstrapped: map[string]bool{}} if err := s.try(t, "true"); err != nil { - t.Fatalf("OB_SERVER_E2E=1 but %s is not reachable: %v", target, err) + t.Fatalf("ONEBOX_SERVER_E2E=1 but %s is not reachable: %v", target, err) } s.guest = strings.Fields(s.run(t, "hostname -I"))[0] return s diff --git a/e2e/server_probe_test.go b/e2e/server_probe_test.go index 8435b211..511b5a06 100644 --- a/e2e/server_probe_test.go +++ b/e2e/server_probe_test.go @@ -248,8 +248,8 @@ func (s *server) teardown(t *testing.T, dir string) { // Kept deliberately when asked. A probe that fails is a probe whose // machine is worth looking at, and tearing it down is how the evidence for // the last three wrong theories disappeared before it could be read. - if os.Getenv("OB_E2E_KEEP") == "1" { - t.Log("OB_E2E_KEEP=1: leaving the application in place") + if os.Getenv("ONEBOX_E2E_KEEP") == "1" { + t.Log("ONEBOX_E2E_KEEP=1: leaving the application in place") return } if out, err := s.obInput(t, dir, s.obHome(t), "observer\ny\n", "destroy", "--volumes"); err != nil { diff --git a/e2e/testdata/postgres/secrets/backup.env b/e2e/testdata/postgres/secrets/backup.env index a60e4796..fbc81323 100644 --- a/e2e/testdata/postgres/secrets/backup.env +++ b/e2e/testdata/postgres/secrets/backup.env @@ -1,9 +1,9 @@ -BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:lSmmCEHir7dioHfLwuo=,iv:Bt8xUhRZBGG9JP20IVxc2O1LnE9h2iPn0YRuxUs1udg=,tag:IB2q8mLvrknktl/qtjwVBw==,type:str] -BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:gzaMGkamkHevSz1ABtJfoi0K+OwtyFqW,iv:9hUVpTJt3I53lx3aKAgeCfJS1KAhR9MAgJGgi2gAsus=,tag:m8RxxbIaIoiff7fjRTvWDw==,type:str] -OB_REPOSITORY_KEY=ENC[AES256_GCM,data:SG1JshUdQ7bWjupdNZmIod+trFELZn8HKoyLO7DLchi22UjUtNNcOBo0qXaPzlLzlk4cY/C62yBsxTUtXYaXbQ==,iv:retfIEsNiwlQW27S2s5ENxRDpdPZj70KtmUrH12poRY=,tag:Tw/aoKBM4zHtsmu8r/w9ew==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsWUZYeHhnTnBXT0UweEpn\nTlJRQnV0by9yNlRPOTZhcFlQVHd5VkIvRjJBClViZXRiR2JOWjJLazRadU54M0VB\nZExmS0RqeWh3MlQ5ZVVPZnZCS2NnNGsKLS0tIFkxSktyc3lMN2srdkNwNkhIWUFD\nUStCbDh3MTBDUmV1Y2tzeWdTR01SMFUKh6PXLS/J02QYax8z/T35Sz24KxwHSH1A\nm1Fxpb4n3ue/lEsKRPbUlwwRpzAMBHhmccfSrNAyBwu2RQ6VyZ17jQ==\n-----END AGE ENCRYPTED FILE-----\n +BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:EkpvA6uAWHgF6rp3nX8=,iv:0c2fmfxyuYSjwG7nd4nW+dSSZdwjO+OSPDxBntmLUy8=,tag:2zBAqdVz59GAar01uylD3Q==,type:str] +BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:+Lw7tGBiyD7RTY0QBr0PWByBDdld29AB,iv:OKbgQyZqrf8+GVR6dfq8ba8fZeyxXLrw9T2Soa5NuA0=,tag:syYsub7TvLFWW44zyyxUYA==,type:str] +ONEBOX_REPOSITORY_KEY=ENC[AES256_GCM,data:GOANkTcfLADqV4UILxYmPo9mfsSYVR8Mot5cli9LwPbcqKY+0rleG4MfY6vPWlegYB0nS1ahhOq7ij1OdQQtNQ==,iv:KVu8JvYnS3cmZgx8U8IpGPM5d9JesqlIp2UNVIlPnvA=,tag:EBxQZUwxEvYq0rhcrkKVWQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwbGp5cFlYQmNxTzJHamYr\nY0N4dFZrdGpuWVdyL01KR2cvYlZuTjhwemx3CnhUZnZmZHgxZ2FkcHZhUjRqTFIx\nbjZORmhMeGRFMDVjaHpqY09IK0Q3aGMKLS0tIGpnTUQzZUJQb2p5UEpnMnNnZy8v\nd1g2alVLeUYyWUk3azRSWXBoTXI1MWMKSOilXfdZbh2C7So8OPjC75v+ahd2y7aM\n/a/3vkjD0Z+iro7NEsHffyM+Ro18JYDnNcGZ8yPDn81HkvnzSwMjkA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1xtc0fzq29crqzk98r59zdetgyxdhqwqm7qrvkal4n7zr3cu5wgassh3u3x -sops_lastmodified=2026-08-21T23:47:21Z -sops_mac=ENC[AES256_GCM,data:bNfd63Xkxu6BQNWP0/Go5h1Brtcg6wDVvqmZDRSUDlyu1JhEWVbIBDsgmXf+v4Dy6wlkGpKWT97CCmScJKfP9LY9iKvZBa0uSlpESKgKgzFY7nHUnkiXPjM5U7v5lmWyBuBuGooW1D0kXG71l88mcnfXe0ij9zEQEfYTSAboCZ4=,iv:53tToUkZi3aQ47RX0Su+i9qqreTDUaTo34hFDyYaSfo=,tag:KCfqsNjBhshEsKZH0qItyQ==,type:str] +sops_lastmodified=2026-08-23T15:59:09Z +sops_mac=ENC[AES256_GCM,data:pTzRIDaKlrKtg+LaTigPToLqy91TaZthQpBAE5fRzjsZ8QkVGJXBWHYwe1x9/HT9/GbGJMXj3b3+cVxHRaXf3j/IFilwuGa4IvoMmotB6eRDJva9Fn1Sv8RlNvssWThzBRvJE8iY4ESfeY4b1rIc1pS5v0ucenaYffHCU6tYcG8=,iv:6pk9mQ+14X38hpOcqCDhTsQDVNp8TRG6iMDgsd41bpw=,tag:5YuWOEwOyrB/cPmzo32jmw==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.13.3 diff --git a/internal/app/backup_walg.go b/internal/app/backup_walg.go index 6ccee01d..d7031bea 100644 --- a/internal/app/backup_walg.go +++ b/internal/app/backup_walg.go @@ -56,7 +56,7 @@ const WalgTrustStore = WalgMountPath + "/ca-certificates.crt" // encryption key. Unlike the destination keys it has a fixed name: the key is // Onebox's own requirement rather than a property of the destination, so there // is no backup_targets field to indirect through. -const WalgRepositoryKeyEntry = "OB_REPOSITORY_KEY" +const WalgRepositoryKeyEntry = "ONEBOX_REPOSITORY_KEY" // WalgPrefix is the repository location for one protected database generation. // @@ -193,14 +193,20 @@ func RenderWalgWrapper(target BackupTarget) []byte { b.WriteString("# from the mode-0600 credential file on the host; only the names are\n") b.WriteString("# here, and the names are not secret.\n") b.WriteString("set -eu\n") + // A declared entry is a required one: WalgCredentialEntries is the same + // list enable-time validation insists on. Refusing here rather than + // skipping matters most for the repository key — wal-g with no + // WALG_LIBSODIUM_KEY does not fail, it writes the backup unencrypted, so a + // credential file that stops defining an entry would quietly downgrade + // every subsequent backup. The credential file is written once at enable + // time and read by every later deploy, so the two can drift. assign := func(walgName, entry string) { if entry == "" { return } - b.WriteString("if [ -n \"${" + entry + "-}\" ]; then\n") - b.WriteString(" " + walgName + "=\"$" + entry + "\"\n") - b.WriteString(" export " + walgName + "\n") - b.WriteString("fi\n") + b.WriteString(": \"${" + entry + ":?is not set in the credential file on this host — re-run `ob backup enable` for this service}\"\n") + b.WriteString(walgName + "=\"$" + entry + "\"\n") + b.WriteString("export " + walgName + "\n") } assign("AWS_ACCESS_KEY_ID", target.Credentials.AccessKeyEntry) assign("AWS_SECRET_ACCESS_KEY", target.Credentials.SecretKeyEntry) @@ -334,8 +340,8 @@ func (r *Resolved) backupForRender(n Names, serviceName string) (*serviceBackup, if err != nil { return nil, err } - environment["OB_S3_KEY_ENTRY"] = projection.Target.Credentials.AccessKeyEntry - environment["OB_S3_SECRET_ENTRY"] = projection.Target.Credentials.SecretKeyEntry + environment["ONEBOX_S3_KEY_ENTRY"] = projection.Target.Credentials.AccessKeyEntry + environment["ONEBOX_S3_SECRET_ENTRY"] = projection.Target.Credentials.SecretKeyEntry return &serviceBackup{ RuntimeHostDir: n.BackupRuntimeDir(serviceName), CredentialFile: n.BackupCredentialFile(serviceName, projection.Policy.Target), diff --git a/internal/app/backup_walg_test.go b/internal/app/backup_walg_test.go index 3fdbbd14..07e68c65 100644 --- a/internal/app/backup_walg_test.go +++ b/internal/app/backup_walg_test.go @@ -1,6 +1,11 @@ package app import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" "strings" "testing" ) @@ -154,3 +159,100 @@ func TestTheWrapperPointsWalgAtTheStagedTrustStore(t *testing.T) { t.Errorf("the trust store is exported without checking it was staged:\n%s", wrapper) } } + +// runWrapper executes a rendered wrapper with a stubbed wal-g on PATH, so the +// test observes what the wrapper actually does rather than what it says. +func runWrapper(t *testing.T, target BackupTarget, env []string) (stdout, stderr string, code int) { + t.Helper() + dir := t.TempDir() + // A stub standing in for the staged binary, reporting whether the wrapper + // handed it an encryption key. The wrapper execs wal-g by its absolute + // staged path, so only that one line is redirected; everything the guard + // does above it runs verbatim. + stub := filepath.Join(dir, "wal-g") + body := "#!/bin/sh\nprintf 'libsodium=[%s]\\n' \"${WALG_LIBSODIUM_KEY-}\"\n" + if err := os.WriteFile(stub, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + rendered := strings.Replace(string(RenderWalgWrapper(target)), WalgMountPath+"/wal-g", stub, 1) + script := filepath.Join(dir, "wrapper.sh") + if err := os.WriteFile(script, []byte(rendered), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(t.Context(), "/bin/sh", script) + cmd.Env = append([]string{"PATH=" + os.Getenv("PATH")}, env...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else if err != nil { + t.Fatal(err) + } + return out.String(), errb.String(), code +} + +func walgTestTarget() BackupTarget { + return BackupTarget{Credentials: CredentialReference{ + AccessKeyEntry: "BACKUP_ACCESS_KEY_ID", + SecretKeyEntry: "BACKUP_SECRET_ACCESS_KEY", + }} +} + +// A credential file written by an older runner names the entries that runner +// required. If a required entry is simply absent, the wrapper must refuse to +// run: wal-g without WALG_LIBSODIUM_KEY does not fail, it writes the backup +// unencrypted — and a backup that is silently unencrypted is worse than one +// that visibly did not happen. +func TestTheWrapperRefusesToRunWithoutARequiredCredential(t *testing.T) { + _, stderr, code := runWrapper(t, walgTestTarget(), []string{ + "BACKUP_ACCESS_KEY_ID=key", + "BACKUP_SECRET_ACCESS_KEY=secret", + // the repository key is missing, as it is after a namespace change + }) + if code == 0 { + t.Fatal("the wrapper ran wal-g with no repository key, so the backup would be unencrypted") + } + if !strings.Contains(stderr, WalgRepositoryKeyEntry) { + t.Fatalf("the failure does not name the missing entry: %q", stderr) + } +} + +func TestTheWrapperRefusesToRunWithoutDestinationCredentials(t *testing.T) { + for _, missing := range []string{"BACKUP_ACCESS_KEY_ID", "BACKUP_SECRET_ACCESS_KEY"} { + t.Run(missing, func(t *testing.T) { + env := []string{ + "BACKUP_ACCESS_KEY_ID=key", + "BACKUP_SECRET_ACCESS_KEY=secret", + WalgRepositoryKeyEntry + "=" + strings.Repeat("ab", 32), + } + var kept []string + for _, entry := range env { + if !strings.HasPrefix(entry, missing+"=") { + kept = append(kept, entry) + } + } + if _, stderr, code := runWrapper(t, walgTestTarget(), kept); code == 0 { + t.Fatalf("the wrapper ran with %s unset", missing) + } else if !strings.Contains(stderr, missing) { + t.Fatalf("the failure does not name %s: %q", missing, stderr) + } + }) + } +} + +func TestTheWrapperPassesTheRepositoryKeyToWalg(t *testing.T) { + key := strings.Repeat("ab", 32) + stdout, stderr, code := runWrapper(t, walgTestTarget(), []string{ + "BACKUP_ACCESS_KEY_ID=key", + "BACKUP_SECRET_ACCESS_KEY=secret", + WalgRepositoryKeyEntry + "=" + key, + }) + if code != 0 { + t.Fatalf("wrapper exited %d: %s", code, stderr) + } + if !strings.Contains(stdout, "libsodium=["+key+"]") { + t.Fatalf("wal-g did not receive the repository key: %q", stdout) + } +} diff --git a/internal/app/equivalence_test.go b/internal/app/equivalence_test.go index 4dc019b3..79e66999 100644 --- a/internal/app/equivalence_test.go +++ b/internal/app/equivalence_test.go @@ -163,7 +163,7 @@ func corpusProjects(t *testing.T) []string { func TestContractDidNotMove(t *testing.T) { got := recordVerdicts(t) - if os.Getenv("OB_UPDATE_VERDICTS") == "1" { + if os.Getenv("ONEBOX_UPDATE_VERDICTS") == "1" { b, err := json.MarshalIndent(got, "", " ") if err != nil { t.Fatal(err) @@ -177,7 +177,7 @@ func TestContractDidNotMove(t *testing.T) { body, err := os.ReadFile(goldenPath) if err != nil { - t.Fatalf("no frozen verdict; record one with OB_UPDATE_VERDICTS=1: %v", err) + t.Fatalf("no frozen verdict; record one with ONEBOX_UPDATE_VERDICTS=1: %v", err) } var want []verdict if err := json.Unmarshal(body, &want); err != nil { diff --git a/internal/app/purity_test.go b/internal/app/purity_test.go index 2180b20e..82f80e8f 100644 --- a/internal/app/purity_test.go +++ b/internal/app/purity_test.go @@ -108,7 +108,7 @@ func TestGenerationIgnoresTheProcessEnvironment(t *testing.T) { } for _, key := range []string{ "HOME", "USER", "PATH", "PWD", "SHELL", "TZ", "LANG", - "OB_BASE_DIR", "OB_APP", "OB_ENV", "OB_IMAGE", "OB_RELEASE", + "ONEBOX_BASE_DIR", "ONEBOX_APP", "ONEBOX_ENV", "ONEBOX_IMAGE", "ONEBOX_RELEASE", "COMPOSE_PROJECT_NAME", "DOCKER_HOST", "TOKEN", "API_TOKEN", } { t.Setenv(key, "generation-must-not-see-this") diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 6a7ec39f..b6692893 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -582,7 +582,7 @@ { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "1116c463820143c01194fea526e8c1c6bf9cfe03c7ff7c81c7e1ae509ab32de2 events=1e798590e3a5dda4 postgres=b1fac70440c33545" + "digest": "cab9f37156b86ba788b71f110ccdd8ae7276607336d3fafcb06d9b5255e7a380 events=1e798590e3a5dda4 postgres=b1fac70440c33545" }, { "case": "corpus/ext-umami.yml", diff --git a/internal/app/testdata/corpus/README.md b/internal/app/testdata/corpus/README.md index 2a6fe6f9..c4539ff7 100644 --- a/internal/app/testdata/corpus/README.md +++ b/internal/app/testdata/corpus/README.md @@ -10,5 +10,5 @@ change took the corpus out from under the tests that depend on it. It lives here now, with the tests. Adding a project here is deliberate: the harness refuses an unfrozen case, so a -new file must have its verdict recorded with `OB_UPDATE_VERDICTS=1` and the +new file must have its verdict recorded with `ONEBOX_UPDATE_VERDICTS=1` and the diff reviewed. diff --git a/internal/app/testdata/corpus/ext-plausible.yml b/internal/app/testdata/corpus/ext-plausible.yml index 20b17fc5..35a652c5 100644 --- a/internal/app/testdata/corpus/ext-plausible.yml +++ b/internal/app/testdata/corpus/ext-plausible.yml @@ -30,7 +30,7 @@ workloads: set -e /entrypoint.sh db createdb /entrypoint.sh db migrate - printf '{"schema_version":"onebox.run/job-result/v1alpha1","changed":true}' > "$OB_RESULT_FILE" + printf '{"schema_version":"onebox.run/job-result/v1alpha1","changed":true}' > "$ONEBOX_RESULT_FILE" data_effect: migration when: pre_release env: diff --git a/internal/app/testdata/corpus/goal.yml b/internal/app/testdata/corpus/goal.yml index 45eaa02c..51880cfa 100644 --- a/internal/app/testdata/corpus/goal.yml +++ b/internal/app/testdata/corpus/goal.yml @@ -32,7 +32,7 @@ runtime: present: [RESEND_API_KEY, TWILIO_ACCOUNT_SID, S3_BUCKET, AWS_ACCESS_KEY_ID] proxy: {managed: true, config: traefik} hooks: - bootstrap: {run: 'scripts/bootstrap.sh "$OB_SERVER"', local: true} + bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} checks: http: - {workload: server, path: /readyz, port: 7510} diff --git a/internal/app/testdata/corpus/monk.yml b/internal/app/testdata/corpus/monk.yml index d65cb4f0..10754e23 100644 --- a/internal/app/testdata/corpus/monk.yml +++ b/internal/app/testdata/corpus/monk.yml @@ -47,9 +47,9 @@ proxy: {managed: true, config: traefik} notifications: ntfy: {webhook: "https://ntfy.sh/monk-deploy-f413efb2e8f7", on: [failure], format: text} hooks: - bootstrap: {run: 'scripts/bootstrap.sh "$OB_SERVER"', local: true} - pre_release: {run: "cd web && bun install --frozen-lockfile && bun run build && rsync -az dist/ $OB_SERVER:/data/monk/web/", local: true} - post_deploy: {run: "rsync -az web/dist/index.html $OB_SERVER:/data/monk/web/index.html", local: true} + bootstrap: {run: 'scripts/bootstrap.sh "$ONEBOX_SERVER"', local: true} + pre_release: {run: "cd web && bun install --frozen-lockfile && bun run build && rsync -az dist/ $ONEBOX_SERVER:/data/monk/web/", local: true} + post_deploy: {run: "rsync -az web/dist/index.html $ONEBOX_SERVER:/data/monk/web/index.html", local: true} checks: http: - {workload: server, path: /healthz, port: 7500} diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go index 0fca3afb..8c868d22 100644 --- a/internal/compose/compose_test.go +++ b/internal/compose/compose_test.go @@ -14,8 +14,8 @@ import ( // way on a developer's laptop and another way on the target would differ // exactly where nobody is looking. func TestInterpolationComesFromDeclaredFilesOnly(t *testing.T) { - t.Setenv("OB_RUNNER_ONLY", "from-the-laptop") - doc := []byte("services:\n web:\n image: nginx:${TAG}\n environment:\n FROM_RUNNER: ${OB_RUNNER_ONLY}\n") + t.Setenv("ONEBOX_RUNNER_ONLY", "from-the-laptop") + doc := []byte("services:\n web:\n image: nginx:${TAG}\n environment:\n FROM_RUNNER: ${ONEBOX_RUNNER_ONLY}\n") p, err := LoadBytes(context.Background(), doc, "probe", t.TempDir(), map[string]string{"TAG": "1.27"}) if err != nil { diff --git a/internal/engine/backup_restore.go b/internal/engine/backup_restore.go index 3121286d..3d7b0e9c 100644 --- a/internal/engine/backup_restore.go +++ b/internal/engine/backup_restore.go @@ -120,10 +120,10 @@ func (e *Engine) RecoverService(ctx context.Context, service, targetTime string, if err != nil { return outcome, err } - environment["OB_S3_KEY_ENTRY"] = target.Credentials.AccessKeyEntry - environment["OB_S3_SECRET_ENTRY"] = target.Credentials.SecretKeyEntry + environment["ONEBOX_S3_KEY_ENTRY"] = target.Credentials.AccessKeyEntry + environment["ONEBOX_S3_SECRET_ENTRY"] = target.Credentials.SecretKeyEntry if target.Credentials.SessionTokenEntry != "" { - environment["OB_S3_SESSION_TOKEN_ENTRY"] = target.Credentials.SessionTokenEntry + environment["ONEBOX_S3_SESSION_TOKEN_ENTRY"] = target.Credentials.SessionTokenEntry } st := e.ui.Step("recovery: fetch base backup", false) diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index e4dbbe82..98431222 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -240,7 +240,7 @@ func TestDeployStopsWhenRequiredJournalEvidenceCannotBeWritten(t *testing.T) { forbidden string want string }{ - {name: "transfer result", record: `"phase":"transfer","event":"result","status":"ok"`, forbidden: "OB_RESULT_FILE", want: "journal transfer result"}, + {name: "transfer result", record: `"phase":"transfer","event":"result","status":"ok"`, forbidden: "ONEBOX_RESULT_FILE", want: "journal transfer result"}, {name: "release intent", record: `"phase":"release","role":"web","event":"intent"`, forbidden: "--scale web=", want: "journal release web intent"}, {name: "release result", record: `"phase":"release","role":"web","event":"result","status":"ok"`, forbidden: "--force-recreate --timeout 30 worker", want: "journal release web result"}, {name: "verify result", record: `"phase":"verify","event":"result","status":"ok"`, forbidden: "ln -sfn 'releases/20260101-000000-aaa111'", want: "journal verify result"}, diff --git a/internal/engine/finalize_test.go b/internal/engine/finalize_test.go index 00621d42..8322f163 100644 --- a/internal/engine/finalize_test.go +++ b/internal/engine/finalize_test.go @@ -236,7 +236,7 @@ func TestResumeFinalizesAfterActivationWithoutReplayingTheDeploy(t *testing.T) { t.Fatalf("resume: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") - for _, forbidden := range []string{"--scale web=", "--force-recreate --timeout 30 worker", "ln -sfn 'releases/" + engineTestDeployReleaseID + "'", "OB_RESULT_FILE"} { + for _, forbidden := range []string{"--scale web=", "--force-recreate --timeout 30 worker", "ln -sfn 'releases/" + engineTestDeployReleaseID + "'", "ONEBOX_RESULT_FILE"} { if strings.Contains(seq, forbidden) { t.Fatalf("finalize must not replay the deploy (%s):\n%s", forbidden, seq) } diff --git a/internal/engine/gate.go b/internal/engine/gate.go index 2842f316..dfe71dd0 100644 --- a/internal/engine/gate.go +++ b/internal/engine/gate.go @@ -26,7 +26,7 @@ func (e *Engine) deployJobSteps() []string { } // runJobs runs every gate step once, before the roll, each under the -// $OB_RESULT_FILE protocol. A step with a same-named hook runs +// $ONEBOX_RESULT_FILE protocol. A step with a same-named hook runs // that hook's command (a custom migrate invocation); otherwise ob auto-runs // `docker compose run --rm --no-deps ` — so `jobs: [migrate]` needs no // hook at all. The rollback gate opens only if EVERY step declared @@ -120,7 +120,7 @@ func (e *Engine) runOneJob(ctx context.Context, job, remoteDir, remoteCompose st const containerResultFile = "/run/onebox/job-result" containerized := true runCmd := e.composeCmd(remoteCompose) + " run --rm --no-deps" + - " -e OB_RESULT_FILE=" + containerResultFile + + " -e ONEBOX_RESULT_FILE=" + containerResultFile + " -v " + q(resultFile+":"+containerResultFile+":rw") + " " + job if h, ok := e.Spec.Hooks[job]; ok && h.Run != "" { if h.Local { @@ -154,7 +154,7 @@ func (e *Engine) runOneJob(ctx context.Context, job, remoteDir, remoteCompose st " && install -m " + resultMode + " /dev/null " + q(resultFile) + " && COMPOSE_PROJECT_NAME=" + e.Spec.Name + " COMPOSE_FILE=" + q(remoteCompose) + - " OB_RESULT_FILE=" + q(resultFile) + + " ONEBOX_RESULT_FILE=" + q(resultFile) + " " + runCmd if containerized { cmd += "; job_status=$?; chmod 600 " + q(resultFile) + " || exit 125; exit $job_status" @@ -200,7 +200,7 @@ func injectComposeJobResult(command, hostResultFile, containerResultFile string) if !strings.Contains(prefix, "docker compose") && !strings.Contains(prefix, "docker-compose") { return command, false } - flags := " run -e OB_RESULT_FILE=" + containerResultFile + + flags := " run -e ONEBOX_RESULT_FILE=" + containerResultFile + " -v " + q(hostResultFile+":"+containerResultFile+":rw") + " " return prefix + flags + command[runIndex+len(" run "):], true } diff --git a/internal/engine/gate_test.go b/internal/engine/gate_test.go index 8bbc7240..0dc78a08 100644 --- a/internal/engine/gate_test.go +++ b/internal/engine/gate_test.go @@ -142,11 +142,11 @@ func TestJobAutoRunsWithoutHook(t *testing.T) { t.Fatalf("deploy: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "run --rm --no-deps -e OB_RESULT_FILE=/run/onebox/job-result") { + if !strings.Contains(seq, "run --rm --no-deps -e ONEBOX_RESULT_FILE=/run/onebox/job-result") { t.Fatalf("a job without a hook must auto-run compose run:\n%s", seq) } // gate protocol still applies to the auto-run job. - if !strings.Contains(seq, "OB_RESULT_FILE=") { + if !strings.Contains(seq, "ONEBOX_RESULT_FILE=") { t.Fatalf("auto-run job must run under the gate protocol:\n%s", seq) } } @@ -206,7 +206,7 @@ func TestJobDoesNotRunWhenIntentCannotBeJournaled(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "journal unavailable") { t.Fatalf("intent journal failure must stop the job: %v", err) } - if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "OB_RESULT_FILE") { + if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "ONEBOX_RESULT_FILE") { t.Fatalf("job ran without a durable intent:\n%s", seq) } } @@ -419,7 +419,7 @@ func TestMigrateComposeJobGetsPrivateWritableBoundResultFile(t *testing.T) { mount := strings.Index(c, "-v '"+resultFile+":/run/onebox/job-result:rw'") sealedFile := strings.Index(c, "chmod 600 '"+resultFile+"'") if strings.Contains(c, "rm -rf '"+resultDir+"'") && - strings.Contains(c, "run --rm --no-deps -e OB_RESULT_FILE=/run/onebox/job-result") && + strings.Contains(c, "run --rm --no-deps -e ONEBOX_RESULT_FILE=/run/onebox/job-result") && privateDir >= 0 && privateDir < writableFile && writableFile < mount && mount < sealedFile { found = true } diff --git a/internal/engine/hooks_test.go b/internal/engine/hooks_test.go index 2bc16d20..cd0b3297 100644 --- a/internal/engine/hooks_test.go +++ b/internal/engine/hooks_test.go @@ -19,7 +19,7 @@ func TestLocalHookRunsOnRunnerNotHost(t *testing.T) { f := &transport.Fake{} dir := t.TempDir() cfg := testConfig() - cfg.Hooks["publish"] = app.Command{Run: "echo $OB_RELEASE_ID > out.txt", Local: true} + cfg.Hooks["publish"] = app.Command{Run: "echo $ONEBOX_RELEASE_ID > out.txt", Local: true} e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, LocalDir: dir}) if err := e.RunHook(context.Background(), "publish", "/var/lib/ob/sample/releases/R9", "x"); err != nil { t.Fatal(err) diff --git a/internal/engine/job_test.go b/internal/engine/job_test.go index c52bbe00..67366f89 100644 --- a/internal/engine/job_test.go +++ b/internal/engine/job_test.go @@ -119,7 +119,7 @@ func TestRunJobRechecksReleaseAndRuntimeUnderLock(t *testing.T) { if !strings.Contains(commands, "set -C; echo") || !strings.Contains(commands, "/fence") { t.Fatalf("post-lock recheck was not lock/fence protected:\n%s", commands) } - if strings.Contains(commands, "OB_RESULT_FILE") { + if strings.Contains(commands, "ONEBOX_RESULT_FILE") { t.Fatalf("stale job created a container:\n%s", commands) } }) @@ -142,7 +142,7 @@ func TestRunJobUsesPlanIdentityAndJournalsAuthorization(t *testing.T) { t.Fatalf("job identity/evidence = %q/%+v", operationID, evidence) } commands := strings.Join(target.Commands, "\n") - if strings.Count(commands, "OB_RESULT_FILE=/run/onebox/job-result") != 1 { + if strings.Count(commands, "ONEBOX_RESULT_FILE=/run/onebox/job-result") != 1 { t.Fatalf("job execution count differs from one:\n%s", commands) } for _, want := range []string{`"deploy_id":"op-job-run"`, `"phase":"job","event":"start"`, `"approval_digest":"approval-digest"`} { diff --git a/internal/engine/migration_backup_test.go b/internal/engine/migration_backup_test.go index 93acece0..07e404b9 100644 --- a/internal/engine/migration_backup_test.go +++ b/internal/engine/migration_backup_test.go @@ -57,7 +57,7 @@ func TestMigrationBackupPolicyStopsBeforeMigrationWithoutEvidence(t *testing.T) if err == nil || !strings.Contains(err.Error(), "migration backup report is required") { t.Fatalf("missing evidence did not stop deploy: %v", err) } - if sequence := strings.Join(f.Commands, "\n"); strings.Contains(sequence, "OB_RESULT_FILE") { + if sequence := strings.Join(f.Commands, "\n"); strings.Contains(sequence, "ONEBOX_RESULT_FILE") { t.Fatalf("migration ran before evidence was accepted:\n%s", sequence) } } @@ -77,7 +77,7 @@ func TestMigrationBackupReceiptIsJournaledBeforeMigration(t *testing.T) { } sequence := strings.Join(f.Commands, "\n") evidenceIndex := strings.Index(sequence, `"sub_step":"`+journal.MigrationBackupSubStep+`"`) - migrationIndex := strings.Index(sequence, "OB_RESULT_FILE") + migrationIndex := strings.Index(sequence, "ONEBOX_RESULT_FILE") if evidenceIndex < 0 || migrationIndex < 0 || evidenceIndex > migrationIndex { t.Fatalf("receipt evidence was not journaled before migration:\n%s", sequence) } @@ -130,7 +130,7 @@ func TestExpiredMigrationBackupEvidenceStopsBeforeMigration(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "expired") { t.Fatalf("expired evidence did not stop deploy: %v", err) } - if sequence := strings.Join(f.Commands, "\n"); strings.Contains(sequence, "OB_RESULT_FILE") { + if sequence := strings.Join(f.Commands, "\n"); strings.Contains(sequence, "ONEBOX_RESULT_FILE") { t.Fatalf("migration ran with expired evidence:\n%s", sequence) } } diff --git a/internal/engine/recreate.go b/internal/engine/recreate.go index 81a3ad5f..67446abd 100644 --- a/internal/engine/recreate.go +++ b/internal/engine/recreate.go @@ -134,14 +134,14 @@ func (e *Engine) runLocalHook(ctx context.Context, name, run, remoteReleaseDir s c := exec.CommandContext(ctx, "sh", "-c", run) // verbatim by design c.Dir = e.Opts.LocalDir c.Env = append(os.Environ(), - "OB_APP="+e.Spec.Name, - "OB_HOST="+e.T.Host(), - "OB_SERVER="+e.T.Destination(), // OpenSSH user@host (IPv6 unbracketed) - "OB_SSH_USER="+e.T.SSHUser(), - "OB_SSH_PORT="+e.T.SSHPort(), - "OB_SSH_JUMP="+e.T.SSHJump(), // empty when the target is reached directly - "OB_RELEASE_DIR="+remoteReleaseDir, - "OB_RELEASE_ID="+filepath.Base(remoteReleaseDir), + "ONEBOX_APP="+e.Spec.Name, + "ONEBOX_HOST="+e.T.Host(), + "ONEBOX_SERVER="+e.T.Destination(), // OpenSSH user@host (IPv6 unbracketed) + "ONEBOX_SSH_USER="+e.T.SSHUser(), + "ONEBOX_SSH_PORT="+e.T.SSHPort(), + "ONEBOX_SSH_JUMP="+e.T.SSHJump(), // empty when the target is reached directly + "ONEBOX_RELEASE_DIR="+remoteReleaseDir, + "ONEBOX_RELEASE_ID="+filepath.Base(remoteReleaseDir), ) var out, errb bytes.Buffer c.Stdout, c.Stderr = &out, &errb diff --git a/internal/engine/recreate_test.go b/internal/engine/recreate_test.go index 77b28411..9c57a655 100644 --- a/internal/engine/recreate_test.go +++ b/internal/engine/recreate_test.go @@ -85,7 +85,7 @@ func TestRecreateRoleSurfacesFailedDrainSignal(t *testing.T) { } } -// A local hook must see the FULL user@host in $OB_SERVER (not the bare +// A local hook must see the FULL user@host in $ONEBOX_SERVER (not the bare // hostname), so hooks can ssh/rsync the deploy host without hardcoding it. func TestLocalHookGetsFullTargetInEnv(t *testing.T) { f := &transport.Fake{ @@ -94,7 +94,7 @@ func TestLocalHookGetsFullTargetInEnv(t *testing.T) { } cfg := testConfig() cfg.Hooks["pre_release"] = app.Command{ - Run: `test "$OB_SERVER" = "root@2001:db8::1" && test "$OB_SSH_USER" = "root" && test "$OB_HOST" = "2001:db8::1" && test "$OB_SSH_PORT" = "2222" || { echo "got server=[$OB_SERVER] user=[$OB_SSH_USER] host=[$OB_HOST] port=[$OB_SSH_PORT]" >&2; exit 1; }`, + Run: `test "$ONEBOX_SERVER" = "root@2001:db8::1" && test "$ONEBOX_SSH_USER" = "root" && test "$ONEBOX_HOST" = "2001:db8::1" && test "$ONEBOX_SSH_PORT" = "2222" || { echo "got server=[$ONEBOX_SERVER] user=[$ONEBOX_SSH_USER] host=[$ONEBOX_HOST] port=[$ONEBOX_SSH_PORT]" >&2; exit 1; }`, Local: true, } e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, LocalDir: t.TempDir()}) @@ -136,7 +136,7 @@ func TestRunHookNoopWhenAbsent(t *testing.T) { // A local hook runs on the operator's machine, which has no tunnel of its own, // so a hook that reaches the host itself needs the bastion named. Empty on a -// direct connection, so `ssh ${OB_SSH_JUMP:+-J $OB_SSH_JUMP}` works either way. +// direct connection, so `ssh ${ONEBOX_SSH_JUMP:+-J $ONEBOX_SSH_JUMP}` works either way. func TestLocalHookGetsTheJumpHostInEnv(t *testing.T) { f := &transport.Fake{ HostName: "10.20.0.10", TargetName: "root@10.20.0.10", @@ -144,7 +144,7 @@ func TestLocalHookGetsTheJumpHostInEnv(t *testing.T) { } cfg := testConfig() cfg.Hooks["pre_release"] = app.Command{ - Run: `test "$OB_SSH_JUMP" = "deploy@bastion.example.com:2222" || { echo "got jump=[$OB_SSH_JUMP]" >&2; exit 1; }`, + Run: `test "$ONEBOX_SSH_JUMP" = "deploy@bastion.example.com:2222" || { echo "got jump=[$ONEBOX_SSH_JUMP]" >&2; exit 1; }`, Local: true, } e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, LocalDir: t.TempDir()}) @@ -157,11 +157,11 @@ func TestLocalHookGetsAnEmptyJumpOnADirectConnection(t *testing.T) { f := &transport.Fake{HostName: "10.20.0.10", TargetName: "root@10.20.0.10", SSHUserName: "root", SSHPortName: "22"} cfg := testConfig() cfg.Hooks["pre_release"] = app.Command{ - Run: `test -z "$OB_SSH_JUMP" || { echo "got jump=[$OB_SSH_JUMP]" >&2; exit 1; }`, + Run: `test -z "$ONEBOX_SSH_JUMP" || { echo "got jump=[$ONEBOX_SSH_JUMP]" >&2; exit 1; }`, Local: true, } e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, LocalDir: t.TempDir()}) if err := e.RunHook(context.Background(), "pre_release", "/r", "/r/compose.yaml"); err != nil { - t.Fatalf("a direct connection must leave OB_SSH_JUMP empty: %v", err) + t.Fatalf("a direct connection must leave ONEBOX_SSH_JUMP empty: %v", err) } } diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index b0c31b50..6c87995c 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -68,7 +68,7 @@ func TestResumeSkipsCompletedStepsAndFinishes(t *testing.T) { if strings.Contains(seq, "--scale web=2") { t.Fatalf("web already rolled — resume must skip it:\n%s", seq) } - if strings.Contains(seq, "OB_RESULT_FILE") { + if strings.Contains(seq, "ONEBOX_RESULT_FILE") { t.Fatalf("migrate already ran — resume must not re-run it:\n%s", seq) } if !strings.Contains(seq, "--force-recreate --timeout 30 worker") { diff --git a/internal/onebox/job_plan_test.go b/internal/onebox/job_plan_test.go index 8f88effe..2bf0042f 100644 --- a/internal/onebox/job_plan_test.go +++ b/internal/onebox/job_plan_test.go @@ -136,7 +136,7 @@ func TestExecuteJobRejectsStaleReleaseBeforeContainerCreation(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "job plan is stale") { t.Fatalf("stale job plan was accepted: %v", err) } - if commands := strings.Join(fake.Commands, "\n"); strings.Contains(commands, "OB_RESULT_FILE") { + if commands := strings.Join(fake.Commands, "\n"); strings.Contains(commands, "ONEBOX_RESULT_FILE") { t.Fatalf("stale job plan created a job container:\n%s", commands) } } @@ -284,7 +284,7 @@ func TestExecuteJobRunsOnceAndJournalsTerminalResult(t *testing.T) { t.Fatalf("job result = %+v", result) } commands := strings.Join(fake.Commands, "\n") - if strings.Count(commands, "OB_RESULT_FILE=/run/onebox/job-result") != 1 { + if strings.Count(commands, "ONEBOX_RESULT_FILE=/run/onebox/job-result") != 1 { t.Fatalf("job did not run exactly once:\n%s", commands) } for _, want := range []string{`"operation_kind":"job_run"`, `"sub_step":"job:maintenance"`, `"event":"finish","status":"ok"`, approval.ApprovalDigest} { diff --git a/scripts/lima.sh b/scripts/lima.sh index 10d18a67..57e4275c 100755 --- a/scripts/lima.sh +++ b/scripts/lima.sh @@ -70,9 +70,9 @@ env_lines() { key="$(ssh_field IdentityFile)" # The same string shape ob.yml's `server:` field takes, parsed by the same # code: internal/target.Address is [user@]host[:port]. - printf 'OB_SERVER_E2E=1\n' - printf 'OB_E2E_SERVER=root@127.0.0.1:%s\n' "$port" - printf 'OB_E2E_SERVER_KEY=%s\n' "$key" + printf 'ONEBOX_SERVER_E2E=1\n' + printf 'ONEBOX_E2E_SERVER=root@127.0.0.1:%s\n' "$port" + printf 'ONEBOX_E2E_SERVER_KEY=%s\n' "$key" } run_tests() { @@ -80,7 +80,7 @@ run_tests() { while IFS= read -r line; do env+=("$line"); done < <(env_lines) # -count=1 because a cached pass against a guest that has since changed is # a green tick for work nobody did. - env OB_E2E=1 "${env[@]}" \ + env ONEBOX_E2E=1 "${env[@]}" \ go test "${repo}/e2e/" -count=1 -timeout 40m -run Server "$@" } diff --git a/scripts/release.sh b/scripts/release.sh index eb97a4f5..21a0090d 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -79,7 +79,7 @@ increment_decimal() { # create would block that fix forever. # # The check is skipped only when origin is not a GitHub remote, which no real -# release is. Set OB_RELEASE_REPOSITORY to name the repository explicitly. +# release is. Set ONEBOX_RELEASE_REPOSITORY to name the repository explicitly. require_previous_release_terminal() { local repository=$1 previous="" candidate previous_commit run_status while IFS= read -r candidate; do @@ -92,7 +92,7 @@ require_previous_release_terminal() { return 0 fi if ! command -v gh >/dev/null 2>&1; then - echo "gh is required to confirm that the ${previous} release run has finished; install it or set OB_RELEASE_REPOSITORY=" >&2 + echo "gh is required to confirm that the ${previous} release run has finished; install it or set ONEBOX_RELEASE_REPOSITORY=" >&2 return 1 fi previous_commit=$(git rev-parse --verify "refs/tags/${previous}^{commit}") @@ -116,7 +116,7 @@ github_repository_slug() { esac } -release_repository=${OB_RELEASE_REPOSITORY:-$(github_repository_slug "$(git remote get-url origin)")} +release_repository=${ONEBOX_RELEASE_REPOSITORY:-$(github_repository_slug "$(git remote get-url origin)")} if [ -n "$release_repository" ]; then require_previous_release_terminal "$release_repository" fi diff --git a/scripts/release_workflow_test.go b/scripts/release_workflow_test.go index 5822b040..7fac3b8d 100644 --- a/scripts/release_workflow_test.go +++ b/scripts/release_workflow_test.go @@ -511,7 +511,7 @@ func TestReleaseWaitsForThePreviousReleaseRunToFinish(t *testing.T) { if test.status == "api-failure" { stub = "#!/usr/bin/env bash\necho 'HTTP 502' >&2\nexit 1\n" } - output, err := runRelease(t, repo, normalJustShim, nil, "OB_RELEASE_REPOSITORY=labstack/onebox", "RELEASE_TEST_GH="+stub) + output, err := runRelease(t, repo, normalJustShim, nil, "ONEBOX_RELEASE_REPOSITORY=labstack/onebox", "RELEASE_TEST_GH="+stub) skipIfUTCMonthChanged(t, month) switch { case test.wantErr == "" && err != nil: diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 6391dbec..ebeb8da6 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -94,6 +94,7 @@ export default defineConfig({ { label: "Schedule a job", slug: "guides/schedule-a-job" }, { label: "Roll back a release", slug: "guides/roll-back" }, { label: "Deploy through a jump host", slug: "guides/deploy-through-a-jump-host" }, + { label: "Environment variables", slug: "guides/environment-variables" }, { label: "Adopt an existing Compose file", slug: "guides/adopt-compose" }, { label: "Eject", slug: "guides/eject" }, ], diff --git a/site/src/content/docs/guides/back-up-a-database.mdx b/site/src/content/docs/guides/back-up-a-database.mdx index 325021ec..929ed71d 100644 --- a/site/src/content/docs/guides/back-up-a-database.mdx +++ b/site/src/content/docs/guides/back-up-a-database.mdx @@ -75,10 +75,10 @@ plus the repository key: ```sh BACKUP_ACCESS_KEY_ID=... BACKUP_SECRET_ACCESS_KEY=... -OB_REPOSITORY_KEY=$(openssl rand -hex 32) +ONEBOX_REPOSITORY_KEY=$(openssl rand -hex 32) ``` -`OB_REPOSITORY_KEY` is a 32-byte key read as hex, so exactly 64 hex characters. +`ONEBOX_REPOSITORY_KEY` is a 32-byte key read as hex, so exactly 64 hex characters. A passphrase-shaped value is refused before anything restarts. You do not stage this file yourself: `ob backup enable` decrypts it and installs it mode-0600 on the host. diff --git a/site/src/content/docs/guides/deploy-through-a-jump-host.mdx b/site/src/content/docs/guides/deploy-through-a-jump-host.mdx index 7f9190cc..773a0086 100644 --- a/site/src/content/docs/guides/deploy-through-a-jump-host.mdx +++ b/site/src/content/docs/guides/deploy-through-a-jump-host.mdx @@ -132,12 +132,13 @@ first hop paper over a problem with the second. ## Local hooks A hook declared `local: true` runs on your machine, which has no tunnel of its -own. Such a hook receives `OB_SSH_JUMP` alongside `OB_SERVER`, empty when the +own. Such a hook receives `ONEBOX_SSH_JUMP` alongside `ONEBOX_SERVER`, empty when the connection is direct: ```sh -ssh ${OB_SSH_JUMP:+-J "$OB_SSH_JUMP"} "$OB_SERVER" -p "$OB_SSH_PORT" 'uptime' +ssh ${ONEBOX_SSH_JUMP:+-J "$ONEBOX_SSH_JUMP"} "$ONEBOX_SERVER" -p "$ONEBOX_SSH_PORT" 'uptime' ``` A local hook written before you added a bastion will otherwise try to reach a -server it can no longer see. +server it can no longer see. The rest of the namespace is listed under +[Environment variables](/guides/environment-variables/). diff --git a/site/src/content/docs/guides/environment-variables.mdx b/site/src/content/docs/guides/environment-variables.mdx new file mode 100644 index 00000000..b0ed70ab --- /dev/null +++ b/site/src/content/docs/guides/environment-variables.mdx @@ -0,0 +1,137 @@ +--- +title: Environment variables +description: Every variable Onebox sets or reads, and how to migrate off the retired OB_ prefix. +summary: The ONEBOX_ namespace — what Onebox injects into hooks and jobs, what it reads from your shell and CI, and the one-line rename from the old OB_ names. +sidebar: + order: 9 +read_when: + - "Writing a hook or a job that talks back to Onebox" + - "Upgrading from a release that used OB_ variables" +--- + +Onebox owns the `ONEBOX_` prefix. Everything it injects or reads lives there, +which keeps it greppable in a CI settings page, a secrets store, or someone +else's hook script. + +Your application's own variables are never touched. A workload that expects +`FANOUT_*`, `DATABASE_URL`, or `OTEL_EXPORTER_OTLP_ENDPOINT` receives exactly +those keys, unchanged — Onebox passes workload environment through rather than +namespacing it. + +## Injected into local hooks + +A hook declared `local: true` runs on your machine. It is given enough to reach +the server itself, because Onebox's own connection is not something a separate +process can borrow: + +| Variable | Value | +| --- | --- | +| `ONEBOX_APP` | The application name | +| `ONEBOX_HOST` | The server's hostname, unbracketed | +| `ONEBOX_SERVER` | `user@host`, valid as an OpenSSH destination | +| `ONEBOX_SSH_USER` | The SSH user | +| `ONEBOX_SSH_PORT` | The SSH port, separate because `user@host:port` is invalid for both `ssh` and `rsync` | +| `ONEBOX_SSH_JUMP` | The jump host, empty on a direct connection — see [Deploy through a jump host](/guides/deploy-through-a-jump-host/) | +| `ONEBOX_RELEASE_DIR` | Absolute path of the release directory on the server | +| `ONEBOX_RELEASE_ID` | The release identifier | + +```sh +ssh ${ONEBOX_SSH_JUMP:+-J "$ONEBOX_SSH_JUMP"} "$ONEBOX_SERVER" -p "$ONEBOX_SSH_PORT" 'uptime' +``` + +## The job result protocol + +| Variable | Value | +| --- | --- | +| `ONEBOX_RESULT_FILE` | Path a job writes its structured result to | + +A job that reports back writes one JSON document there: + +```sh +printf '{"schema_version":"onebox.run/job-result/v1alpha1","changed":true}' > "$ONEBOX_RESULT_FILE" +``` + +## Backup helpers + +| Variable | Value | +| --- | --- | +| `ONEBOX_REPOSITORY_KEY` | Repository encryption key | +| `ONEBOX_S3_KEY_ENTRY` | Name of the entry holding the S3 access key | +| `ONEBOX_S3_SECRET_ENTRY` | Name of the entry holding the S3 secret key | +| `ONEBOX_S3_SESSION_TOKEN_ENTRY` | Name of the entry holding the S3 session token | + +## Read from your shell or CI + +| Variable | Effect | +| --- | --- | +| `ONEBOX_LOCAL` | `1` or `true` runs against the local Docker daemon instead of connecting over SSH | +| `ONEBOX_BIN_DIR` | Where `just build` writes the binary, and where the other recipes look for it (default `bin`) | +| `ONEBOX_INSTALL_DIR` | Where `just install` places the binary (default `~/.local/bin`) | +| `ONEBOX_VERSION` | Version string `just build` stamps into the binary; must match `vYYYY.M.REVISION` | +| `ONEBOX_RELEASE_REPOSITORY` | Repository `scripts/release.sh` queries to confirm the previous release run finished, when the git remote does not name it | + +`ONEBOX_LOCAL` is the only one of these Onebox itself reads; the rest are build +and release controls read by `just` and the release script. + +Contributor and test controls — `ONEBOX_E2E`, `ONEBOX_E2E_*`, `ONEBOX_SERVER_E2E`, +and `ONEBOX_UPDATE_VERDICTS` — follow the same prefix. `just e2e` sets +`ONEBOX_E2E` for you; the rest are set by hand when running a suite or +refreshing a frozen fixture. + +Nothing in this namespace reaches a generated runtime. Generation reads no +process environment at all, so the same project renders the same Compose on +your laptop and in CI, and a digest keeps meaning something. + +## Migrating from `OB_` + +Earlier releases used `OB_`. The rename is mechanical and complete — there are +no aliases, no fallback reads, and no deprecation window: + +| Before | After | +| --- | --- | +| `OB_APP` | `ONEBOX_APP` | +| `OB_HOST` | `ONEBOX_HOST` | +| `OB_SERVER` | `ONEBOX_SERVER` | +| `OB_SSH_USER` | `ONEBOX_SSH_USER` | +| `OB_SSH_PORT` | `ONEBOX_SSH_PORT` | +| `OB_RELEASE_DIR` | `ONEBOX_RELEASE_DIR` | +| `OB_RELEASE_ID` | `ONEBOX_RELEASE_ID` | +| `OB_RESULT_FILE` | `ONEBOX_RESULT_FILE` | +| `OB_REPOSITORY_KEY` | `ONEBOX_REPOSITORY_KEY` | +| `OB_S3_KEY_ENTRY` | `ONEBOX_S3_KEY_ENTRY` | +| `OB_S3_SECRET_ENTRY` | `ONEBOX_S3_SECRET_ENTRY` | +| `OB_S3_SESSION_TOKEN_ENTRY` | `ONEBOX_S3_SESSION_TOKEN_ENTRY` | +| `OB_LOCAL` | `ONEBOX_LOCAL` | +| `OB_INSTALL_DIR` | `ONEBOX_INSTALL_DIR` | +| `OB_BIN_DIR` | `ONEBOX_BIN_DIR` | +| `OB_VERSION` | `ONEBOX_VERSION` | +| `OB_RELEASE_REPOSITORY` | `ONEBOX_RELEASE_REPOSITORY` | +| `OB_E2E`, `OB_E2E_*` | `ONEBOX_E2E`, `ONEBOX_E2E_*` | +| `OB_SERVER_E2E` | `ONEBOX_SERVER_E2E` | +| `OB_UPDATE_VERDICTS` | `ONEBOX_UPDATE_VERDICTS` | + +Update your hook scripts, CI and release settings, and any job writing to the +result file. The executable is still `ob`, the project file is still `ob.yml`, +and the API version is still `onebox.run/v1` — only the environment namespace +changed. + +A stray old name does not fall back: it is simply never read, so a hook reading +`$OB_SERVER` gets an empty string rather than an error — unless your own shell +or CI still exports one, in which case the hook receives that stale value, +because hooks inherit your environment. Grep for `OB_` once across your +repository and CI configuration and the migration is done. + +### Protected databases need `ob backup enable` re-run + +If a service has backups enabled, renaming the key inside your encrypted +credential file is not enough on its own. The decrypted file lives on the host, +written once when backups were enabled, and every later deploy reads it as it +stands: + +```sh +ob backup enable +``` + +Re-running it rewrites the host copy with `ONEBOX_REPOSITORY_KEY`. Until you do, +the wal-g wrapper refuses to run rather than archiving without an encryption +key, so a backup will fail loudly instead of silently landing unencrypted. diff --git a/site/src/content/docs/guides/run-migrations.mdx b/site/src/content/docs/guides/run-migrations.mdx index 7c11bb60..6f0f6353 100644 --- a/site/src/content/docs/guides/run-migrations.mdx +++ b/site/src/content/docs/guides/run-migrations.mdx @@ -38,14 +38,14 @@ gates read: ## Report what actually happened -A job can write JSON or `key=value` data to `$OB_RESULT_FILE` using the +A job can write JSON or `key=value` data to `$ONEBOX_RESULT_FILE` using the `onebox.run/job-result/v1alpha1` protocol. Provider-aware evidence records `changed`, `provider`, and ordered `before_revisions` / `after_revisions`. ```sh #!/bin/sh ./bin/migrate -cat > "$OB_RESULT_FILE" < "$ONEBOX_RESULT_FILE" <