From 265ec32f18fe605d1acb979d7ada11a94cf65b0f Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 13:28:34 -0700 Subject: [PATCH 1/7] feat(deploy): manage operator secrets in the OpenBao web UI (#14) ## Summary Operator secrets are now managed in OpenBao's web UI instead of gitignored env files pushed by a script. - `moon run deploy:openbao` forwards OpenBao to `localhost:18200`, logs in through Kubernetes auth as a new `snapflow-operator` role and prints a one-hour token to sign in with. OpenBao stays cluster-internal; the UI is never routed through Traefik. - The `snapflow-operator` policy (written by the configure job) can create, edit, roll back and delete only the operator secrets: `snapflow/api`, `alerts`, `backups`, `cloudflare-dns` and `edges/*`. It cannot read the keyrings and platform credentials the job generates, write any other path, or change OpenBao itself. The old shell used the far broader `snapflow-configure` role. - Closing the session (Ctrl-C) annotates every ExternalSecret with `force-sync`, so edits reach the cluster at once instead of at the hourly refresh, as the push did. - `ui = true` in the OpenBao server config. - Removed: `deploy:secrets`, `push-secrets.sh`, `openbao-shell.sh` and `lib/openbao-session.sh`. `docs/deployment.md` describes the UI workflow. The `/deploy/secrets/production/` gitignore entry stays until the operator has moved its existing local values into OpenBao and deleted the folder, so those files can never be committed by accident. ## Testing - Against a throwaway OpenBao 2.7.1 (the pinned image) with the policy generated exactly as `configure.sh` writes it, an operator token could create, update, read, roll back and delete `snapflow/api` and `snapflow/edges/home`, and list `snapflow/` and `snapflow/edges/`; it was denied reading or writing `snapflow/api/encryption-keyring` and `snapflow/platform/redis`, writing other paths, writing policies and listing mounts. - `deploy/scripts/validate.sh` passes for every stage; shellcheck is clean on the new and changed scripts. --- .../infrastructure/controllers/openbao.yaml | 5 +- deploy/moon.yml | 13 +- .../platform/openbao-configure/configure.sh | 21 +++ .../openbao-configure/kustomization.yaml | 1 + .../platform/openbao-configure/operator.yaml | 9 + deploy/scripts/lib/openbao-session.sh | 40 ---- deploy/scripts/openbao-shell.sh | 16 -- deploy/scripts/openbao-ui.sh | 67 +++++++ deploy/scripts/push-secrets.sh | 172 ------------------ deploy/scripts/validate.sh | 2 +- docs/deployment.md | 32 ++-- 11 files changed, 120 insertions(+), 258 deletions(-) create mode 100644 deploy/platform/openbao-configure/operator.yaml delete mode 100644 deploy/scripts/lib/openbao-session.sh delete mode 100644 deploy/scripts/openbao-shell.sh create mode 100644 deploy/scripts/openbao-ui.sh delete mode 100644 deploy/scripts/push-secrets.sh diff --git a/deploy/infrastructure/controllers/openbao.yaml b/deploy/infrastructure/controllers/openbao.yaml index 3863039..155ddcc 100644 --- a/deploy/infrastructure/controllers/openbao.yaml +++ b/deploy/infrastructure/controllers/openbao.yaml @@ -58,7 +58,10 @@ spec: enabled: true setNodeId: true config: | - ui = false + # The web UI is how the operator manages secrets. The service is + # cluster-internal and reached only by port-forward + # (moon run deploy:openbao). + ui = true listener "tcp" { tls_disable = 1 diff --git a/deploy/moon.yml b/deploy/moon.yml index ae0a23d..a302c26 100644 --- a/deploy/moon.yml +++ b/deploy/moon.yml @@ -30,24 +30,15 @@ tasks: runFromWorkspaceRoot: true openbao: - description: "Opens a shell logged in to the cluster's OpenBao for entering and rotating secrets" + description: "Opens the cluster's OpenBao web UI on localhost, signed in for managing the operator secrets" command: "bash" - args: ["deploy/scripts/openbao-shell.sh"] + args: ["deploy/scripts/openbao-ui.sh"] options: cache: false interactive: true runInCI: false runFromWorkspaceRoot: true - secrets: - description: "Validates the secrets files in deploy/secrets/production and writes every changed file to OpenBao" - command: "bash" - args: ["deploy/scripts/push-secrets.sh"] - options: - cache: false - runInCI: false - runFromWorkspaceRoot: true - reconcile: description: "Makes Flux pull main and apply every stage now" command: "flux" diff --git a/deploy/platform/openbao-configure/configure.sh b/deploy/platform/openbao-configure/configure.sh index 0d56c22..9a1ee63 100644 --- a/deploy/platform/openbao-configure/configure.sh +++ b/deploy/platform/openbao-configure/configure.sh @@ -27,6 +27,8 @@ readonly keyring_key_id="production-1" readonly keyring_key_bytes=32 readonly generated_secret_length=40 readonly service_token_ttl="1h" +# The secrets the operator supplies in the OpenBao UI, under snapflow/. +readonly operator_secret_paths="api alerts backups cloudflare-dns edges/*" BAO_TOKEN="$(bao write -field=token "auth/kubernetes/login" role="${login_role}" jwt="$(cat "${token_file}")")" export BAO_TOKEN @@ -110,6 +112,24 @@ path "${kv_mount}/data/snapflow/*" { } POLICY +# The operator (moon run deploy:openbao, docs/deployment.md "Secrets") manages +# only the secrets it supplies: it can browse the snapflow folder and create, +# edit, roll back and delete these paths, but cannot read the keyrings and +# platform credentials this job generates or change OpenBao itself. +{ + printf 'path "%s/metadata/" {\n capabilities = ["list"]\n}\n' "${kv_mount}" + printf 'path "%s/metadata/snapflow/" {\n capabilities = ["list"]\n}\n' "${kv_mount}" + printf 'path "%s/metadata/snapflow/edges/" {\n capabilities = ["list"]\n}\n' "${kv_mount}" + # The paths are policy globs, not files, so they must not expand here. + set -f + for path in ${operator_secret_paths}; do + printf 'path "%s/data/snapflow/%s" {\n capabilities = ["create", "read", "update", "delete"]\n}\n' "${kv_mount}" "${path}" + printf 'path "%s/metadata/snapflow/%s" {\n capabilities = ["read", "list", "delete"]\n}\n' "${kv_mount}" "${path}" + printf 'path "%s/undelete/snapflow/%s" {\n capabilities = ["update"]\n}\n' "${kv_mount}" "${path}" + done + set +f +} | write_policy snapflow-operator + # The backup CronJob (deploy/platform/backups) may take a raft snapshot and # nothing else. write_policy snapflow-openbao-backup </dev/null & - openbao_port_forward=$! - trap 'kill "${openbao_port_forward}"' EXIT - - export BAO_ADDR="http://127.0.0.1:${openbao_local_port}" - local attempt - for ((attempt = 1; attempt <= openbao_ready_attempts; attempt++)); do - bao status >/dev/null 2>&1 && break - if ((attempt == openbao_ready_attempts)); then - echo "OpenBao is not reachable or is sealed at ${BAO_ADDR}" >&2 - exit 1 - fi - sleep "${openbao_ready_interval_seconds}" - done - - local jwt - jwt="$(kubectl --namespace "${openbao_namespace}" create token "${openbao_service_account}" --audience "${openbao_token_audience}" --duration "${openbao_token_duration}")" - BAO_TOKEN="$(bao write -field=token "auth/kubernetes/login" role="${openbao_login_role}" jwt="${jwt}")" - export BAO_TOKEN -} diff --git a/deploy/scripts/openbao-shell.sh b/deploy/scripts/openbao-shell.sh deleted file mode 100644 index a7e4a0d..0000000 --- a/deploy/scripts/openbao-shell.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Snapflow. All rights reserved. -# -# Snapflow is licensed under the GNU Affero General Public License v3.0. -# You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html -# -# SPDX-License-Identifier: AGPL-3.0 - -set -euo pipefail - -# shellcheck source=deploy/scripts/lib/openbao-session.sh -. "deploy/scripts/lib/openbao-session.sh" - -openbao_session_open -echo "OpenBao is at ${BAO_ADDR} with a ${openbao_token_duration} token. Exit the shell to close the connection." -"${SHELL:-bash}" diff --git a/deploy/scripts/openbao-ui.sh b/deploy/scripts/openbao-ui.sh new file mode 100644 index 0000000..6e8c4a3 --- /dev/null +++ b/deploy/scripts/openbao-ui.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Snapflow. All rights reserved. +# +# Snapflow is licensed under the GNU Affero General Public License v3.0. +# You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html +# +# SPDX-License-Identifier: AGPL-3.0 + +# Opens the cluster's OpenBao web UI on this machine for managing the operator +# secrets (docs/deployment.md, "Secrets"). OpenBao is never exposed outside +# the cluster: this forwards its port to localhost and logs in through +# Kubernetes auth as the snapflow-operator role, whose token can edit only the +# operator secrets and expires after an hour. On exit it asks External Secrets +# to sync now instead of at its hourly refresh, and Reloader then restarts only +# the workloads whose secrets changed. + +set -euo pipefail + +readonly namespace="openbao" +readonly service="openbao" +readonly service_port=8200 +readonly local_port=18200 +readonly login_role="snapflow-operator" +readonly service_account="snapflow-openbao-operator" +readonly token_audience="openbao" +readonly login_jwt_duration="10m" +readonly ready_attempts=30 +readonly ready_interval_seconds=1 +readonly address="http://127.0.0.1:${local_port}" + +close() { + kill "${port_forward}" 2>/dev/null || true + if [[ -n "${token:-}" ]]; then + kubectl annotate externalsecrets.external-secrets.io --all --all-namespaces \ + "force-sync=$(date +%s)" --overwrite >/dev/null + echo "Requested an External Secrets sync; changed secrets reach the cluster now." + fi +} + +kubectl --namespace "${namespace}" port-forward "service/${service}" "${local_port}:${service_port}" >/dev/null & +port_forward=$! +trap close EXIT + +for ((attempt = 1; attempt <= ready_attempts; attempt++)); do + curl --silent --fail --output /dev/null "${address}/v1/sys/health" && break + if ((attempt == ready_attempts)); then + echo "OpenBao is not reachable or is sealed at ${address}" >&2 + exit 1 + fi + sleep "${ready_interval_seconds}" +done + +jwt="$(kubectl --namespace "${namespace}" create token "${service_account}" \ + --audience "${token_audience}" --duration "${login_jwt_duration}")" +token="$(jq --null-input --arg role "${login_role}" --arg jwt "${jwt}" '{role: $role, jwt: $jwt}' | + curl --silent --fail --data @- "${address}/v1/auth/kubernetes/login" | + jq --exit-status --raw-output '.auth.client_token')" + +cat <&2 - failures=$((failures + 1)) -} - -unquote() { - local value="$1" - if ((${#value} >= 2)) && [[ "${value:0:1}" == "${value: -1}" && ("${value:0:1}" == '"' || "${value:0:1}" == "'") ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "${value}" -} - -load_secret_file() { - local file="$1" schema="$2" path="$3" - local -A allowed=() seen=() values=() - local -a pairs=() - local line key value number=0 - - for key in ${required_keys[${schema}]} ${optional_keys[${schema}]:-}; do - allowed["${key}"]=1 - done - - while IFS= read -r line || [[ -n "${line}" ]]; do - number=$((number + 1)) - line="${line%$'\r'}" - [[ "${line}" =~ ${comment_line_pattern} ]] && continue - if [[ ! "${line}" =~ ${secret_line_pattern} ]]; then - fail "${file}:${number}: expected KEY=VALUE" - continue - fi - key="${BASH_REMATCH[1]}" - value="$(unquote "${BASH_REMATCH[2]}")" - if [[ -z "${allowed[${key}]+set}" ]]; then - fail "${file}:${number}: ${key} is not a ${schema} secret; non-secret settings come from the Helm chart" - elif [[ -n "${seen[${key}]+set}" ]]; then - fail "${file}:${number}: ${key} is set more than once" - else - seen["${key}"]=1 - if [[ -n "${value}" ]]; then - values["${key}"]=1 - pairs+=("${key}" "${value}") - fi - fi - done <"${file}" - - for key in ${required_keys[${schema}]}; do - [[ -n "${values[${key}]+set}" ]] || fail "${file}: ${key} is required" - done - - if ((${#pairs[@]} == 0)); then - payloads["${path}"]='{}' - else - payloads["${path}"]="$(printf '%s\n' "${pairs[@]}" | jq -cSRn "${pairs_to_object}")" - fi -} - -collect_secrets() { - if [[ ! -d "${source_directory}" ]]; then - echo "${source_directory} does not exist; see Secrets in docs/deployment.md." >&2 - exit 1 - fi - if [[ -n "$(git ls-files -- "${source_directory}")" ]]; then - fail "${source_directory} is tracked by git; remove it from the index before pushing secrets" - fi - - local file relative name schema - while IFS= read -r -d '' file; do - fail "${file} is readable by other users; run chmod 600 on it" - done < <(find "${source_directory}" -type f -perm /077 -print0) - - for schema in "${!required_keys[@]}"; do - [[ "${schema}" == "${edge_schema}" ]] && continue - [[ -f "${source_directory}/${schema}${env_extension}" ]] || fail "${source_directory}/${schema}${env_extension} is missing" - done - - while IFS= read -r -d '' file; do - relative="${file#"${source_directory}"/}" - name="${relative%"${env_extension}"}" - if [[ "${relative}" != *"${env_extension}" ]]; then - schema="" - elif [[ "${name}" == "${edges_directory}/"* && "${name#"${edges_directory}"/}" != */* ]]; then - schema="${edge_schema}" - elif [[ "${name}" != */* && "${name}" != "${edge_schema}" && -n "${required_keys[${name}]+set}" ]]; then - schema="${name}" - else - schema="" - fi - if [[ -z "${schema}" ]]; then - fail "${file} is not a known secrets file" - continue - fi - load_secret_file "${file}" "${schema}" "${kv_prefix}/${name}" - done < <(find "${source_directory}" -type f -print0 | sort -z) - - if ((failures > 0)); then - echo "Nothing was pushed: ${failures} problem(s) found." >&2 - exit 1 - fi -} - -current_secret() { - local path="$1" output - if output="$(bao kv get -mount="${kv_mount}" -format=json "${path}" 2>&1)"; then - printf '%s' "${output}" | jq -cS '.data.data // {}' - elif [[ "${output}" == *"${missing_secret_message}"* ]]; then - echo '{}' - else - echo "${output}" >&2 - return 1 - fi -} - -push_secrets() { - local path changed=0 - openbao_session_open - while IFS= read -r path; do - if [[ "$(current_secret "${path}")" == "${payloads[${path}]}" ]]; then - echo "${path}: unchanged" - else - printf '%s' "${payloads[${path}]}" | bao kv put -mount="${kv_mount}" "${path}" - >/dev/null - echo "${path}: updated" - changed=$((changed + 1)) - fi - done < <(printf '%s\n' "${!payloads[@]}" | sort) - - if ((changed > 0)); then - kubectl annotate externalsecrets.external-secrets.io --all --all-namespaces "force-sync=$(date +%s)" --overwrite >/dev/null - echo "Requested an immediate External Secrets sync; Reloader restarts the workloads whose secrets changed." - fi -} - -collect_secrets -push_secrets diff --git a/deploy/scripts/validate.sh b/deploy/scripts/validate.sh index b88e446..a92398c 100644 --- a/deploy/scripts/validate.sh +++ b/deploy/scripts/validate.sh @@ -50,4 +50,4 @@ for kustomization in "${substituted_kustomizations[@]}"; do done kubeconform_strict <"${deploy_root}/clusters/production/stages.yaml" -shellcheck --external-sources "${deploy_root}"/scripts/*.sh "${deploy_root}"/scripts/lib/*.sh "${deploy_root}"/bootstrap/*.sh "${deploy_root}"/platform/openbao-configure/*.sh +shellcheck --external-sources "${deploy_root}"/scripts/*.sh "${deploy_root}"/bootstrap/*.sh "${deploy_root}"/platform/openbao-configure/*.sh diff --git a/docs/deployment.md b/docs/deployment.md index a2d663c..0bccba4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -45,7 +45,6 @@ deploy/ charts/snapflow/ Helm chart for api, worker, dashboard, marketing, snapshot-manager and edges components/ shared kustomize components (HelmRelease defaults) scripts/ scripts behind the moon tasks - secrets/production/ your operator secrets, gitignored and pushed with moon run deploy:secrets ``` Flux applies the stages in order: `infrastructure-controllers` → `openbao-configure` → `infrastructure-configs` → `platform` → `platform-jobs` → `platform-backups` and `apps`. `images` runs alongside once the secret store is ready. @@ -58,8 +57,7 @@ Flux applies the stages in order: `infrastructure-controllers` → `openbao-conf | `moon run :image` | Builds the app image into the local Docker engine | | `moon run :publish` | Typechecks, lints, validates, builds, scans with Trivy, and only then tags `build--`; the commit must be on `dev` | | `moon run deploy:bootstrap` | Creates the OpenBao seal key if missing and bootstraps Flux | -| `moon run deploy:secrets` | Checks the secrets files in `deploy/secrets/production/` and writes every changed file to OpenBao | -| `moon run deploy:openbao` | Opens a shell logged in to OpenBao for inspecting secrets by hand | +| `moon run deploy:openbao` | Opens the OpenBao web UI on `localhost:18200` and prints a one-hour token for managing the operator secrets | | `moon run deploy:reconcile` | Makes Flux pull `main` and apply every stage now | | `moon run deploy:status` | Shows every Flux stage, Helm release and image policy | @@ -82,18 +80,18 @@ Machine releases (the Snapflow runtime, its installer and the TUF repository at 2. **Router**: forward TCP 80, 443, 8443 and UDP 8443 (edge), and TCP 9443 (site, registry and storage) to the machine. 3. **k3s**: `sudo PUBLIC_IP= deploy/bootstrap/install-k3s.sh server-edge` (the API is advertised on the address of the default route; set `NODE_IP` to pick another), then copy `/etc/rancher/k3s/k3s.yaml` to your workstation's kubeconfig and replace `127.0.0.1` with the machine's address. 4. **Flux**: `SNAPFLOW_AUTOMATION_PRIVATE_KEY_FILE= moon run deploy:bootstrap`. It installs the [Flux Operator](https://fluxcd.control-plane.io/operator/), stores the App key in the `flux-system` secret (the only copy outside your password manager), and applies the `FluxInstance` in `deploy/clusters/production/flux-system`, which syncs `main` as the App; no deploy key is created. From then on Flux manages the operator and itself from Git. Back up the printed OpenBao seal key offline; it decrypts everything in OpenBao. -5. **Secrets**: fill in the files in `deploy/secrets/production/` (gitignored, mode `600`) and run `moon run deploy:secrets`: +5. **Secrets**: run `moon run deploy:openbao`, open the printed URL and sign in with method *Token* and the printed token. Under `secret/snapflow/`, create each secret below with *Create secret*, one key per field. Press Ctrl-C in the terminal when you are done, which asks External Secrets to sync the changes now. - | File | OpenBao path | Keys | - | --- | --- | --- | - | `api.env` | `snapflow/api` | `BETTER_AUTH_SECRETS`, `RESEND_API_KEY`, `SNAPFLOW_CLOUDFLARE_API_TOKEN`, `SNAPFLOW_CLOUDFLARE_WEBHOOK_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_PLATFORM_WEBHOOK_SECRET`, `STRIPE_CONNECT_WEBHOOK_SECRET`, and optionally `HEALTH_CHECK_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (same names as `apps/api/.env`) | - | `alerts.env` | `snapflow/alerts` | `discord-webhook-url` (a Discord channel webhook, see [Alerts](#alerts)) | - | `backups.env` | `snapflow/backups` | `access-key-id`, `secret-access-key` (the R2 S3 credentials, see [Backups](#backups)) | - | `cloudflare-dns.env` | `snapflow/cloudflare-dns` | `api-token` (the `Zone:DNS:Edit` token) | - | `edges/.env` | `snapflow/edges/` | `id`, `enrollment-secret` (step 6) | + | OpenBao path | Keys | + | --- | --- | + | `snapflow/api` | `BETTER_AUTH_SECRETS`, `RESEND_API_KEY`, `SNAPFLOW_CLOUDFLARE_API_TOKEN`, `SNAPFLOW_CLOUDFLARE_WEBHOOK_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_PLATFORM_WEBHOOK_SECRET`, `STRIPE_CONNECT_WEBHOOK_SECRET`, and optionally `HEALTH_CHECK_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (same names as `apps/api/.env`) | + | `snapflow/alerts` | `discord-webhook-url` (a Discord channel webhook, see [Alerts](#alerts)) | + | `snapflow/backups` | `access-key-id`, `secret-access-key` (the R2 S3 credentials, see [Backups](#backups)) | + | `snapflow/cloudflare-dns` | `api-token` (the `Zone:DNS:Edit` token) | + | `snapflow/edges/` | `id`, `enrollment-secret` (step 6) | - Only secrets go in these files; every other api setting comes from the Helm chart. The keys each file accepts are defined in `deploy/scripts/push-secrets.sh`. The directory only ever leaves your machine into OpenBao. The task refuses to push anything if a required key is missing or empty, a key is unknown, a file is readable by other users, or the directory is tracked by git. It never prints values and writes only the files that changed. Everything else (api keyrings; Postgres, Redis, RustFS and snapshot-manager credentials; the tunnel CA) is generated in the cluster, and Cloudflare's public origin pull CA ships with the chart. Keep a copy of the directory in your password manager; OpenBao also keeps earlier versions of every path. -6. **Edge**: register the edge with the admin API (`POST /api/admin/edges`, then `POST /api/admin/edges/:id/enrollment-secret`), put its `id` and `enrollment-secret` in `deploy/secrets/production/edges/home.env` (mode `600`), run `moon run deploy:secrets`, and add `{name: home, node: }` to `edges` in `deploy/apps/production/snapflow.yaml`. + Only secrets go in OpenBao; every other api setting comes from the Helm chart. The UI token can create, edit, roll back and delete only these paths and expires after an hour; OpenBao is never exposed outside the cluster. Every save is a new version, so *Version history* shows and restores earlier values. A missing or misspelled key shows up as a failing ExternalSecret (`kubectl get externalsecrets -A`) and a Flux alert. Everything else (api keyrings; Postgres, Redis, RustFS and snapshot-manager credentials; the tunnel CA) is generated in the cluster, and Cloudflare's public origin pull CA ships with the chart. Keep a copy of these values in your password manager. +6. **Edge**: register the edge with the admin API (`POST /api/admin/edges`, then `POST /api/admin/edges/:id/enrollment-secret`), store its `id` and `enrollment-secret` at `snapflow/edges/home` in the OpenBao UI, and add `{name: home, node: }` to `edges` in `deploy/apps/production/snapflow.yaml`. 7. **Public images**: the images are public, like the source, so neither the cluster nor Flux needs a registry token. After the first publish, open each package under the organization's *Packages* (`snapflow/api`, `snapflow/dashboard`, `snapflow/marketing`, `snapflow/snapshot-manager`, `snapflow/edge`) and set *Package settings > Change visibility* to *Public* (the organization must allow public packages under *Settings > Packages*). @@ -114,17 +112,17 @@ Every sandbox host serves code its owner controls, including the system toolbox, ## Operations -- **Rotate a secret**: edit its file in `deploy/secrets/production/` and run `moon run deploy:secrets`; External Secrets syncs immediately and Reloader rolls only the workloads whose secrets changed. Removing an edge file does not delete its OpenBao path; delete it with `moon run deploy:openbao` after removing the edge from the chart. +- **Rotate a secret**: open `moon run deploy:openbao`, edit the secret and save it as a new version, then press Ctrl-C; External Secrets syncs at once and Reloader rolls only the workloads whose secrets changed. To retire an edge, remove it from the chart, then delete `snapflow/edges/` in the UI (*Delete metadata and all versions*). - **Roll back an app**: on a `hotfix/` branch from `main`, set the app's `tag` and `digest` in `deploy/apps/production/snapflow.yaml` back to the previous pins (or revert the promotion), and merge it into `main`. Image automation never writes `main`, so the rollback stays until the next promotion, which should carry the fix. Database migrations are forward-only. - **Change settings**: edit `deploy/clusters/production/cluster-settings.env` or the chart values in Git; Flux applies the change. - **Upgrade platform components**: bump the pinned chart and image versions in `deploy/` and run `moon run deploy:validate`. - **Upgrade k3s**: rerun `install-k3s.sh` with a newer `k3s_version`. -- **Add an edge server**: install it with `install-k3s.sh edge` (it joins over WireGuard and is tainted for the edge only), then add its secrets file and `edges` entry as in step 6. +- **Add an edge server**: install it with `install-k3s.sh edge` (it joins over WireGuard and is tainted for the edge only), then add its OpenBao secret and `edges` entry as in step 6. - **Backups and restores**: see [Backups](#backups). ### Alerts -Flux reports every failed reconciliation (sources, stages, Helm releases and image automation, in every namespace that holds Flux objects) to Discord, and posts the result of each stage for the synced `main` commit to GitHub as a commit status. Both are in `deploy/infrastructure/configs/notifications.yaml`; a new namespace with Flux objects must be added to its `flux-errors` Alert. The Discord webhook is `alerts.env` (channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL); rotate it by replacing the webhook and running `moon run deploy:secrets`. Commit statuses are posted as the `snapflowio-automation` GitHub App, which needs the *Commit statuses: Read and write* repository permission. +Flux reports every failed reconciliation (sources, stages, Helm releases and image automation, in every namespace that holds Flux objects) to Discord, and posts the result of each stage for the synced `main` commit to GitHub as a commit status. Both are in `deploy/infrastructure/configs/notifications.yaml`; a new namespace with Flux objects must be added to its `flux-errors` Alert. The Discord webhook is `discord-webhook-url` in `snapflow/alerts` (channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL); rotate it by replacing the webhook and saving the new URL in the OpenBao UI. Commit statuses are posted as the `snapflowio-automation` GitHub App, which needs the *Commit statuses: Read and write* repository permission. ### Backups @@ -137,7 +135,7 @@ Everything that cannot be rebuilt from Git is copied to the Cloudflare R2 bucket | RustFS (volume backups, sandbox snapshots) | `rclone sync` of both buckets; objects it would delete or overwrite move to `rustfs-changes//` | daily at 04:00 | mirror, plus 30 days of changes | `rustfs/`, `rustfs-changes/` | | k3s (etcd) | k3s etcd snapshots, uploaded with the local copy | every six hours | 28 snapshots (7 days) | `k3s/` | -Credentials: on the R2 overview page, *Manage API tokens > Create Account API token* with *Object Read & Write* on `snapflow-backups` only. Cloudflare shows the S3 *Access Key ID* and *Secret Access Key* once, on creation; put them in `backups.env` as `access-key-id` and `secret-access-key`. (They can also be derived from the token: the access key ID is the token's ID and the secret access key is the SHA-256 hex digest of the token value.) Keep them, the OpenBao seal key, the k3s server token (`/var/lib/rancher/k3s/server/token`) and `deploy/secrets/production/` in your password manager: no backup can be restored without them. A failed backup Job or a failing WAL archive shows up as a Flux or CloudNativePG error; check `kubectl get backups,scheduledbackups -n snapflow` and the CronJobs after any change. +Credentials: on the R2 overview page, *Manage API tokens > Create Account API token* with *Object Read & Write* on `snapflow-backups` only. Cloudflare shows the S3 *Access Key ID* and *Secret Access Key* once, on creation; save them in `snapflow/backups` as `access-key-id` and `secret-access-key`. (They can also be derived from the token: the access key ID is the token's ID and the secret access key is the SHA-256 hex digest of the token value.) Keep them, the OpenBao seal key, the k3s server token (`/var/lib/rancher/k3s/server/token`) and the operator secrets from step 5 in your password manager: no backup can be restored without them. A failed backup Job or a failing WAL archive shows up as a Flux or CloudNativePG error; check `kubectl get backups,scheduledbackups -n snapflow` and the CronJobs after any change. Test a restore every quarter. Restore order after losing the machine: k3s, then OpenBao, then Postgres and RustFS. From 830832e15c3ebc2c617ae379990b9d61bf198dd4 Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 14:17:03 -0700 Subject: [PATCH 2/7] feat(deploy): add dynamic DNS, a host firewall and read-only database access (#15) Dynamic DNS: favonia/cloudflare-ddns keeps the public site, registry and storage records on the home connection's current IPv4 address, in the infrastructure-configs stage so it runs before the platform and apps. It reuses the cert-manager DNS token and reports failures to the alerts channel; that stage now takes the shared cluster settings. Host firewall: deploy/bootstrap/host-firewall.sh applies the server's whole UFW configuration declaratively: public edge and Traefik ports, SSH and the k3s API from the administrative network only, and the k3s pod and service networks. It lists every port Docker or Kubernetes exposes past UFW. Database access: CloudNativePG manages a snapflow_readonly role in pg_read_all_data, with a password generated in OpenBao that the operator can read in the UI. --- deploy/bootstrap/host-firewall.sh | 118 ++++++++++++++++++ deploy/clusters/production/stages.yaml | 4 + .../configs/cloudflare-ddns.yaml | 108 ++++++++++++++++ .../infrastructure/configs/kustomization.yaml | 1 + deploy/platform/data/postgres.yaml | 13 ++ deploy/platform/data/secrets.yaml | 25 ++++ .../platform/openbao-configure/configure.sh | 9 ++ deploy/scripts/validate.sh | 2 +- docs/deployment.md | 33 ++++- 9 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 deploy/bootstrap/host-firewall.sh create mode 100644 deploy/infrastructure/configs/cloudflare-ddns.yaml diff --git a/deploy/bootstrap/host-firewall.sh b/deploy/bootstrap/host-firewall.sh new file mode 100644 index 0000000..2ae2717 --- /dev/null +++ b/deploy/bootstrap/host-firewall.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Snapflow. All rights reserved. +# +# Snapflow is licensed under the GNU Affero General Public License v3.0. +# You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html +# +# SPDX-License-Identifier: AGPL-3.0 + +# Applies the host firewall of a k3s server (docs/deployment.md, "Host +# firewall") with UFW. The rules here are the whole firewall: each run resets +# UFW and applies exactly this set, so a second run changes nothing and a rule +# added by hand is removed. The router is the outer wall and forwards only the +# public ports below; this is the second wall if it ever forwards more. +# +# Public: the edge (80, 443 and 8443 TCP, 8443 UDP for QUIC machine tunnels) +# and Traefik (9443, behind Cloudflare and for the registry and storage). +# Administrative network only: SSH (rate limited) and the k3s API. +# The k3s pod and service networks are allowed, as k3s requires with UFW. +# +# Usage: sudo ADMIN_CIDR=192.168.40.0/24 deploy/bootstrap/host-firewall.sh [--dry-run] +# ADMIN_CIDR defaults to the network of the default route's interface. + +set -euo pipefail + +readonly usage="Usage: sudo [ADMIN_CIDR=] host-firewall.sh [--dry-run]" +readonly k3s_pod_cidr="10.42.0.0/16" +readonly k3s_service_cidr="10.43.0.0/16" +readonly public_tcp_ports=(80 443 8443 9443) +readonly public_udp_ports=(8443) +readonly ssh_port=22 +readonly kubernetes_api_port=6443 +# Remote administration through Tailscale, when the machine runs it. +readonly tailscale_interface="tailscale0" + +dry_run=false +case "${1:-}" in +"") ;; +--dry-run) dry_run=true ;; +*) + echo "${usage}" >&2 + exit 1 + ;; +esac + +if [[ "${EUID}" -ne 0 ]]; then + echo "host-firewall.sh must run as root" >&2 + exit 1 +fi +command -v ufw >/dev/null || { + echo "ufw is not installed (apt install ufw)" >&2 + exit 1 +} + +default_interface="$(ip -4 route show default | awk '{for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1); exit}')" +ADMIN_CIDR="${ADMIN_CIDR:-$(ip -4 -o route show dev "${default_interface}" scope link | awk '{print $1; exit}')}" +: "${ADMIN_CIDR:?set ADMIN_CIDR to the network allowed to administer this server}" + +rule() { + if [[ "${dry_run}" == true ]]; then + echo "ufw $*" + else + ufw "$@" >/dev/null + fi +} + +echo "Administrative network: ${ADMIN_CIDR}" +if [[ "${dry_run}" == false ]]; then + ufw --force reset >/dev/null +fi +rule default deny incoming +rule default allow outgoing +# k3s, Docker and Snapflow insert their own forwarding rules ahead of UFW's; +# everything else routed through the host is dropped. +rule default deny routed +rule logging low + +rule limit from "${ADMIN_CIDR}" to any port "${ssh_port}" proto tcp comment "SSH" +rule allow from "${ADMIN_CIDR}" to any port "${kubernetes_api_port}" proto tcp comment "k3s API" +if ip link show "${tailscale_interface}" >/dev/null 2>&1; then + rule limit in on "${tailscale_interface}" to any port "${ssh_port}" proto tcp comment "SSH over Tailscale" + rule allow in on "${tailscale_interface}" to any port "${kubernetes_api_port}" proto tcp comment "k3s API over Tailscale" +fi + +for port in "${public_tcp_ports[@]}"; do + rule allow "${port}/tcp" comment "public" +done +for port in "${public_udp_ports[@]}"; do + rule allow "${port}/udp" comment "public" +done + +for cidr in "${k3s_pod_cidr}" "${k3s_service_cidr}"; do + rule allow from "${cidr}" comment "k3s" + rule route allow from "${cidr}" comment "k3s" + rule route allow to "${cidr}" comment "k3s" +done + +if [[ "${dry_run}" == true ]]; then + exit 0 +fi +ufw --force enable >/dev/null +ufw status verbose + +# Docker-published ports and k3s NodePort, LoadBalancer and host ports are +# DNAT-ed before UFW sees the packet, so UFW rules do not restrict them; list +# them so each one is a deliberate choice. +echo +echo "Reachable regardless of UFW (restrict these at the router or in their own configuration):" +if command -v docker >/dev/null && docker info >/dev/null 2>&1; then + docker ps --filter "publish=1-65535" --format ' docker {{.Names}}: {{.Ports}}' || true +fi +if command -v k3s >/dev/null; then + k3s kubectl get services --all-namespaces --no-headers \ + --output custom-columns='NS:.metadata.namespace,NAME:.metadata.name,TYPE:.spec.type,PORTS:.spec.ports[*].port' 2>/dev/null | + awk '$3 == "NodePort" || $3 == "LoadBalancer" {print " service " $1 "/" $2 " (" $3 "): " $4}' || true + k3s kubectl get pods --all-namespaces --no-headers \ + --output custom-columns='NS:.metadata.namespace,NAME:.metadata.name,HOSTNET:.spec.hostNetwork,HOSTPORTS:.spec.containers[*].ports[*].hostPort' 2>/dev/null | + awk '$3 == "true" || ($4 != "" && $4 != "") {print " pod " $1 "/" $2 " (hostNetwork " $3 ", hostPorts " $4 ")"}' || true +fi diff --git a/deploy/clusters/production/stages.yaml b/deploy/clusters/production/stages.yaml index 58aa79a..72f1882 100644 --- a/deploy/clusters/production/stages.yaml +++ b/deploy/clusters/production/stages.yaml @@ -50,6 +50,10 @@ spec: path: ./deploy/infrastructure/configs prune: true wait: true + postBuild: + substituteFrom: + - kind: ConfigMap + name: cluster-settings --- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization diff --git a/deploy/infrastructure/configs/cloudflare-ddns.yaml b/deploy/infrastructure/configs/cloudflare-ddns.yaml new file mode 100644 index 0000000..2f8d11d --- /dev/null +++ b/deploy/infrastructure/configs/cloudflare-ddns.yaml @@ -0,0 +1,108 @@ +# Keeps the DNS records that point at this home connection on its current +# public IPv4 address, which the ISP may change. favonia/cloudflare-ddns checks +# the address every five minutes and on start, before the platform and apps +# stages deploy, and updates only the records named in DOMAINS: the public +# site (proxied through Cloudflare) and the registry and storage hosts (DNS +# only, because Cloudflare's proxy caps request bodies at 100 MB). It uses the +# same Zone:DNS:Edit token as cert-manager (snapflow/cloudflare-dns in OpenBao) +# and reports failed updates to the Flux alerts Discord channel. +apiVersion: v1 +kind: Namespace +metadata: + name: cloudflare-ddns +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: cloudflare-ddns + namespace: cloudflare-ddns +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: cloudflare-ddns + creationPolicy: Owner + template: + data: + api-token: "{{ .apiToken }}" + # A Discord webhook URL is https://discord.com/api/webhooks//; + # Shoutrrr's form of it is discord://@. + notification-url: >- + {{ printf "discord://%s@%s" (index (splitList "/" .webhookUrl) 6) (index (splitList "/" .webhookUrl) 5) }} + data: + - secretKey: apiToken + remoteRef: + key: snapflow/cloudflare-dns + property: api-token + - secretKey: webhookUrl + remoteRef: + key: snapflow/alerts + property: discord-webhook-url +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflare-ddns + namespace: cloudflare-ddns + annotations: + reloader.stakater.com/auto: "true" +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: cloudflare-ddns + template: + metadata: + labels: + app.kubernetes.io/name: cloudflare-ddns + spec: + automountServiceAccountToken: false + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: cloudflare-ddns + image: favonia/cloudflare-ddns:1.18.0@sha256:345b41895a8846d74c62d30bd27e61101a754f8ab8da10c68115823883803afb + env: + - name: CLOUDFLARE_API_TOKEN_FILE + value: /run/secrets/cloudflare-ddns/api-token + - name: SHOUTRRR_FILE + value: /run/secrets/cloudflare-ddns/notification-url + - name: DOMAINS + value: ${PUBLIC_DOMAIN},${REGISTRY_DOMAIN},${STORAGE_DOMAIN} + - name: PROXIED + value: is(${PUBLIC_DOMAIN}) + - name: IP6_PROVIDER + value: none + - name: UPDATE_CRON + value: "@every 5m" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + volumeMounts: + - name: secrets + mountPath: /run/secrets/cloudflare-ddns + readOnly: true + volumes: + - name: secrets + secret: + secretName: cloudflare-ddns + defaultMode: 0440 diff --git a/deploy/infrastructure/configs/kustomization.yaml b/deploy/infrastructure/configs/kustomization.yaml index 8a722dc..019cfa3 100644 --- a/deploy/infrastructure/configs/kustomization.yaml +++ b/deploy/infrastructure/configs/kustomization.yaml @@ -4,3 +4,4 @@ resources: - secret-store.yaml - cluster-issuers.yaml - notifications.yaml + - cloudflare-ddns.yaml diff --git a/deploy/platform/data/postgres.yaml b/deploy/platform/data/postgres.yaml index c93267f..692b54a 100644 --- a/deploy/platform/data/postgres.yaml +++ b/deploy/platform/data/postgres.yaml @@ -11,6 +11,19 @@ spec: initdb: database: snapflow owner: snapflow + # A login for database tools that can read every table and change nothing + # (docs/deployment.md, "Querying the database"). pg_read_all_data is + # PostgreSQL's built-in role for that; the password comes from OpenBao. + managed: + roles: + - name: snapflow_readonly + ensure: present + login: true + connectionLimit: 5 + passwordSecret: + name: snapflow-postgres-readonly + inRoles: + - pg_read_all_data # WAL is archived continuously and base backups are taken daily # (snapflow-postgres-daily), so the database can be restored to any point in # the retention window. diff --git a/deploy/platform/data/secrets.yaml b/deploy/platform/data/secrets.yaml index b182870..22e1120 100644 --- a/deploy/platform/data/secrets.yaml +++ b/deploy/platform/data/secrets.yaml @@ -43,3 +43,28 @@ spec: dataFrom: - extract: key: snapflow/platform/rustfs +--- +# The read-only Postgres login for database tools (docs/deployment.md, +# "Querying the database"); postgres.yaml declares the role. CloudNativePG +# reloads the role's password when this secret changes. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: snapflow-postgres-readonly + namespace: snapflow +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: snapflow-postgres-readonly + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + metadata: + labels: + cnpg.io/reload: "true" + dataFrom: + - extract: + key: snapflow/platform/postgres-readonly diff --git a/deploy/platform/openbao-configure/configure.sh b/deploy/platform/openbao-configure/configure.sh index 9a1ee63..34d79a5 100644 --- a/deploy/platform/openbao-configure/configure.sh +++ b/deploy/platform/openbao-configure/configure.sh @@ -128,6 +128,11 @@ POLICY printf 'path "%s/undelete/snapflow/%s" {\n capabilities = ["update"]\n}\n' "${kv_mount}" "${path}" done set +f + # The read-only database login (docs/deployment.md, "Querying the + # database") is generated below; the operator may read it, nothing more. + printf 'path "%s/metadata/snapflow/platform/" {\n capabilities = ["list"]\n}\n' "${kv_mount}" + printf 'path "%s/data/snapflow/platform/postgres-readonly" {\n capabilities = ["read"]\n}\n' "${kv_mount}" + printf 'path "%s/metadata/snapflow/platform/postgres-readonly" {\n capabilities = ["read"]\n}\n' "${kv_mount}" } | write_policy snapflow-operator # The backup CronJob (deploy/platform/backups) may take a raft snapshot and @@ -161,6 +166,10 @@ if ! secret_exists snapflow/platform/rustfs; then bao kv put -mount="${kv_mount}" -cas=0 snapflow/platform/rustfs \ access-key="$(random_secret)" secret-key="$(random_secret)" >/dev/null fi +if ! secret_exists snapflow/platform/postgres-readonly; then + bao kv put -mount="${kv_mount}" -cas=0 snapflow/platform/postgres-readonly \ + username=snapflow_readonly password="$(random_secret)" >/dev/null +fi if ! secret_exists snapflow/platform/snapshot-manager; then bao kv put -mount="${kv_mount}" -cas=0 snapflow/platform/snapshot-manager \ http-secret="$(random_secret)" >/dev/null diff --git a/deploy/scripts/validate.sh b/deploy/scripts/validate.sh index a92398c..877723d 100644 --- a/deploy/scripts/validate.sh +++ b/deploy/scripts/validate.sh @@ -17,12 +17,12 @@ readonly k3s_only_kinds="HelmChartConfig" readonly kustomizations=( clusters/production/flux-system infrastructure/controllers - infrastructure/configs platform/openbao-configure platform/jobs images ) readonly substituted_kustomizations=( + infrastructure/configs platform/data platform/backups apps/production diff --git a/docs/deployment.md b/docs/deployment.md index 0bccba4..4c1e629 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -73,12 +73,12 @@ Machine releases (the Snapflow runtime, its installer and the TUF repository at 1. **Cloudflare** - `snapflow.io`: an A record to your public IP, proxied (orange cloud). Add an Origin Rule that matches the hostname `snapflow.io` and overrides the destination port to `9443`, set SSL/TLS to *Full (strict)* and turn on *Always Use HTTPS*. - - `registry` and `storage`: A records to your public IP, DNS only (grey cloud). Use dynamic DNS if the address changes. + - `registry` and `storage`: A records to your public IP, DNS only (grey cloud). These three records are kept on the connection's current address by [dynamic DNS](#dynamic-dns), so a changed home IP needs no action. - `snapflow.run`, the sandbox zone from `PLATFORM_DOMAIN`: a separate registrable domain, so code running in a sandbox never shares a browser site with the dashboard or the api (the api refuses to start otherwise). Add an A record for each edge's public host, DNS only. Under *SSL/TLS > Custom Hostnames* set that host as the fallback origin, and turn on *Authenticated Origin Pulls*. The api creates every sandbox CNAME itself. - Create an API token with `Zone:DNS:Edit` on both zones for certificates, and give `SNAPFLOW_CLOUDFLARE_API_TOKEN` DNS and custom hostname access to the sandbox zone. - Email: add the sending domain from `EMAIL_DOMAIN` in `cluster-settings.env` (`mail.snapflow.io`) under *Domains* in [Resend](https://resend.com/domains) and copy its records into the zone, all DNS only: the DKIM `TXT` record `resend._domainkey.mail`, and the `MX` (priority 10) and SPF `TXT` records on `send.mail` that Resend uses as the bounce (Return-Path) domain. Add a DMARC `TXT` record on `_dmarc` (start with `v=DMARC1; p=none;` and tighten it once reports are clean), then press *Verify* in Resend or run `resend domains verify ` with the [Resend CLI](https://resend.com/docs/cli). Create an API key with *Sending access* restricted to that domain for `RESEND_API_KEY`. The api sends from `Snapflow ` (Helm value `email.from`); set `email.replyTo` in `deploy/apps/production/snapflow.yaml` to route replies to a monitored mailbox. -2. **Router**: forward TCP 80, 443, 8443 and UDP 8443 (edge), and TCP 9443 (site, registry and storage) to the machine. -3. **k3s**: `sudo PUBLIC_IP= deploy/bootstrap/install-k3s.sh server-edge` (the API is advertised on the address of the default route; set `NODE_IP` to pick another), then copy `/etc/rancher/k3s/k3s.yaml` to your workstation's kubeconfig and replace `127.0.0.1` with the machine's address. +2. **Router**: give the machine a fixed LAN address (a DHCP reservation), and forward TCP 80, 443, 8443 and UDP 8443 (edge), and TCP 9443 (site, registry and storage) to it. Forward nothing else; SSH and the k3s API stay on the local network. +3. **k3s**: `sudo PUBLIC_IP= deploy/bootstrap/install-k3s.sh server-edge` (the API is advertised on the address of the default route; set `NODE_IP` to pick another), then copy `/etc/rancher/k3s/k3s.yaml` to your workstation's kubeconfig and replace `127.0.0.1` with the machine's address. Then apply the [host firewall](#host-firewall). 4. **Flux**: `SNAPFLOW_AUTOMATION_PRIVATE_KEY_FILE= moon run deploy:bootstrap`. It installs the [Flux Operator](https://fluxcd.control-plane.io/operator/), stores the App key in the `flux-system` secret (the only copy outside your password manager), and applies the `FluxInstance` in `deploy/clusters/production/flux-system`, which syncs `main` as the App; no deploy key is created. From then on Flux manages the operator and itself from Git. Back up the printed OpenBao seal key offline; it decrypts everything in OpenBao. 5. **Secrets**: run `moon run deploy:openbao`, open the printed URL and sign in with method *Token* and the printed token. Under `secret/snapflow/`, create each secret below with *Create secret*, one key per field. Press Ctrl-C in the terminal when you are done, which asks External Secrets to sync the changes now. @@ -90,7 +90,7 @@ Machine releases (the Snapflow runtime, its installer and the TUF repository at | `snapflow/cloudflare-dns` | `api-token` (the `Zone:DNS:Edit` token) | | `snapflow/edges/` | `id`, `enrollment-secret` (step 6) | - Only secrets go in OpenBao; every other api setting comes from the Helm chart. The UI token can create, edit, roll back and delete only these paths and expires after an hour; OpenBao is never exposed outside the cluster. Every save is a new version, so *Version history* shows and restores earlier values. A missing or misspelled key shows up as a failing ExternalSecret (`kubectl get externalsecrets -A`) and a Flux alert. Everything else (api keyrings; Postgres, Redis, RustFS and snapshot-manager credentials; the tunnel CA) is generated in the cluster, and Cloudflare's public origin pull CA ships with the chart. Keep a copy of these values in your password manager. + Only secrets go in OpenBao; every other api setting comes from the Helm chart. The UI token can create, edit, roll back and delete only these paths, read the [read-only database login](#querying-the-database), and expires after an hour; OpenBao is never exposed outside the cluster. Every save is a new version, so *Version history* shows and restores earlier values. A missing or misspelled key shows up as a failing ExternalSecret (`kubectl get externalsecrets -A`) and a Flux alert. Everything else (api keyrings; Postgres, Redis, RustFS and snapshot-manager credentials; the tunnel CA) is generated in the cluster, and Cloudflare's public origin pull CA ships with the chart. Keep a copy of these values in your password manager. 6. **Edge**: register the edge with the admin API (`POST /api/admin/edges`, then `POST /api/admin/edges/:id/enrollment-secret`), store its `id` and `enrollment-secret` at `snapflow/edges/home` in the OpenBao UI, and add `{name: home, node: }` to `edges` in `deploy/apps/production/snapflow.yaml`. 7. **Public images**: the images are public, like the source, so neither the cluster nor Flux needs a registry token. After the first publish, open each package under the organization's *Packages* (`snapflow/api`, `snapflow/dashboard`, `snapflow/marketing`, `snapflow/snapshot-manager`, `snapflow/edge`) and set *Package settings > Change visibility* to *Public* (the organization must allow public packages under *Settings > Packages*). @@ -120,6 +120,31 @@ Every sandbox host serves code its owner controls, including the system toolbox, - **Add an edge server**: install it with `install-k3s.sh edge` (it joins over WireGuard and is tainted for the edge only), then add its OpenBao secret and `edges` entry as in step 6. - **Backups and restores**: see [Backups](#backups). +### Host firewall + +`deploy/bootstrap/host-firewall.sh` is the server's whole UFW configuration: each run resets UFW and applies the same rules, so running it again changes nothing and removes anything added by hand. The edge ports (TCP 80, 443 and 8443, UDP 8443) and Traefik (TCP 9443) are public; SSH (rate limited) and the k3s API accept only the administrative network, by default the network of the default route, plus Tailscale when the machine runs it; the k3s pod and service networks are allowed as k3s requires. Preview it with `--dry-run`, then apply it on the server: + +```sh +ssh -t 'sudo bash -s' < deploy/bootstrap/host-firewall.sh +``` + +Set `ADMIN_CIDR` (for example `sudo ADMIN_CIDR=192.168.40.0/24 bash -s`) to choose the administrative network. Docker-published ports and Kubernetes NodePort, LoadBalancer and host-network ports bypass UFW, so the script ends by listing every such port; only Traefik's 9443 and the edge should appear. The router's port forwards remain the outer boundary. + +### Dynamic DNS + +`deploy/infrastructure/configs/cloudflare-ddns.yaml` runs [cloudflare-ddns](https://github.com/favonia/cloudflare-ddns), which checks the connection's public IPv4 address every five minutes and on start, and points `PUBLIC_DOMAIN` (proxied), `REGISTRY_DOMAIN` and `STORAGE_DOMAIN` (DNS only) at it. It deploys in the `infrastructure-configs` stage, before the platform and apps, uses the `snapflow/cloudflare-dns` token, and reports failed updates to the alerts Discord channel. Check it with `kubectl -n cloudflare-ddns logs deploy/cloudflare-ddns`. + +### Querying the database + +Postgres has no public port. Database tools connect as `snapflow_readonly`, a login that can read every table and change nothing (PostgreSQL's `pg_read_all_data`); its password is generated in OpenBao at `snapflow/platform/postgres-readonly`, which the OpenBao UI shows. The `snapflow` owner login is for the api only. + +In DataGrip (or any client with an SSH tunnel): + +- **SSH/SSL**: tunnel through the server with your SSH key. +- **Host**: the address of the primary's service, `kubectl -n snapflow get service snapflow-postgres-rw -o jsonpath='{.spec.clusterIP}'` (stable while the cluster exists); **Port** `5432`; **Database** `snapflow`; **User** `snapflow_readonly`. + +Without a tunnel, `kubectl -n snapflow port-forward service/snapflow-postgres-rw 5432:5432` serves it on `localhost:5432` while it runs. + ### Alerts Flux reports every failed reconciliation (sources, stages, Helm releases and image automation, in every namespace that holds Flux objects) to Discord, and posts the result of each stage for the synced `main` commit to GitHub as a commit status. Both are in `deploy/infrastructure/configs/notifications.yaml`; a new namespace with Flux objects must be added to its `flux-errors` Alert. The Discord webhook is `discord-webhook-url` in `snapflow/alerts` (channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL); rotate it by replacing the webhook and saving the new URL in the OpenBao UI. Commit statuses are posted as the `snapflowio-automation` GitHub App, which needs the *Commit statuses: Read and write* repository permission. From 125a079cb7b121d9c7ae224053eb57beee7b95b2 Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 14:47:49 -0700 Subject: [PATCH 3/7] chore: remove the docs folder (#16) The api's env-docs command becomes env-file and only lays out the local apps/api/.env; its CI check is gone. Runbook content now lives in the files it describes: cluster-settings.env, bootstrap-flux.sh, install-k3s.sh, the backup manifests, notifications.yaml, postgres.yaml, the deploy:openbao output, the TUF trust root README and the README. --- .github/workflows/ci.yml | 2 - .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 4 +- README.md | 31 +++- apps/api/moon.yml | 18 +- apps/api/src/cli/cli.module.ts | 4 +- apps/api/src/cli/commands/env-docs.command.ts | 114 ------------ apps/api/src/cli/commands/env-file.command.ts | 45 +++++ .../utils/environment-docs.util.ts | 137 -------------- .../utils/environment-file.util.ts | 20 +- apps/snapflow/internal/tuf/trust/README.md | 65 ++++++- deploy/apps/production/kustomization.yaml | 6 +- deploy/bootstrap/host-firewall.sh | 9 +- deploy/bootstrap/install-k3s.sh | 11 ++ .../clusters/production/cluster-settings.env | 27 ++- .../infrastructure/configs/notifications.yaml | 7 +- deploy/platform/backups/k3s.yaml | 20 +- deploy/platform/backups/openbao.yaml | 10 +- deploy/platform/backups/rustfs.yaml | 4 + deploy/platform/data/postgres-backups.yaml | 15 +- deploy/platform/data/postgres.yaml | 10 +- deploy/platform/data/secrets.yaml | 5 +- .../platform/openbao-configure/configure.sh | 6 +- deploy/scripts/bootstrap-flux.sh | 22 +++ deploy/scripts/openbao-ui.sh | 17 +- docs/deployment.md | 174 ------------------ docs/hosting.md | 67 ------- docs/reference/api-environment.md | 165 ----------------- docs/releases.md | 147 --------------- scripts/download-geoip-database.sh | 4 +- 30 files changed, 315 insertions(+), 853 deletions(-) delete mode 100644 apps/api/src/cli/commands/env-docs.command.ts create mode 100644 apps/api/src/cli/commands/env-file.command.ts delete mode 100644 apps/api/src/config/environment/utils/environment-docs.util.ts delete mode 100644 docs/deployment.md delete mode 100644 docs/hosting.md delete mode 100644 docs/reference/api-environment.md delete mode 100644 docs/releases.md mode change 100755 => 100644 scripts/download-geoip-database.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5376dd0..1dbbacf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,8 +107,6 @@ jobs: --concurrency=4 - name: Build the API run: pnpm exec moon run api:build-production - - name: Verify the generated environment documentation is current - run: pnpm exec moon run api:env-docs-check - name: Build the dashboard run: pnpm exec moon run dashboard:build - name: Build the marketing website diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97137ae..fcaf56e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: exit 1 fi if ! jq --exit-status '.signed._type == "root"' apps/snapflow/internal/tuf/trust/root.json >/dev/null; then - echo "::error::apps/snapflow/internal/tuf/trust/root.json must hold the production TUF root (see docs/releases.md)" + echo "::error::apps/snapflow/internal/tuf/trust/root.json must hold the production TUF root (see apps/snapflow/internal/tuf/trust/README.md)" exit 1 fi epoch="$(git show -s --format=%ct HEAD)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52ee7b2..f75a529 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,8 +32,8 @@ Done once per repository: 1. *Settings > General*: default branch `main`. It stays `main` because scheduled workflows run only on the default branch: `tuf-refresh.yml` signs with the `beta` environment, which only `main` may use, and the repository page shows what is deployed. Under *Pull Requests* allow merge commits and squash merging (both titled from the pull request), disallow rebase merging, enable *Always suggest updating pull request branches*, *Allow auto-merge* (Renovate merges its own pull requests with it) and *Automatically delete head branches*. 2. *Settings > Rules > Rulesets > New ruleset > Import a ruleset*: import [`.github/rulesets/main.json`](.github/rulesets/main.json), [`.github/rulesets/dev.json`](.github/rulesets/dev.json) and [`.github/rulesets/tags.json`](.github/rulesets/tags.json). The branch rulesets require a pull request and the `CI passed` check and block force pushes and deletion; `main` accepts only merge commits, `dev` squash and merge commits. The tag ruleset lets only the automation App create, move or delete `v*` release tags. The App is also the only actor that may push to `dev` directly, for Flux image automation's digest pins; nothing bypasses `main`. 3. *Settings > Actions > General*: require actions to be pinned to a full-length commit SHA (every workflow already pins them), require approval of workflow runs for all external contributors (the KVM runners are self-hosted), and set *Workflow permissions* to read repository contents (jobs request more themselves). -4. *Automation App*: the `snapflowio-automation` GitHub App (Contents, Pull requests, Issues and Workflows: read and write; no webhook) opens the release-please and main-to-dev pull requests, creates release tags and pushes Flux image pins to `dev`. Pull requests it opens start CI like any other, which `GITHUB_TOKEN` pull requests do not. Install it on this repository only. Store its Client ID in the repository variable `SNAPFLOW_AUTOMATION_CLIENT_ID`, and its private key in the `automation` environment, which only `main` may deploy to, so no workflow run from another branch can use it: `gh secret set SNAPFLOW_AUTOMATION_PRIVATE_KEY --env automation < key.pem`. Flux keeps its own copy in the cluster ([docs/deployment.md](docs/deployment.md)). +4. *Automation App*: the `snapflowio-automation` GitHub App (Contents, Pull requests, Issues and Workflows: read and write; no webhook) opens the release-please and main-to-dev pull requests, creates release tags and pushes Flux image pins to `dev`. Pull requests it opens start CI like any other, which `GITHUB_TOKEN` pull requests do not. Install it on this repository only. Store its Client ID in the repository variable `SNAPFLOW_AUTOMATION_CLIENT_ID`, and its private key in the `automation` environment, which only `main` may deploy to, so no workflow run from another branch can use it: `gh secret set SNAPFLOW_AUTOMATION_PRIVATE_KEY --env automation < key.pem`. Flux keeps its own copy in the cluster ([`deploy/scripts/bootstrap-flux.sh`](deploy/scripts/bootstrap-flux.sh)). 5. *Settings > Advanced Security*: enable Dependabot alerts (Renovate reads them for its security pull requests), secret scanning and push protection. Leave Dependabot security updates and version updates off; Renovate opens those pull requests. 6. Install the [Renovate GitHub App](https://github.com/apps/renovate) on this repository only. 7. The [Blacksmith](https://www.blacksmith.sh) GitHub App must have access to this repository: the CI suites, CodeQL, the dependency audits and the dev container build run on its `blacksmith-4vcpu-ubuntu-2404` runners, and their jobs wait in the queue without it. Releases, promotion, TUF signing and image publishing stay on GitHub-hosted runners, so their signatures and build attestations come from GitHub's own builders. -8. The release environments, secrets and KVM runners are in [docs/releases.md](docs/releases.md#one-time-setup). +8. The release environments, secrets, TUF ceremony and KVM runners are in [apps/snapflow/internal/tuf/trust/README.md](apps/snapflow/internal/tuf/trust/README.md#one-time-setup). diff --git a/README.md b/README.md index acaec25..9af0356 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,36 @@ Run this on a Linux machine (amd64 or arm64) with KVM and systemd: curl -fsSL https://snapflow.io/install.sh | sh ``` -See [docs/hosting.md](docs/hosting.md) for the full requirements and [docs/releases.md](docs/releases.md) for release channels and how to verify downloads. +The installer verifies the CLI against `SHA256SUMS` (and its cosign signature when `cosign` is installed), installs `/usr/local/bin/snapflow` and runs `sudo snapflow setup`, which installs the runtime from the signed TUF repository at `releases.snapflow.io`. Options: `--version X.Y.Z`, `--channel stable|beta` (default `stable`), `--dry-run`, `--no-setup`. Switch channels later with `sudo snapflow update --channel beta|stable`; remove Snapflow with `sudo snapflow uninstall`. + +### Requirements + +| Requirement | Detail | +| --- | --- | +| Operating system | Linux on x86_64 or arm64 with KVM (`/dev/kvm`) | +| Kernel | 6.2 or newer with Landlock enabled | +| systemd | With D-Bus; sandboxes keep running when the daemon restarts | +| cgroup v2, nftables | The unified hierarchy; nftables for sandbox networking | +| IPv4 forwarding | Enabled | +| KSM | Off: same-page merging lets tenants observe each other's memory | +| Storage | `/var/lib/snapflow` on XFS with `reflink=1` or Btrfs for instant clones; other filesystems copy in full | +| GPUs (optional) | IOMMU on in firmware and the kernel command line, the NVIDIA driver, `nvidia-drm` with `modeset=0`, `nvidia-persistenced` stopped. Each GPU goes to one sandbox at a time with its IOMMU group, and is reset, health-checked and scrubbed after use. | + +Snapflow sets up sandbox isolation itself (a network namespace per sandbox without access to the machine or its network, jailed hypervisors, CPU, memory and I/O limits on whole cores, per-job registry tokens) and works alongside Docker, UFW and firewalld. Check a machine before listing it with `sudo snapflow doctor`, and prove its isolation with `sudo snapflow doctor --isolation` while Snapflow is stopped. Hosts must not inspect or tamper with customer workloads, and must keep these settings in place. + +### Verifying a download + +```sh +v=X.Y.Z +base=https://github.com/snapflowio/snapflow/releases/download/v$v +curl -fsSLO "$base/SHA256SUMS" -O "$base/SHA256SUMS.sigstore.json" -O "$base/snapflow-$v-linux-amd64.tar.gz" +sha256sum --check --ignore-missing SHA256SUMS +cosign verify-blob --bundle SHA256SUMS.sigstore.json \ + --certificate-identity-regexp '^https://github\.com/snapflowio/snapflow/\.github/workflows/release\.yml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS +gh attestation verify "snapflow-$v-linux-amd64.tar.gz" --repo snapflowio/snapflow \ + --signer-workflow snapflowio/snapflow/.github/workflows/release.yml +``` ### Top contributors: diff --git a/apps/api/moon.yml b/apps/api/moon.yml index e672374..dd9d371 100644 --- a/apps/api/moon.yml +++ b/apps/api/moon.yml @@ -68,28 +68,18 @@ tasks: options: runFromWorkspaceRoot: true - env-docs: + env-file: + description: "Lays out apps/api/.env with every variable development may set, in schema order" command: "node" args: - "dist/apps/api/cli.mjs" - - "env-docs" - deps: - - "build-production" - options: - runFromWorkspaceRoot: true - cache: false - - env-docs-check: - command: "node" - args: - - "dist/apps/api/cli.mjs" - - "env-docs" - - "--check" + - "env-file" deps: - "build-production" options: runFromWorkspaceRoot: true cache: false + runInCI: false email-preview: description: "Serves the React Email preview of every transactional email template on port 3002" diff --git a/apps/api/src/cli/cli.module.ts b/apps/api/src/cli/cli.module.ts index de1fe78..e5ebb12 100644 --- a/apps/api/src/cli/cli.module.ts +++ b/apps/api/src/cli/cli.module.ts @@ -9,12 +9,12 @@ import { Module } from '@nestjs/common'; import { CreateAdminCommand } from './commands/create-admin.command'; -import { EnvironmentDocsCommand } from './commands/env-docs.command'; +import { EnvironmentFileCommand } from './commands/env-file.command'; import { ResetLocalDatabaseCommand } from './commands/reset-database.command'; @Module({ providers: [ - EnvironmentDocsCommand, + EnvironmentFileCommand, ResetLocalDatabaseCommand, CreateAdminCommand, ], diff --git a/apps/api/src/cli/commands/env-docs.command.ts b/apps/api/src/cli/commands/env-docs.command.ts deleted file mode 100644 index d6e2ae0..0000000 --- a/apps/api/src/cli/commands/env-docs.command.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2026 Snapflow. All rights reserved. - * - * Snapflow is licensed under the GNU Affero General Public License v3.0. - * You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html - * - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { Command, CommandRunner, Option } from 'nest-commander'; -import { renderEnvironmentReference } from '../../config/environment/utils/environment-docs.util'; -import { layoutEnvironmentFile } from '../../config/environment/utils/environment-file.util'; -import { writeLine } from '../cli-output.util'; - -const ENVIRONMENT_DOCUMENTS = { - REFERENCE: 'docs/reference/api-environment.md', - LOCAL_FILE: 'apps/api/.env', -} as const; -const PUBLIC_FILE_MODE = 0o644; -const PRIVATE_FILE_MODE = 0o600; - -type EnvironmentDocsOptions = Readonly<{ check?: boolean }>; - -interface GeneratedDocument { - path: string; - current: string | undefined; - expected: string; - mode: number; - added: readonly string[]; -} - -async function readIfPresent(path: string): Promise { - return existsSync(path) ? readFile(path, 'utf8') : undefined; -} - -async function referenceDocument(): Promise { - const path = ENVIRONMENT_DOCUMENTS.REFERENCE; - return { - path, - current: await readIfPresent(path), - expected: renderEnvironmentReference(), - mode: PUBLIC_FILE_MODE, - added: [], - }; -} - -async function localDocument(): Promise { - const path = ENVIRONMENT_DOCUMENTS.LOCAL_FILE; - const current = await readIfPresent(path); - if (current === undefined) return undefined; - const { content, added, unregistered } = layoutEnvironmentFile(current); - if (unregistered.length > 0) { - throw new Error( - `${path} sets unregistered variables: ${unregistered.join(', ')}` - ); - } - return { path, current, expected: content, mode: PRIVATE_FILE_MODE, added }; -} - -async function generatedDocuments(): Promise { - const local = await localDocument(); - return [await referenceDocument(), ...(local ? [local] : [])]; -} - -function verify(documents: readonly GeneratedDocument[]): void { - const stale = documents - .filter((document) => document.current !== document.expected) - .map((document) => document.path); - if (stale.length > 0) { - throw new Error( - `Out of date: ${stale.join(', ')}; run moon run api:env-docs` - ); - } - writeLine( - `Up to date: ${documents.map((document) => document.path).join(', ')}` - ); -} - -async function write(documents: readonly GeneratedDocument[]): Promise { - for (const document of documents) { - if (document.current === document.expected) continue; - await mkdir(dirname(document.path), { recursive: true }); - await writeFile(document.path, document.expected, { mode: document.mode }); - writeLine( - document.added.length > 0 - ? `Wrote ${document.path}; listed ${document.added.join(', ')}` - : `Wrote ${document.path}` - ); - } -} - -@Command({ - name: 'env-docs', - description: 'Generate the environment reference and lay out apps/api/.env', -}) -export class EnvironmentDocsCommand extends CommandRunner { - @Option({ - flags: '--check', - description: 'Fail when a document differs from its generated form', - }) - parseCheck(): boolean { - return true; - } - - public async run(): Promise { - const { check } = this.command.opts(); - const documents = await generatedDocuments(); - if (check) verify(documents); - else await write(documents); - } -} diff --git a/apps/api/src/cli/commands/env-file.command.ts b/apps/api/src/cli/commands/env-file.command.ts new file mode 100644 index 0000000..4f4effd --- /dev/null +++ b/apps/api/src/cli/commands/env-file.command.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Snapflow. All rights reserved. + * + * Snapflow is licensed under the GNU Affero General Public License v3.0. + * You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html + * + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { Command, CommandRunner } from 'nest-commander'; +import { layoutEnvironmentFile } from '../../config/environment/utils/environment-file.util'; +import { writeLine } from '../cli-output.util'; + +const LOCAL_ENVIRONMENT_FILE = 'apps/api/.env'; +const PRIVATE_FILE_MODE = 0o600; + +@Command({ + name: 'env-file', + description: + 'Lay out apps/api/.env with every variable development may set, in schema order', +}) +export class EnvironmentFileCommand extends CommandRunner { + public async run(): Promise { + const path = LOCAL_ENVIRONMENT_FILE; + const current = existsSync(path) ? await readFile(path, 'utf8') : ''; + const { content, added, unregistered } = layoutEnvironmentFile(current); + if (unregistered.length > 0) { + throw new Error( + `${path} sets unregistered variables: ${unregistered.join(', ')}` + ); + } + if (content === current) { + writeLine(`Up to date: ${path}`); + return; + } + await writeFile(path, content, { mode: PRIVATE_FILE_MODE }); + writeLine( + added.length > 0 + ? `Wrote ${path}; listed ${added.join(', ')}` + : `Wrote ${path}` + ); + } +} diff --git a/apps/api/src/config/environment/utils/environment-docs.util.ts b/apps/api/src/config/environment/utils/environment-docs.util.ts deleted file mode 100644 index 4be45ed..0000000 --- a/apps/api/src/config/environment/utils/environment-docs.util.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2026 Snapflow. All rights reserved. - * - * Snapflow is licensed under the GNU Affero General Public License v3.0. - * You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html - * - * SPDX-License-Identifier: AGPL-3.0 - */ - -import { z } from 'zod'; -import { DeploymentEnvironment } from '../../enums/deployment-environment.enum'; -import { ENVIRONMENT_NAMESPACES } from '../registry.environment'; -import { ENVIRONMENT_METADATA } from './environment-variable.util'; - -interface DocumentedVariable { - name: string; - type: string; - description: string; - defaultValue: string | undefined; - required: boolean; - secret: boolean; - requiredIn: readonly string[]; - allowedIn: readonly string[]; -} - -type JsonSchemaProperty = Readonly<{ - type?: string; - enum?: readonly unknown[]; - default?: unknown; - description?: string; - secret?: boolean; - requiredIn?: readonly string[]; - allowedIn?: readonly string[]; -}>; - -const GENERATED_NOTICE = - 'Generated by `moon run api:env-docs` from apps/api/src/config/environment; do not edit.'; -const SELECTOR = 'SNAPFLOW_ENVIRONMENT'; -const SECRET_PLACEHOLDER = '(secret)'; - -function documentedVariables( - namespace: (typeof ENVIRONMENT_NAMESPACES)[number] -): DocumentedVariable[] { - const schema = z.toJSONSchema(namespace.schema, { - io: 'input', - unrepresentable: 'any', - metadata: ENVIRONMENT_METADATA, - }); - const properties = (schema.properties ?? {}) as Readonly< - Record - >; - const required = new Set(schema.required ?? []); - return Object.entries(properties).map(([name, property]) => ({ - name, - type: property.enum - ? property.enum.join(' \\| ') - : (property.type ?? 'string'), - description: property.description ?? '', - defaultValue: - property.default === undefined ? undefined : String(property.default), - required: required.has(name), - secret: property.secret === true, - requiredIn: property.requiredIn ?? [], - allowedIn: property.allowedIn ?? [], - })); -} - -function shown( - variable: DocumentedVariable, - value: string | undefined -): string { - if (value === undefined) return ''; - return variable.secret ? SECRET_PLACEHOLDER : value; -} - -export function environmentFileSections(): { - title: string; - names: string[]; -}[] { - return ENVIRONMENT_NAMESPACES.map((namespace) => { - const shape: Readonly> = namespace.schema.shape; - return { - title: namespace.title, - names: Object.keys(shape).filter((name) => { - const allowedIn = ENVIRONMENT_METADATA.get(shape[name])?.allowedIn; - return ( - !allowedIn || allowedIn.includes(DeploymentEnvironment.DEVELOPMENT) - ); - }), - }; - }); -} - -function tableCell(value: string): string { - return value.replaceAll('|', '\\|').replaceAll('\n', ' '); -} - -export function renderEnvironmentReference(): string { - const sections = ENVIRONMENT_NAMESPACES.map((namespace) => { - const rows = documentedVariables(namespace).map((variable) => - [ - `\`${variable.name}\``, - variable.type, - shown(variable, variable.defaultValue), - [ - variable.required ? 'all' : variable.requiredIn.join(', '), - variable.allowedIn.length > 0 - ? `only ${variable.allowedIn.join(', ')}` - : '', - ] - .filter(Boolean) - .join('; '), - variable.secret ? 'yes' : '', - tableCell(variable.description), - ].join(' | ') - ); - return [ - `## ${namespace.title}`, - '', - namespace.description, - ...(namespace.note ? ['', namespace.note] : []), - '', - '| Variable | Type | Default | Required | Secret | Description |', - '| --- | --- | --- | --- | --- | --- |', - ...rows.map((row) => `| ${row} |`), - ].join('\n'); - }); - return [ - '# API environment', - '', - ``, - '', - `\`${SELECTOR}\` selects the deployment profile: whether the deployment is hosted (HTTPS origins, loopback Vault Proxy, verified database TLS, no localhost domain, no test keys) and whether logs and audit entries go to a developer console. Every application value comes from the environment, and code holds no deployment values: a local apps/api/.env (gitignored; it lists every variable development may set, in the sections below, and an empty value counts as unset) sets them in development; in production Kubernetes sets them from OpenBao through the External Secrets Operator. Secrets have no default. Runtime settings live in the settings system, and policy in versioned \`*_POLICY\` constants.`, - '', - ...sections.flatMap((section) => [section, '']), - ].join('\n'); -} diff --git a/apps/api/src/config/environment/utils/environment-file.util.ts b/apps/api/src/config/environment/utils/environment-file.util.ts index 6fb7718..275db60 100644 --- a/apps/api/src/config/environment/utils/environment-file.util.ts +++ b/apps/api/src/config/environment/utils/environment-file.util.ts @@ -7,7 +7,10 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { environmentFileSections } from './environment-docs.util'; +import type { z } from 'zod'; +import { DeploymentEnvironment } from '../../enums/deployment-environment.enum'; +import { ENVIRONMENT_NAMESPACES } from '../registry.environment'; +import { ENVIRONMENT_METADATA } from './environment-variable.util'; const SECTION_PREFIX = '# '; const LINE_SEPARATOR = '\n'; @@ -21,6 +24,21 @@ interface EnvironmentFileLayout { unregistered: string[]; } +function environmentFileSections(): { title: string; names: string[] }[] { + return ENVIRONMENT_NAMESPACES.map((namespace) => { + const shape: Readonly> = namespace.schema.shape; + return { + title: namespace.title, + names: Object.keys(shape).filter((name) => { + const allowedIn = ENVIRONMENT_METADATA.get(shape[name])?.allowedIn; + return ( + !allowedIn || allowedIn.includes(DeploymentEnvironment.DEVELOPMENT) + ); + }), + }; + }); +} + function variableLines(content: string): Map { const lines = new Map(); for (const line of content.split(LINE_SEPARATOR)) { diff --git a/apps/snapflow/internal/tuf/trust/README.md b/apps/snapflow/internal/tuf/trust/README.md index e7c9834..654b467 100644 --- a/apps/snapflow/internal/tuf/trust/README.md +++ b/apps/snapflow/internal/tuf/trust/README.md @@ -1,5 +1,66 @@ # Release trust root Release builds embed `root.json` from this directory as the TUF bootstrap root. -It is the public `metadata/1.root.json` produced by `snapflow-release tuf init --policy production`; -see docs/releases.md. +It is the public `metadata/1.root.json` produced by `snapflow-release tuf init --policy production`. +Machines receive releases through the TUF repository at `https://releases.snapflow.io` +(bucket holds only `metadata/` and `targets/`; bundles stay on GitHub Releases). + +## Channels + +- **beta**: every release, automatically after its KVM canaries pass (`release.yml`). GitHub shows these as prereleases. +- **stable**: a beta release promoted with **Actions > Promote to stable**, which waits for a `stable` environment reviewer, re-points the channel without rebuilding and marks the GitHub release as latest. + +Machines switch with `sudo snapflow update --channel beta|stable`. + +## One-time setup + +1. *Settings > General > Releases*: enable **Immutable Releases**. +2. Environments, both limited to `main`: + + | Environment | Reviewers | Secrets | + | --- | --- | --- | + | `beta` | none (unattended; `tuf-refresh.yml` also signs with it) | `SNAPFLOW_RELEASE_R2_ACCOUNT_ID`, `SNAPFLOW_RELEASE_R2_ACCESS_KEY_ID`, `SNAPFLOW_RELEASE_R2_SECRET_ACCESS_KEY`, `SNAPFLOW_RELEASE_R2_BUCKET`, `SNAPFLOW_TUF_SNAPSHOT_KEY`, `SNAPFLOW_TUF_TIMESTAMP_KEY`, `SNAPFLOW_TUF_BETA_KEY` | + | `stable` | required; turn on *Prevent self-review* once a second maintainer can approve | the four `SNAPFLOW_RELEASE_R2_*` secrets, `SNAPFLOW_TUF_SNAPSHOT_KEY`, `SNAPFLOW_TUF_TIMESTAMP_KEY`, `SNAPFLOW_TUF_STABLE_KEY` | + + Key secrets hold PEM file contents; jobs write them to mode `0600` files under `$RUNNER_TEMP` and delete them after the step. +3. Cloudflare R2: a bucket with the custom domain `releases.snapflow.io`, and an R2 API token with *Object Read & Write* on that bucket only. +4. TUF root ceremony, on an offline machine with a checkout of the tag you trust: + + ```sh + go run ./apps/snapflow/cmd/snapflow-release tuf init --repository tuf --private-keys keys --policy production + ``` + + This creates three root keys (threshold 2), one targets key and one key each for snapshot, timestamp, beta and stable. Root and targets keys never leave offline storage: each root key on separate encrypted media with a different holder or location, the targets key with them. Put the snapshot, timestamp, beta and stable keys into the environment secrets, then delete those local copies (a lost online key is replaced with `tuf resign`). Commit `tuf/metadata/1.root.json` here as `root.json`; release builds fail until it is present. Upload the repository as below. +5. KVM runners: one self-hosted runner per architecture on bare metal or a VM with nested virtualization, labelled `self-hosted`, `linux`, `X64` or `ARM64`, and `snapflow-kvm`, with `/dev/kvm` and passwordless `sudo`, accepting only this repository's jobs. Set the repository variable `SNAPFLOW_KVM_RUNNER_ARCHITECTURES` to the registered architectures, for example `["X64"]`; `isolation.yml` is skipped until then. + +## Routine operations + +- `tuf-refresh.yml` runs every six hours: it re-signs timestamp (valid three days), snapshot near its 14-day expiry and beta halfway through its year, and fails if root, targets, beta or stable expire within 30 days. +- GitHub disables scheduled workflows in a public repository after 60 days without activity, silently. Monitor `https://releases.snapflow.io/metadata/timestamp.json` from outside GitHub and alert when its `expires` is under two days away; re-enable **TUF refresh** and run it once if it stopped. +- Run **Promote to stable** with the current stable version at least once a year to re-sign stable. +- Root and targets, yearly, offline: `tuf resign --repository tuf --role root --key ROOT_KEY_1 --key ROOT_KEY_2` and `tuf resign --repository tuf --role targets --key TARGETS_KEY --snapshot-key SNAPSHOT_KEY --timestamp-key TIMESTAMP_KEY`. +- Root rotation: `tuf generate-root-keys --output new-keys`, then `tuf rotate-root --repository tuf --current-root-key OLD_1 --current-root-key OLD_2 --new-root-key NEW_1 --new-root-key NEW_2 --new-root-key NEW_3`, and commit the new root here. +- Online key compromise (snapshot, timestamp, beta or stable): `tuf generate-key --output NEW.pem`, then offline `tuf resign --repository tuf --role root --key ROOT_KEY_1 --key ROOT_KEY_2 --replace snapshot=NEW.pem` (or `timestamp=`), or for beta and stable `tuf resign --repository tuf --role targets --key TARGETS_KEY --replace beta=NEW.pem --snapshot-key SNAPSHOT_KEY --timestamp-key TIMESTAMP_KEY`. Upload, update the GitHub secret and run **TUF refresh**. A compromised targets key is replaced through `--role root ... --replace targets=NEW.pem`; a compromised root key is dropped with `tuf rotate-root`. + +## Uploading by hand + +Disable **TUF refresh** during a ceremony and start from the live repository: + +```sh +export RCLONE_CONFIG_R2_TYPE=s3 RCLONE_CONFIG_R2_PROVIDER=Cloudflare RCLONE_CONFIG_R2_NO_CHECK_BUCKET=true +export RCLONE_CONFIG_R2_ENDPOINT=https://.r2.cloudflarestorage.com +export RCLONE_CONFIG_R2_ACCESS_KEY_ID= RCLONE_CONFIG_R2_SECRET_ACCESS_KEY= +rclone copy --checksum --include "/metadata/**" --include "/targets/**" r2: tuf +``` + +Afterwards upload immutable files first and `timestamp.json` last, then re-enable **TUF refresh**: + +```sh +rclone copy --checksum --immutable --header-upload "Cache-Control: public, max-age=31536000, immutable" tuf/targets r2:/targets +rclone copy --checksum --immutable --header-upload "Cache-Control: public, max-age=31536000, immutable" --exclude /timestamp.json tuf/metadata r2:/metadata +rclone copyto --checksum --header-upload "Cache-Control: no-store" tuf/metadata/timestamp.json r2:/metadata/timestamp.json +``` + +## Compatibility contract + +Installed machines read the bundle manifest and the channel documents (`targets/channels//linux-.json`) with the CLI they already run. A schema change must first ship in a release whose CLI understands both forms; only a later release may produce the new form. The same holds for the `install.sh` asset names and the `SHA256SUMS` format. diff --git a/deploy/apps/production/kustomization.yaml b/deploy/apps/production/kustomization.yaml index 0b9b2a0..9e802db 100644 --- a/deploy/apps/production/kustomization.yaml +++ b/deploy/apps/production/kustomization.yaml @@ -7,8 +7,10 @@ components: patches: # The migration hook runs before the upgrade, so rolling the release back # would put the previous code on a schema the migration already changed. - # A failed upgrade is retried instead; migrations stay backward compatible - # with the running release (docs/deployment.md, "Database migrations"). + # A failed upgrade is retried instead. Every migration must therefore keep + # working with the release before it (expand and contract): add columns and + # tables in one release, use them in the next, and drop what old code needed + # only once no running release uses it. - target: kind: HelmRelease name: snapflow diff --git a/deploy/bootstrap/host-firewall.sh b/deploy/bootstrap/host-firewall.sh index 2ae2717..4765e46 100644 --- a/deploy/bootstrap/host-firewall.sh +++ b/deploy/bootstrap/host-firewall.sh @@ -6,8 +6,8 @@ # # SPDX-License-Identifier: AGPL-3.0 -# Applies the host firewall of a k3s server (docs/deployment.md, "Host -# firewall") with UFW. The rules here are the whole firewall: each run resets +# Applies the host firewall of a k3s server with UFW, after install-k3s.sh. +# The rules here are the whole firewall: each run resets # UFW and applies exactly this set, so a second run changes nothing and a rule # added by hand is removed. The router is the outer wall and forwards only the # public ports below; this is the second wall if it ever forwards more. @@ -18,7 +18,10 @@ # The k3s pod and service networks are allowed, as k3s requires with UFW. # # Usage: sudo ADMIN_CIDR=192.168.40.0/24 deploy/bootstrap/host-firewall.sh [--dry-run] -# ADMIN_CIDR defaults to the network of the default route's interface. +# ADMIN_CIDR defaults to the network of the default route's interface. From a +# workstation: ssh -t 'sudo bash -s' < deploy/bootstrap/host-firewall.sh +# Only Traefik's 9443 and the edge should appear in the closing list of ports +# that bypass UFW. set -euo pipefail diff --git a/deploy/bootstrap/install-k3s.sh b/deploy/bootstrap/install-k3s.sh index 00ee4ad..1f63def 100644 --- a/deploy/bootstrap/install-k3s.sh +++ b/deploy/bootstrap/install-k3s.sh @@ -6,6 +6,17 @@ # # SPDX-License-Identifier: AGPL-3.0 +# Installs k3s on a node, as root on that node: +# server-edge the first server, which also runs the edge: +# sudo PUBLIC_IP= install-k3s.sh server-edge +# server a server without the edge +# edge an edge-only node that joins over WireGuard and is tainted for +# the edge (K3S_URL, K3S_TOKEN and NODE_NAME required); then add +# it to edges in deploy/apps/production/snapflow.yaml +# The API is advertised on the address of the default route (NODE_IP overrides). +# Upgrade by rerunning it with a newer k3s_version. Apply +# deploy/bootstrap/host-firewall.sh afterwards. + set -euo pipefail readonly k3s_version="v1.36.5+k3s1" diff --git a/deploy/clusters/production/cluster-settings.env b/deploy/clusters/production/cluster-settings.env index b2b7970..ce66176 100644 --- a/deploy/clusters/production/cluster-settings.env +++ b/deploy/clusters/production/cluster-settings.env @@ -1,9 +1,32 @@ +# Values several stages share; Flux substitutes them as ${NAME} into the +# infrastructure-configs, platform, platform-backups and apps stages. + +# The site, dashboard (/dashboard) and api (/api). Cloudflare: an A record to +# the public IP, proxied, with an Origin Rule that matches this hostname and +# overrides the destination port to 9443; SSL/TLS Full (strict) and Always +# Use HTTPS. Dynamic DNS (infrastructure/configs/cloudflare-ddns.yaml) keeps +# the address current. PUBLIC_DOMAIN=snapflow.io +# Object storage and the image registry: A records to the public IP, DNS only +# (Cloudflare's proxy caps request bodies at 100 MB and layers are larger), +# reached on 9443 and kept current by dynamic DNS. STORAGE_DOMAIN=storage.snapflow.io REGISTRY_DOMAIN=registry.snapflow.io +# The sandbox zone. It must be its own registrable domain so sandbox pages are +# cross-site to the dashboard (the api refuses to start otherwise); file it +# with the Public Suffix List (private section) so each sandbox host is its +# own site. Cloudflare: an A record per edge's public host, DNS only; that host +# as the Custom Hostnames fallback origin; Authenticated Origin Pulls on. The +# api creates every sandbox CNAME with SNAPFLOW_CLOUDFLARE_API_TOKEN, which +# needs DNS and custom hostname access to this zone. PLATFORM_DOMAIN=snapflow.run +# The api sends from noreply@ this domain through Resend. Add it under Domains +# in Resend and copy its records into the zone, DNS only: the DKIM TXT record +# resend._domainkey.mail, the MX (priority 10) and SPF TXT records on +# send.mail, and a DMARC TXT record on _dmarc (start at p=none). Create a +# Resend API key with sending access to this domain only for RESEND_API_KEY. EMAIL_DOMAIN=mail.snapflow.io -# Off-cluster backups (docs/deployment.md, "Backups"): the R2 S3 endpoint of -# the Cloudflare account that owns the bucket. +# Off-cluster backups (platform/backups and platform/data/postgres-backups.yaml): +# the R2 S3 endpoint of the Cloudflare account that owns the bucket. BACKUP_S3_ENDPOINT=https://74bcfe44629d279b31d58f3dbd5170af.r2.cloudflarestorage.com BACKUP_S3_BUCKET=snapflow-backups diff --git a/deploy/infrastructure/configs/notifications.yaml b/deploy/infrastructure/configs/notifications.yaml index 9e8c070..82285dc 100644 --- a/deploy/infrastructure/configs/notifications.yaml +++ b/deploy/infrastructure/configs/notifications.yaml @@ -1,5 +1,10 @@ # Flux reports every failed reconciliation to Discord, and the result of each -# synced commit to GitHub as a commit status. +# synced commit to GitHub as a commit status. The webhook is +# discord-webhook-url in snapflow/alerts (channel settings > Integrations > +# Webhooks > New Webhook > Copy Webhook URL); rotate it by saving a new URL in +# the OpenBao UI. A new namespace that holds Flux objects must be added to the +# flux-errors Alert. Commit statuses are posted as the snapflowio-automation +# GitHub App, which needs the Commit statuses: Read and write permission. apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: diff --git a/deploy/platform/backups/k3s.yaml b/deploy/platform/backups/k3s.yaml index eedf659..51308b3 100644 --- a/deploy/platform/backups/k3s.yaml +++ b/deploy/platform/backups/k3s.yaml @@ -1,6 +1,22 @@ # k3s uploads every etcd snapshot (install-k3s.sh: every six hours, 28 kept) -# to R2 as well, reading this Secret at each snapshot. Restoring needs the S3 -# settings on the command line instead (docs/deployment.md, "Restore"). +# to R2 under k3s/ as well, reading this Secret at each snapshot. +# +# Restore, first after losing the machine (then OpenBao, then Postgres and +# RustFS): install k3s with install-k3s.sh, stop it, put the original server +# token at /var/lib/rancher/k3s/server/token, and pass the S3 settings on the +# command line, because this Secret does not exist yet: +# k3s server --cluster-reset --etcd-s3 \ +# --etcd-s3-endpoint=.r2.cloudflarestorage.com --etcd-s3-region=auto \ +# --etcd-s3-bucket=snapflow-backups --etcd-s3-folder=k3s \ +# --etcd-s3-access-key= --etcd-s3-secret-key= \ +# --cluster-reset-restore-path= +# then start k3s again. +# +# The R2 credentials are snapflow/backups in OpenBao: an R2 Account API token +# with Object Read & Write on the bucket only; Cloudflare shows its S3 access +# key ID and secret once, on creation. Keep them, the OpenBao seal key and the +# k3s server token in a password manager: no backup restores without them. +# Test a restore every quarter. apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: diff --git a/deploy/platform/backups/openbao.yaml b/deploy/platform/backups/openbao.yaml index 1adaf86..a8cbaa9 100644 --- a/deploy/platform/backups/openbao.yaml +++ b/deploy/platform/backups/openbao.yaml @@ -1,5 +1,11 @@ -# A daily OpenBao raft snapshot, kept in R2 for 30 days. Restoring one needs -# the openbao-seal key too (docs/deployment.md, "Restore"). +# A daily OpenBao raft snapshot at 03:30, kept in R2 under openbao/ for 30 days. +# +# Restore (after k3s, before Postgres and RustFS): on a fresh cluster, create +# the openbao/openbao-seal secret from the backed-up seal key before OpenBao +# starts (bootstrap-flux.sh would otherwise generate a new one), copy a +# snapshot from openbao/ and run `bao operator raft snapshot restore -force +# ` with a root or sufficiently privileged token. Snapshots are encrypted +# with the seal key, so the original key is required. apiVersion: v1 kind: ServiceAccount metadata: diff --git a/deploy/platform/backups/rustfs.yaml b/deploy/platform/backups/rustfs.yaml index 1c64e24..1862b83 100644 --- a/deploy/platform/backups/rustfs.yaml +++ b/deploy/platform/backups/rustfs.yaml @@ -2,6 +2,10 @@ # Both buckets are mirrored to R2 daily; an object the mirror would delete or # overwrite is first moved under rustfs-changes/, kept for 30 days, so a # deletion in RustFS is never lost the same night. +# +# Restore (after OpenBao): `rclone sync r2:snapflow-backups/rustfs/ +# rustfs:` for snapflow and snapflow-snapshots; an object deleted or +# overwritten in the last 30 days is under rustfs-changes///. apiVersion: v1 kind: ConfigMap metadata: diff --git a/deploy/platform/data/postgres-backups.yaml b/deploy/platform/data/postgres-backups.yaml index bdb9f77..0770e09 100644 --- a/deploy/platform/data/postgres-backups.yaml +++ b/deploy/platform/data/postgres-backups.yaml @@ -1,5 +1,18 @@ # Postgres backups go to Cloudflare R2 through the Barman Cloud plugin: WAL -# continuously, a base backup every night, kept for a 30-day recovery window. +# continuously, a base backup every night at 03:00, kept for a 30-day recovery +# window under postgres/. Check them with +# `kubectl -n snapflow get backups,scheduledbackups`. +# +# Restore (after OpenBao): create a new Cluster that bootstraps from the object +# store, as in https://cloudnative-pg.io/plugin-barman-cloud/docs/usage/#restoring-a-cluster: +# bootstrap.recovery.source names an externalClusters entry with +# plugin.name barman-cloud.cloudnative-pg.io, parameters.barmanObjectName +# snapflow-postgres-backups and serverName snapflow-postgres, optionally with +# recoveryTarget.targetTime for a point in time. Write new WAL to a second +# ObjectStore so the restored cluster does not overwrite the history it came +# from. If R2 rejects uploads with an x-amz-content-sha256 error, set +# AWS_REQUEST_CHECKSUM_CALCULATION and AWS_RESPONSE_CHECKSUM_VALIDATION to +# when_required in the ObjectStore's instanceSidecarConfiguration.env. apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: diff --git a/deploy/platform/data/postgres.yaml b/deploy/platform/data/postgres.yaml index 692b54a..f6c2448 100644 --- a/deploy/platform/data/postgres.yaml +++ b/deploy/platform/data/postgres.yaml @@ -11,9 +11,13 @@ spec: initdb: database: snapflow owner: snapflow - # A login for database tools that can read every table and change nothing - # (docs/deployment.md, "Querying the database"). pg_read_all_data is - # PostgreSQL's built-in role for that; the password comes from OpenBao. + # A login for database tools that can read every table and change nothing; + # pg_read_all_data is PostgreSQL's built-in role for that. Its password is + # snapflow/platform/postgres-readonly, shown in the OpenBao UI. Postgres has + # no public port: in DataGrip, tunnel over SSH through the server and use + # host `kubectl -n snapflow get service snapflow-postgres-rw -o + # jsonpath='{.spec.clusterIP}'`, port 5432, database snapflow, user + # snapflow_readonly. The snapflow owner login is for the api only. managed: roles: - name: snapflow_readonly diff --git a/deploy/platform/data/secrets.yaml b/deploy/platform/data/secrets.yaml index 22e1120..137f532 100644 --- a/deploy/platform/data/secrets.yaml +++ b/deploy/platform/data/secrets.yaml @@ -44,9 +44,8 @@ spec: - extract: key: snapflow/platform/rustfs --- -# The read-only Postgres login for database tools (docs/deployment.md, -# "Querying the database"); postgres.yaml declares the role. CloudNativePG -# reloads the role's password when this secret changes. +# The read-only Postgres login for database tools; postgres.yaml declares the +# role. CloudNativePG reloads the role's password when this secret changes. apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: diff --git a/deploy/platform/openbao-configure/configure.sh b/deploy/platform/openbao-configure/configure.sh index 34d79a5..5e451d8 100644 --- a/deploy/platform/openbao-configure/configure.sh +++ b/deploy/platform/openbao-configure/configure.sh @@ -112,7 +112,7 @@ path "${kv_mount}/data/snapflow/*" { } POLICY -# The operator (moon run deploy:openbao, docs/deployment.md "Secrets") manages +# The operator (moon run deploy:openbao) manages # only the secrets it supplies: it can browse the snapflow folder and create, # edit, roll back and delete these paths, but cannot read the keyrings and # platform credentials this job generates or change OpenBao itself. @@ -128,8 +128,8 @@ POLICY printf 'path "%s/undelete/snapflow/%s" {\n capabilities = ["update"]\n}\n' "${kv_mount}" "${path}" done set +f - # The read-only database login (docs/deployment.md, "Querying the - # database") is generated below; the operator may read it, nothing more. + # The read-only database login (platform/data/postgres.yaml) is generated + # below; the operator may read it, nothing more. printf 'path "%s/metadata/snapflow/platform/" {\n capabilities = ["list"]\n}\n' "${kv_mount}" printf 'path "%s/data/snapflow/platform/postgres-readonly" {\n capabilities = ["read"]\n}\n' "${kv_mount}" printf 'path "%s/metadata/snapflow/platform/postgres-readonly" {\n capabilities = ["read"]\n}\n' "${kv_mount}" diff --git a/deploy/scripts/bootstrap-flux.sh b/deploy/scripts/bootstrap-flux.sh index 24a4b18..0ed5b74 100644 --- a/deploy/scripts/bootstrap-flux.sh +++ b/deploy/scripts/bootstrap-flux.sh @@ -9,6 +9,28 @@ # Installs the Flux Operator and the FluxInstance in deploy/clusters/production/ # flux-system. Flux syncs this repository as the snapflowio-automation GitHub App, # so no deploy key is created; after this, Flux manages the operator and itself. +# +# First installation of a production cluster, in order: +# 1. Cloudflare and Resend records for the domains in +# deploy/clusters/production/cluster-settings.env (each is described there), +# and a Zone:DNS:Edit API token for certificates and dynamic DNS. +# 2. Router: a DHCP reservation for the server, and port forwards for TCP 80, +# 443, 8443 and UDP 8443 (edge) and TCP 9443 (site, registry and storage), +# nothing else. +# 3. On the server: deploy/bootstrap/install-k3s.sh, then +# deploy/bootstrap/host-firewall.sh; copy /etc/rancher/k3s/k3s.yaml into +# the workstation's kubeconfig with the server's address. +# 4. SNAPFLOW_AUTOMATION_PRIVATE_KEY_FILE= moon run deploy:bootstrap +# (this script). Back up the printed OpenBao seal key offline. +# 5. moon run deploy:openbao and create the secrets it lists. +# 6. Each edge: register it with the admin API (POST /api/admin/edges, then +# POST /api/admin/edges/:id/enrollment-secret), store snapflow/edges/ +# in OpenBao and add {name, node} to edges in deploy/apps/production/snapflow.yaml. +# 7. After the first image publish, make each ghcr.io/snapflowio/snapflow/* +# package public (Package settings > Change visibility), so neither the +# cluster nor Flux needs a registry token. +# Flux retries each stage until its prerequisites exist, so steps 5 to 7 may +# come in any order. moon run deploy:status shows progress. set -euo pipefail diff --git a/deploy/scripts/openbao-ui.sh b/deploy/scripts/openbao-ui.sh index 6e8c4a3..f06050b 100644 --- a/deploy/scripts/openbao-ui.sh +++ b/deploy/scripts/openbao-ui.sh @@ -7,7 +7,7 @@ # SPDX-License-Identifier: AGPL-3.0 # Opens the cluster's OpenBao web UI on this machine for managing the operator -# secrets (docs/deployment.md, "Secrets"). OpenBao is never exposed outside +# secrets listed below (the message this prints). OpenBao is never exposed outside # the cluster: this forwards its port to localhost and logs in through # Kubernetes auth as the snapflow-operator role, whose token can edit only the # operator secrets and expires after an hour. On exit it asks External Secrets @@ -62,6 +62,21 @@ Sign in with method "Token" and this token (valid for one hour): ${token} +Secrets you supply, one key per field (Create secret under secret/snapflow/): + snapflow/api BETTER_AUTH_SECRETS RESEND_API_KEY SNAPFLOW_CLOUDFLARE_API_TOKEN + SNAPFLOW_CLOUDFLARE_WEBHOOK_SECRET STRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEY STRIPE_PLATFORM_WEBHOOK_SECRET + STRIPE_CONNECT_WEBHOOK_SECRET; optionally HEALTH_CHECK_API_KEY + GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET (the names apps/api/.env uses) + snapflow/alerts discord-webhook-url + snapflow/backups access-key-id secret-access-key (R2 S3 credentials) + snapflow/cloudflare-dns api-token (Zone:DNS:Edit on the public zone) + snapflow/edges/ id enrollment-secret (from the admin API's edge enrollment) +Read only: snapflow/platform/postgres-readonly (the database tools login). +Every save is a new version; Version history restores earlier values. A +missing key shows up as a failing ExternalSecret (kubectl get externalsecrets -A). + Press Ctrl-C when you are done; your changes are then synced to the cluster. MESSAGE wait "${port_forward}" || true diff --git a/docs/deployment.md b/docs/deployment.md deleted file mode 100644 index 4c1e629..0000000 --- a/docs/deployment.md +++ /dev/null @@ -1,174 +0,0 @@ -# Deployment - -Snapflow runs on [k3s](https://k3s.io) and is deployed with [Flux](https://fluxcd.io) (GitOps): the cluster pulls this repository and converges on what `main` describes. Every deployment operation is a [moon](https://moonrepo.dev) task, locally and in CI. - -## Architecture - -| Component | Role | -| --- | --- | -| k3s | Kubernetes, single control plane with embedded etcd (`cluster-init`), secrets encrypted at rest, WireGuard between nodes | -| Flux | Applies `deploy/` from `main`; image automation pins new images by digest | -| OpenBao | The only secrets store. Self-initialises, auto-unseals with a static seal key, holds every application secret, the api keyrings and the tunnel PKI | -| External Secrets Operator | Syncs OpenBao secrets into Kubernetes Secrets | -| Reloader | Rolls workloads when their secrets or config change | -| CloudNativePG | Postgres with operator-managed TLS (`sslmode=verify-full`) | -| Redis, RustFS | StatefulSets for locks and caches, and S3-compatible object storage | -| cert-manager | Let's Encrypt certificates through Cloudflare DNS validation | -| Traefik (k3s) | Cluster ingress on port `9443` (`traefik-direct`); ports 80 and 443 stay free for the edge | - -### Traffic - -The machine has one public IP, and the edge owns ports 80, 443 and 8443 for sandboxes. Everything else reaches Traefik on `9443`. - -| Host | Path to the cluster | -| --- | --- | -| `snapflow.io` (`/`, `/dashboard`, `/api`) | Cloudflare proxy (orange cloud) → an Origin Rule rewrites the destination port to `9443` → Traefik. Visitors use plain `https://snapflow.io`; Traefik trusts `X-Forwarded-For` only from Cloudflare's address ranges. | -| `*.snapflow.run` (sandboxes) | One proxied CNAME per exposure, created by the api, pointing at the edge that holds the sandbox's machine tunnel. The edge accepts these hosts only through Cloudflare authenticated origin pulls. | -| Edge public host (for example `edge-home.snapflow.run`), machine tunnel `:8443` | DNS only → edge on the host network (real client addresses) | -| `registry.snapflow.io:9443`, `storage.snapflow.io:9443` | DNS only → Traefik. Not proxied because Cloudflare limits request bodies to 100 MB and snapshot layers are larger. | - -### Repository layout - -``` -docker-bake.hcl image build graph for the five deployable apps -deploy/ - bootstrap/install-k3s.sh installs a node (server, server-edge or edge) - clusters/production/ Flux entry point: stages and cluster-settings.env - infrastructure/controllers/ cert-manager, CloudNativePG and its Barman Cloud plugin, ESO, OpenBao, Reloader, Traefik config - infrastructure/configs/ Let's Encrypt issuer, the OpenBao ClusterSecretStore and Flux alerts - platform/openbao-configure/ idempotent job that reconciles OpenBao from Git - platform/data/ Postgres (with its backups), Redis, RustFS and their secrets - platform/jobs/ object storage buckets - platform/backups/ OpenBao, RustFS and k3s backups to R2 - apps/production/ the Snapflow HelmRelease and its pinned image digests - images/ Flux image repositories, policies and update automation - charts/snapflow/ Helm chart for api, worker, dashboard, marketing, snapshot-manager and edges - components/ shared kustomize components (HelmRelease defaults) - scripts/ scripts behind the moon tasks -``` - -Flux applies the stages in order: `infrastructure-controllers` → `openbao-configure` → `infrastructure-configs` → `platform` → `platform-jobs` → `platform-backups` and `apps`. `images` runs alongside once the secret store is ready. - -## Tasks - -| Task | What it does | -| --- | --- | -| `moon run deploy:validate` | Lints the chart, validates every stage against the Kubernetes and CRD schemas, and shellchecks the scripts (runs in CI) | -| `moon run :image` | Builds the app image into the local Docker engine | -| `moon run :publish` | Typechecks, lints, validates, builds, scans with Trivy, and only then tags `build--`; the commit must be on `dev` | -| `moon run deploy:bootstrap` | Creates the OpenBao seal key if missing and bootstraps Flux | -| `moon run deploy:openbao` | Opens the OpenBao web UI on `localhost:18200` and prints a one-hour token for managing the operator secrets | -| `moon run deploy:reconcile` | Makes Flux pull `main` and apply every stage now | -| `moon run deploy:status` | Shows every Flux stage, Helm release and image policy | - -Images are built once, from `dev`, and production runs exactly those images: - -1. A merge into `dev` runs CI and then the same `:publish` task in GitHub Actions for every app it changed (`dev.yml` calls `ci.yml` and `images.yml`), and attests the image. -2. Within five minutes Flux image automation commits the new digest to `deploy/apps/production` on `dev`. It authenticates as the `snapflowio-automation` GitHub App, the only actor the `dev` ruleset lets push directly. That commit changes only the pinned manifests, so it starts neither CI nor another image build. -3. Promoting `dev` to `main` carries the pinned digests into production, and Flux deploys them. Nothing writes `main` directly. - -Machine releases (the Snapflow runtime, its installer and the TUF repository at `releases.snapflow.io`) are described in [releases.md](releases.md). - -## First installation - -1. **Cloudflare** - - `snapflow.io`: an A record to your public IP, proxied (orange cloud). Add an Origin Rule that matches the hostname `snapflow.io` and overrides the destination port to `9443`, set SSL/TLS to *Full (strict)* and turn on *Always Use HTTPS*. - - `registry` and `storage`: A records to your public IP, DNS only (grey cloud). These three records are kept on the connection's current address by [dynamic DNS](#dynamic-dns), so a changed home IP needs no action. - - `snapflow.run`, the sandbox zone from `PLATFORM_DOMAIN`: a separate registrable domain, so code running in a sandbox never shares a browser site with the dashboard or the api (the api refuses to start otherwise). Add an A record for each edge's public host, DNS only. Under *SSL/TLS > Custom Hostnames* set that host as the fallback origin, and turn on *Authenticated Origin Pulls*. The api creates every sandbox CNAME itself. - - Create an API token with `Zone:DNS:Edit` on both zones for certificates, and give `SNAPFLOW_CLOUDFLARE_API_TOKEN` DNS and custom hostname access to the sandbox zone. - - Email: add the sending domain from `EMAIL_DOMAIN` in `cluster-settings.env` (`mail.snapflow.io`) under *Domains* in [Resend](https://resend.com/domains) and copy its records into the zone, all DNS only: the DKIM `TXT` record `resend._domainkey.mail`, and the `MX` (priority 10) and SPF `TXT` records on `send.mail` that Resend uses as the bounce (Return-Path) domain. Add a DMARC `TXT` record on `_dmarc` (start with `v=DMARC1; p=none;` and tighten it once reports are clean), then press *Verify* in Resend or run `resend domains verify ` with the [Resend CLI](https://resend.com/docs/cli). Create an API key with *Sending access* restricted to that domain for `RESEND_API_KEY`. The api sends from `Snapflow ` (Helm value `email.from`); set `email.replyTo` in `deploy/apps/production/snapflow.yaml` to route replies to a monitored mailbox. -2. **Router**: give the machine a fixed LAN address (a DHCP reservation), and forward TCP 80, 443, 8443 and UDP 8443 (edge), and TCP 9443 (site, registry and storage) to it. Forward nothing else; SSH and the k3s API stay on the local network. -3. **k3s**: `sudo PUBLIC_IP= deploy/bootstrap/install-k3s.sh server-edge` (the API is advertised on the address of the default route; set `NODE_IP` to pick another), then copy `/etc/rancher/k3s/k3s.yaml` to your workstation's kubeconfig and replace `127.0.0.1` with the machine's address. Then apply the [host firewall](#host-firewall). -4. **Flux**: `SNAPFLOW_AUTOMATION_PRIVATE_KEY_FILE= moon run deploy:bootstrap`. It installs the [Flux Operator](https://fluxcd.control-plane.io/operator/), stores the App key in the `flux-system` secret (the only copy outside your password manager), and applies the `FluxInstance` in `deploy/clusters/production/flux-system`, which syncs `main` as the App; no deploy key is created. From then on Flux manages the operator and itself from Git. Back up the printed OpenBao seal key offline; it decrypts everything in OpenBao. -5. **Secrets**: run `moon run deploy:openbao`, open the printed URL and sign in with method *Token* and the printed token. Under `secret/snapflow/`, create each secret below with *Create secret*, one key per field. Press Ctrl-C in the terminal when you are done, which asks External Secrets to sync the changes now. - - | OpenBao path | Keys | - | --- | --- | - | `snapflow/api` | `BETTER_AUTH_SECRETS`, `RESEND_API_KEY`, `SNAPFLOW_CLOUDFLARE_API_TOKEN`, `SNAPFLOW_CLOUDFLARE_WEBHOOK_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_PLATFORM_WEBHOOK_SECRET`, `STRIPE_CONNECT_WEBHOOK_SECRET`, and optionally `HEALTH_CHECK_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (same names as `apps/api/.env`) | - | `snapflow/alerts` | `discord-webhook-url` (a Discord channel webhook, see [Alerts](#alerts)) | - | `snapflow/backups` | `access-key-id`, `secret-access-key` (the R2 S3 credentials, see [Backups](#backups)) | - | `snapflow/cloudflare-dns` | `api-token` (the `Zone:DNS:Edit` token) | - | `snapflow/edges/` | `id`, `enrollment-secret` (step 6) | - - Only secrets go in OpenBao; every other api setting comes from the Helm chart. The UI token can create, edit, roll back and delete only these paths, read the [read-only database login](#querying-the-database), and expires after an hour; OpenBao is never exposed outside the cluster. Every save is a new version, so *Version history* shows and restores earlier values. A missing or misspelled key shows up as a failing ExternalSecret (`kubectl get externalsecrets -A`) and a Flux alert. Everything else (api keyrings; Postgres, Redis, RustFS and snapshot-manager credentials; the tunnel CA) is generated in the cluster, and Cloudflare's public origin pull CA ships with the chart. Keep a copy of these values in your password manager. -6. **Edge**: register the edge with the admin API (`POST /api/admin/edges`, then `POST /api/admin/edges/:id/enrollment-secret`), store its `id` and `enrollment-secret` at `snapflow/edges/home` in the OpenBao UI, and add `{name: home, node: }` to `edges` in `deploy/apps/production/snapflow.yaml`. - -7. **Public images**: the images are public, like the source, so neither the cluster nor Flux needs a registry token. After the first publish, open each package under the organization's *Packages* (`snapflow/api`, `snapflow/dashboard`, `snapflow/marketing`, `snapflow/snapshot-manager`, `snapflow/edge`) and set *Package settings > Change visibility* to *Public* (the organization must allow public packages under *Settings > Packages*). - -Flux keeps retrying each stage until its prerequisites exist, so the order of steps 5 and 6 does not matter. - -### Database migrations - -The migration Job runs as a Helm pre-upgrade hook, so the new schema is in place before the new code starts and while the previous code is still serving. Every migration must therefore keep working with the release before it (expand and contract): add columns and tables in one release, start using them in the next, and drop what the old code needed only once no release uses it. The production `snapflow` release retries a failed upgrade instead of rolling back, because a rollback would run the previous code against a schema the migration already changed. - -### Sandbox domain isolation - -Every sandbox host serves code its owner controls, including the system toolbox, terminal and desktop. Isolation comes from four layers: - -- The sandbox zone is its own registrable domain, so sandbox pages are cross-site to `snapflow.io`. -- The edge removes the `Domain` attribute from every cookie a sandbox sets, and drops cookies that would shadow its own `__Host-Snapflow-Access-v1-` session cookies. -- Dashboard and api session cookies use the `__Host-` prefix, and the api refuses state-changing session requests from untrusted origins. -- File the sandbox zone with the [Public Suffix List](https://github.com/publicsuffix/list) (private section). Once browsers ship the entry, each sandbox host is its own site, isolated from every other sandbox as well. - -## Operations - -- **Rotate a secret**: open `moon run deploy:openbao`, edit the secret and save it as a new version, then press Ctrl-C; External Secrets syncs at once and Reloader rolls only the workloads whose secrets changed. To retire an edge, remove it from the chart, then delete `snapflow/edges/` in the UI (*Delete metadata and all versions*). -- **Roll back an app**: on a `hotfix/` branch from `main`, set the app's `tag` and `digest` in `deploy/apps/production/snapflow.yaml` back to the previous pins (or revert the promotion), and merge it into `main`. Image automation never writes `main`, so the rollback stays until the next promotion, which should carry the fix. Database migrations are forward-only. -- **Change settings**: edit `deploy/clusters/production/cluster-settings.env` or the chart values in Git; Flux applies the change. -- **Upgrade platform components**: bump the pinned chart and image versions in `deploy/` and run `moon run deploy:validate`. -- **Upgrade k3s**: rerun `install-k3s.sh` with a newer `k3s_version`. -- **Add an edge server**: install it with `install-k3s.sh edge` (it joins over WireGuard and is tainted for the edge only), then add its OpenBao secret and `edges` entry as in step 6. -- **Backups and restores**: see [Backups](#backups). - -### Host firewall - -`deploy/bootstrap/host-firewall.sh` is the server's whole UFW configuration: each run resets UFW and applies the same rules, so running it again changes nothing and removes anything added by hand. The edge ports (TCP 80, 443 and 8443, UDP 8443) and Traefik (TCP 9443) are public; SSH (rate limited) and the k3s API accept only the administrative network, by default the network of the default route, plus Tailscale when the machine runs it; the k3s pod and service networks are allowed as k3s requires. Preview it with `--dry-run`, then apply it on the server: - -```sh -ssh -t 'sudo bash -s' < deploy/bootstrap/host-firewall.sh -``` - -Set `ADMIN_CIDR` (for example `sudo ADMIN_CIDR=192.168.40.0/24 bash -s`) to choose the administrative network. Docker-published ports and Kubernetes NodePort, LoadBalancer and host-network ports bypass UFW, so the script ends by listing every such port; only Traefik's 9443 and the edge should appear. The router's port forwards remain the outer boundary. - -### Dynamic DNS - -`deploy/infrastructure/configs/cloudflare-ddns.yaml` runs [cloudflare-ddns](https://github.com/favonia/cloudflare-ddns), which checks the connection's public IPv4 address every five minutes and on start, and points `PUBLIC_DOMAIN` (proxied), `REGISTRY_DOMAIN` and `STORAGE_DOMAIN` (DNS only) at it. It deploys in the `infrastructure-configs` stage, before the platform and apps, uses the `snapflow/cloudflare-dns` token, and reports failed updates to the alerts Discord channel. Check it with `kubectl -n cloudflare-ddns logs deploy/cloudflare-ddns`. - -### Querying the database - -Postgres has no public port. Database tools connect as `snapflow_readonly`, a login that can read every table and change nothing (PostgreSQL's `pg_read_all_data`); its password is generated in OpenBao at `snapflow/platform/postgres-readonly`, which the OpenBao UI shows. The `snapflow` owner login is for the api only. - -In DataGrip (or any client with an SSH tunnel): - -- **SSH/SSL**: tunnel through the server with your SSH key. -- **Host**: the address of the primary's service, `kubectl -n snapflow get service snapflow-postgres-rw -o jsonpath='{.spec.clusterIP}'` (stable while the cluster exists); **Port** `5432`; **Database** `snapflow`; **User** `snapflow_readonly`. - -Without a tunnel, `kubectl -n snapflow port-forward service/snapflow-postgres-rw 5432:5432` serves it on `localhost:5432` while it runs. - -### Alerts - -Flux reports every failed reconciliation (sources, stages, Helm releases and image automation, in every namespace that holds Flux objects) to Discord, and posts the result of each stage for the synced `main` commit to GitHub as a commit status. Both are in `deploy/infrastructure/configs/notifications.yaml`; a new namespace with Flux objects must be added to its `flux-errors` Alert. The Discord webhook is `discord-webhook-url` in `snapflow/alerts` (channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL); rotate it by replacing the webhook and saving the new URL in the OpenBao UI. Commit statuses are posted as the `snapflowio-automation` GitHub App, which needs the *Commit statuses: Read and write* repository permission. - -### Backups - -Everything that cannot be rebuilt from Git is copied to the Cloudflare R2 bucket `snapflow-backups` (`BACKUP_S3_ENDPOINT` and `BACKUP_S3_BUCKET` in `cluster-settings.env`): - -| What | How | Schedule | Kept | Location in the bucket | -| --- | --- | --- | --- | --- | -| Postgres | CloudNativePG [Barman Cloud plugin](https://cloudnative-pg.io/plugin-barman-cloud/): continuous WAL archiving and a base backup | WAL continuously, base backup daily at 03:00 | 30-day recovery window | `postgres/` | -| OpenBao | `bao operator raft snapshot save` | daily at 03:30 | 30 days | `openbao/` | -| RustFS (volume backups, sandbox snapshots) | `rclone sync` of both buckets; objects it would delete or overwrite move to `rustfs-changes//` | daily at 04:00 | mirror, plus 30 days of changes | `rustfs/`, `rustfs-changes/` | -| k3s (etcd) | k3s etcd snapshots, uploaded with the local copy | every six hours | 28 snapshots (7 days) | `k3s/` | - -Credentials: on the R2 overview page, *Manage API tokens > Create Account API token* with *Object Read & Write* on `snapflow-backups` only. Cloudflare shows the S3 *Access Key ID* and *Secret Access Key* once, on creation; save them in `snapflow/backups` as `access-key-id` and `secret-access-key`. (They can also be derived from the token: the access key ID is the token's ID and the secret access key is the SHA-256 hex digest of the token value.) Keep them, the OpenBao seal key, the k3s server token (`/var/lib/rancher/k3s/server/token`) and the operator secrets from step 5 in your password manager: no backup can be restored without them. A failed backup Job or a failing WAL archive shows up as a Flux or CloudNativePG error; check `kubectl get backups,scheduledbackups -n snapflow` and the CronJobs after any change. - -Test a restore every quarter. Restore order after losing the machine: k3s, then OpenBao, then Postgres and RustFS. - -- **k3s**: install k3s with `install-k3s.sh`, stop it, and restore a snapshot from R2, passing the S3 settings on the command line (the in-cluster S3 secret is not available during a restore): `k3s server --cluster-reset --etcd-s3 --etcd-s3-endpoint=.r2.cloudflarestorage.com --etcd-s3-region=auto --etcd-s3-bucket=snapflow-backups --etcd-s3-folder=k3s --etcd-s3-access-key= --etcd-s3-secret-key= --cluster-reset-restore-path=`, with the original server token in place. Then start k3s again. -- **OpenBao**: on a fresh cluster, create the `openbao-seal` secret from your backed-up seal key before OpenBao starts (`bootstrap-flux.sh` would otherwise generate a new one), then copy a snapshot from `openbao/` and run `bao operator raft snapshot restore -force ` with a root or sufficiently privileged token. Snapshots are encrypted with the seal key, so the original key is required. -- **Postgres**: create a new Cluster that bootstraps from the object store, as in the [plugin's restore guide](https://cloudnative-pg.io/plugin-barman-cloud/docs/usage/#restoring-a-cluster): `bootstrap.recovery.source` pointing at an `externalClusters` entry with `plugin.name: barman-cloud.cloudnative-pg.io`, `parameters.barmanObjectName: snapflow-postgres-backups` and `serverName: snapflow-postgres`, and optionally `recoveryTarget.targetTime` for a point in time. Write new WAL to a fresh path (a second ObjectStore) so the restored cluster does not overwrite the history it came from. If R2 rejects uploads with an `x-amz-content-sha256` error, set `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_required` in the ObjectStore's `instanceSidecarConfiguration.env`, as the plugin documents. -- **RustFS**: `rclone sync r2:snapflow-backups/rustfs/ rustfs:` for `snapflow` and `snapflow-snapshots`; an object deleted or overwritten in the last 30 days is under `rustfs-changes///`. - -## Third-party data - -The api image ships the [DB-IP](https://db-ip.com) City Lite database, which locates machines and the callers placing sandboxes. It is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): pages that show locations derived from it must credit DB-IP with a link to https://db-ip.com. The image build downloads the current monthly edition, so rebuild the api image at least monthly to keep locations accurate. diff --git a/docs/hosting.md b/docs/hosting.md deleted file mode 100644 index 2f0bf31..0000000 --- a/docs/hosting.md +++ /dev/null @@ -1,67 +0,0 @@ -# Hosting - -Hosts list their own machines and Snapflow schedules customer sandboxes on them. Every sandbox is a microVM. This page lists what a machine needs, what Snapflow sets up on it, and how to check it before listing. Installation is described in [releases.md](releases.md#installing). - -## Requirements - -| Requirement | Detail | -| --- | --- | -| Operating system | Linux on x86_64 or arm64 with KVM (`/dev/kvm`). macOS is not supported. | -| Kernel | 6.2 or newer with Landlock enabled. Cloud Hypervisor always runs under Landlock. | -| systemd | With D-Bus. Every microVM runs in its own transient scope `snapflow-vm-.scope` under `snapflow-vms.slice`, so sandboxes keep running when the daemon restarts. | -| cgroup v2 | The unified hierarchy | -| nftables | Sandbox networking and isolation rules | -| IPv4 forwarding | Enabled | -| KSM | Off. Kernel same-page merging lets tenants observe each other's memory through timing. | - -### Storage - -Keep the data directory `/var/lib/snapflow` on XFS formatted with `reflink=1`, or on Btrfs, so images and volumes are cloned instantly. Other filesystems work, but every clone is a full copy and volume files are preallocated. - -Volumes live on the machine's local disk. No Ceph or other storage cluster is needed: running sandboxes' volumes are backed up off the machine (see [Volumes and backups](#volumes-and-backups)). - -### GPUs - -A GPU is passed through whole, together with the rest of its IOMMU group, to one sandbox at a time. - -- IOMMU enabled in firmware and on the kernel command line -- The NVIDIA driver installed (`nvidia-smi` and `libcuda.so.1`) -- `nvidia-drm` loaded with `modeset=0` -- `nvidia-persistenced` not running - -When a sandbox releases a GPU, Snapflow resets it, checks its health with `nvidia-smi` and scrubs its memory. A GPU that fails any step is quarantined and removed from the listing until the machine reboots. - -## Isolation - -Snapflow applies all of the following itself; there is nothing to configure. - -- **Network**: each sandbox has its own network namespace. It can reach the internet, but not the machine, the machine's local network, cloud metadata (`169.254.169.254`) or other sandboxes unless the customer links them. Spoofed source IP and MAC addresses are dropped, and IPv6 is off for sandboxes. -- **Firewall**: Snapflow works alongside Docker, UFW or plain iptables, and firewalld without manual rules. -- **Hypervisor jail**: each hypervisor process runs under its own uid with no capabilities, `no_new_privs`, seccomp, Landlock, private mount, PID, IPC and UTS namespaces, and access to its own files only. -- **Resources**: each sandbox has CPU, memory, process and disk I/O limits, and is pinned to whole physical CPU cores so tenants never share SMT siblings. -- **Registry access**: the machine receives a short-lived token scoped to each job instead of a shared registry password. - -Isolation protects the machine from sandboxes and sandboxes from each other. It does not hide sandboxes from the host operating the machine, and customers are told so in the terms of service. - -## Volumes and backups - -Volumes of running sandboxes are backed up incrementally and encrypted with AES-256-GCM, every 15 minutes by default (`SNAPFLOW_VOLUME_BACKUP_INTERVAL_SECONDS` on the api, see [api-environment.md](reference/api-environment.md)). If a machine fails, a volume can be restored onto another machine, losing at most one interval of writes. - -## Checking a machine - -```sh -sudo snapflow doctor -``` - -checks the machine before it is listed, including the kernel and Landlock, KSM, CPU vulnerability mitigations and, on GPU machines, the GPU prerequisites. - -```sh -sudo snapflow doctor --isolation -``` - -proves the isolation walls described above on the machine itself. Run it while Snapflow is stopped, before the machine is listed. - -## Host responsibilities - -- Do not inspect, copy or tamper with customer workloads or their data. -- Keep the required settings in place (KSM off, IOMMU on for GPUs and the rest of [Requirements](#requirements)) and do not disable or work around Snapflow's isolation. diff --git a/docs/reference/api-environment.md b/docs/reference/api-environment.md deleted file mode 100644 index 87ab806..0000000 --- a/docs/reference/api-environment.md +++ /dev/null @@ -1,165 +0,0 @@ -# API environment - - - -`SNAPFLOW_ENVIRONMENT` selects the deployment profile: whether the deployment is hosted (HTTPS origins, loopback Vault Proxy, verified database TLS, no localhost domain, no test keys) and whether logs and audit entries go to a developer console. Every application value comes from the environment, and code holds no deployment values: a local apps/api/.env (gitignored; it lists every variable development may set, in the sections below, and an empty value counts as unset) sets them in development; in production Kubernetes sets them from OpenBao through the External Secrets Operator. Secrets have no default. Runtime settings live in the settings system, and policy in versioned `*_POLICY` constants. - -## Environment - -The deployment selector and the process role - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_ENVIRONMENT` | development \| production | | all | | The deployment profile: development or production. | -| `SNAPFLOW_PROCESS_ROLE` | api \| worker \| all | | | | What this process runs (api, worker or all); Kubernetes and moon set it for every API process. | - -## Observability - -Logging and tracing - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `LOG_LEVEL` | trace \| debug \| info \| warn \| error \| fatal | info | | | Pino log level; debug also logs HTTP requests and times @LogExecution methods. | -| `SQL_LOG_LEVEL` | trace \| debug \| info \| warn \| error \| fatal | | all | | Most verbose log level that still logs SQL statements: they are logged while LOG_LEVEL is at or below it. | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | string | | | | OTLP/HTTP collector the traces are exported to; tracing is off without it. The standard OTEL_* variables tune the exporter. | - -## Public URLs & Domain - -The public origins of the API, website and dashboard, and the sandbox domain - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `API_URL` | string | | production | | Public origin of the API; hosted deployments require HTTPS. | -| `WEBSITE_URL` | string | | production | | Public origin of the marketing website. | -| `ADMIN_URL` | string | | production | | Public origin of the dashboard (served under /dashboard). | -| `SNAPFLOW_PLATFORM_DOMAIN` | string | | production | | Domain under which sandboxes are exposed; hosted deployments refuse a localhost domain or one sharing a site with API_URL, ADMIN_URL or WEBSITE_URL. | -| `SNAPFLOW_EDGE_HTTPS_PORT` | string | | only development | | HTTPS port of the local edge in sandbox origins; hosted edges serve the default 443. | -| `SNAPFLOW_EDGE_GUEST_ORIGIN` | string | | only development | | Origin at which sandbox guests reach the local edge for speedtests; hosted guests reach each edge at https://. | - -## Network & Proxies - -The HTTP listener and the proxies in front of it - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `PORT` | string | 3001 | | | TCP port the HTTP API listens on. | -| `SNAPFLOW_TRUSTED_PROXY_CIDRS` | string | 127.0.0.0/8,::1/128 | | | Proxies whose X-Forwarded-For the API trusts. | - -## Authentication - -Session signing secrets and the health check principal - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `BETTER_AUTH_SECRETS` | string | | all | yes | Better Auth signing secrets as :, newest first. | -| `HEALTH_CHECK_API_KEY` | string | | | yes | Bearer key of the health check principal. | - -## Social sign-in - -OAuth applications for Google and GitHub sign-in; a provider is offered only when both of its variables are set - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `GOOGLE_CLIENT_ID` | string | | | | Client ID of the Google OAuth application. | -| `GOOGLE_CLIENT_SECRET` | string | | | yes | Client secret of the Google OAuth application. | -| `GITHUB_CLIENT_ID` | string | | | | Client ID of the GitHub OAuth application. | -| `GITHUB_CLIENT_SECRET` | string | | | yes | Client secret of the GitHub OAuth application. | - -## Database - -PostgreSQL; one-off processes (migrations, CLI) use a single connection - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `DATABASE_URL` | string | | all | yes | PostgreSQL connection URL; each deployment has its own database. TLS follows its libpq sslmode and sslrootcert parameters; hosted deployments require sslmode=verify-full. | -| `DATABASE_POOL_MAX` | string | | all | | Maximum pooled connections of each long-running API or worker process. | -| `DATABASE_POOL_IDLE_TIMEOUT_MS` | string | | all | | Milliseconds an idle pooled connection stays open before it is closed. | -| `DATABASE_CONNECTION_TIMEOUT_MS` | string | | all | | Milliseconds to wait for a new database connection before failing. | - -## Redis - -Redis: locks, throttling, realtime fan-out and caches - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `REDIS_URL` | string | | all | yes | Redis URL redis://[user:password@]host:port (rediss:// for TLS), without a database index: the API uses databases 0 and 1. | - -## Email - -Transactional email sent through Resend - -In development, set RESEND_BASE_URL to the devcontainer Resend emulator and any re_ key to capture email locally; without RESEND_API_KEY the API starts but every email send fails. - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `RESEND_API_KEY` | string | | production | yes | Resend API key with sending access to the EMAIL_FROM domain. | -| `EMAIL_FROM` | string | | production | | Sender of platform email on a domain verified in Resend, as an address or "Display Name
"; required with RESEND_API_KEY. | -| `EMAIL_REPLY_TO` | string | | | | Reply-To address of platform email. | -| `RESEND_BASE_URL` | string | | only development | | Origin of a local Resend API emulator that captures email instead of sending it; unset sends through https://api.resend.com. | - -## Object Storage - -S3-compatible object storage for build contexts, logs and backups - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `S3_ENDPOINT` | string | | | | Endpoint of a self-hosted S3-compatible object storage with STS AssumeRole support; setting it selects that provider and requires S3_ACCESS_KEY and S3_SECRET_KEY, and without it the AWS S3 endpoint is derived from S3_REGION. | -| `S3_REGION` | string | | production | | S3 signing region; without S3_ENDPOINT it selects the AWS S3 endpoint. | -| `S3_DEFAULT_BUCKET` | string | | production | | Bucket of platform objects. | -| `S3_ACCESS_KEY` | string | | | yes | Static S3 access key, set with S3_SECRET_KEY; required with S3_ENDPOINT, otherwise the AWS default credential chain applies. | -| `S3_SECRET_KEY` | string | | | yes | Static S3 secret key. | -| `S3_ROLE_ARN` | string | | | | IAM role AWS STS assumes to issue push credentials scoped to an organization; required for AWS S3 and not allowed with S3_ENDPOINT. | -| `SNAPFLOW_VOLUME_BACKUP_INTERVAL_SECONDS` | string | 900 | | | Seconds between incremental backups of the volumes of a running sandbox; a host failure loses at most this much volume data. | - -## Snapshot Manager - -The platform registry that stores snapshot images, backups and build caches - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_SNAPSHOT_MANAGER_URL` | string | | all | | URL machines and builders reach the snapshot manager at. | - -## IP Geolocation - -The IP geolocation database that locates machines and the callers placing sandboxes - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_GEOIP_DATABASE_PATH` | string | | all | | Path of the DB-IP City Lite database (MaxMind DB format); the API image ships it, and apps/api/scripts/download-geoip-database.sh fetches it for development. | - -## Vault - -The Vault Proxy that serves the keyrings and signs tunnel certificates - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_VAULT_PROXY_URL` | string | | all | | Vault Proxy URL; hosted deployments require a loopback proxy. | - -## Cloudflare - -Custom domains through the Cloudflare zone of the platform domain - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_CLOUDFLARE_API_TOKEN` | string | | production | yes | Cloudflare API token with Zone:Read, DNS:Edit and SSL and Certificates:Edit on the platform domain's zone; the zone and its custom hostname fallback origin (the custom domains' CNAME target) are looked up with it. | -| `SNAPFLOW_CLOUDFLARE_WEBHOOK_SECRET` | string | | production | yes | Shared secret of Cloudflare custom hostname webhooks. | - -## Stripe - -Stripe Connect top-ups, host payouts and webhooks - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `STRIPE_SECRET_KEY` | string | | all | yes | Stripe secret API key; the API refuses a live key outside production and a test key in production. | -| `STRIPE_PLATFORM_WEBHOOK_SECRET` | string | | all | yes | Signing secret of the platform event destination (POST /api/stripe/webhooks/platform). | -| `STRIPE_CONNECT_WEBHOOK_SECRET` | string | | all | yes | Signing secret of the connected-accounts event destination (POST /api/stripe/webhooks/connect). | -| `STRIPE_PUBLISHABLE_KEY` | string | | all | | Stripe publishable key the dashboard loads Stripe.js and Connect.js with. | - -## Development - -Secrets of the local development stack, shared with its other services - -| Variable | Type | Default | Required | Secret | Description | -| --- | --- | --- | --- | --- | --- | -| `SNAPFLOW_DEVELOPMENT_EDGE_ENROLLMENT_SECRET` | string | | only development | yes | Enrollment secret of the local edge (its SNAPFLOW_EDGE_ENROLLMENT_SECRET). | -| `SNAPFLOW_DEVELOPMENT_MACHINE_ENROLLMENT_SECRET` | string | | only development | yes | Enrollment secret the local machine registers itself with (its SNAPFLOW_DEVELOPMENT_MACHINE_ENROLLMENT_SECRET). | diff --git a/docs/releases.md b/docs/releases.md deleted file mode 100644 index 018428f..0000000 --- a/docs/releases.md +++ /dev/null @@ -1,147 +0,0 @@ -# Releases - -Snapflow machines run one self-contained release: the `snapflow` CLI, the `snapflowd` daemon, the microVM runtime and the guest platform. Releases are versioned from Conventional Commits, built and tested in GitHub Actions, published on [GitHub Releases](https://github.com/snapflowio/snapflow/releases), and delivered to machines through a [TUF](https://theupdateframework.io) repository at `https://releases.snapflow.io`. - -## How a release happens - -1. Commits land on `dev` as squashed pull requests with [Conventional Commits](https://www.conventionalcommits.org) titles (enforced on pull requests), and reach `main` when `dev` is promoted with a merge commit (see [CONTRIBUTING.md](../CONTRIBUTING.md#branches)). `fix:` bumps the patch version, `feat:` the minor version; while the version is below 1.0.0 a breaking change (`feat!:`) bumps the minor version. -2. [release-please](https://github.com/googleapis/release-please) keeps a release pull request open that updates `apps/snapflow/CHANGELOG.md` and `.release-please-manifest.json`. Every commit counts except those confined to code the native release does not ship (the `exclude-paths` in `release-please-config.json`: the api, dashboard, edge, marketing, snapshot-manager, TypeScript libraries and `deploy/`). -3. Merging the release pull request makes release-please tag the merge commit `vX.Y.Z` and create a draft GitHub release. -4. The same `main` run calls `release.yml`, which builds from the tagged commit and publishes: - -``` -main.yml (push to main) -├── ci.yml -├── images.yml app images for Flux -└── release-please ── release.yml only when a release was created - prepare tag is on main, trust/root.json holds the production root - ├── erofs-utils mkfs.erofs per architecture - └── guest-platform guest images per architecture, pushed to ghcr.io - guest-release digest-pinned guest index, cosign-signed - bundle CLI, daemon, runtime bundle, CLI tarball and SBOM per architecture - kvm-canary boots the exact bundle on a self-hosted KVM runner per architecture - publish SHA256SUMS, cosign signature, provenance, upload, publish as prerelease - verify anonymous download, checksums, signature, attestations - tuf-beta points the beta channel at the release -``` - -Every job builds from the tag, so a failed run can be resumed with **Actions > Release > Run workflow** and the tag. A release that is already published is not modified again; the run only verifies it and continues with `tuf-beta`. - -Jobs that change the TUF repository (`tuf-beta`, `promote.yml`, `tuf-refresh.yml`) share the `snapflow-tuf` concurrency group. GitHub keeps one pending job per group, so a queued job can be replaced by a newer one; rerun a cancelled `tuf-beta` job from the run page. - -## Release assets - -| Asset | Contents | -| --- | --- | -| `snapflow-X.Y.Z-linux-{amd64,arm64}.tar.gz` | The `snapflow` CLI and `THIRD_PARTY_NOTICES.md`, as a reproducible archive. The CLI is the same binary that is inside the runtime bundle. | -| `snapflow-runtime-X.Y.Z-linux-{amd64,arm64}.tar.zst` | The runtime bundle that `snapflow setup` and `snapflow update` install | -| `snapflow-runtime-X.Y.Z-linux-{amd64,arm64}.spdx.json` | SPDX SBOM of everything in the bundle | -| `install.sh` | The installer | -| `SHA256SUMS` | Checksums of every asset above | -| `SHA256SUMS.sigstore.json` | Sigstore bundle for `SHA256SUMS`, signed keylessly by `release.yml` on `main` | - -Every asset also has a GitHub build provenance attestation, and GitHub attests the release itself when it is published (Immutable Releases). - -## Verifying a download - -```sh -v=X.Y.Z -base=https://github.com/snapflowio/snapflow/releases/download/v$v -curl -fsSLO "$base/SHA256SUMS" -O "$base/SHA256SUMS.sigstore.json" -O "$base/snapflow-$v-linux-amd64.tar.gz" - -sha256sum --check --ignore-missing SHA256SUMS -cosign verify-blob --bundle SHA256SUMS.sigstore.json \ - --certificate-identity-regexp '^https://github\.com/snapflowio/snapflow/\.github/workflows/release\.yml@refs/heads/main$' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - SHA256SUMS -gh attestation verify "snapflow-$v-linux-amd64.tar.gz" --repo snapflowio/snapflow \ - --signer-workflow snapflowio/snapflow/.github/workflows/release.yml -gh release verify "v$v" --repo snapflowio/snapflow -gh release verify-asset "v$v" "snapflow-$v-linux-amd64.tar.gz" --repo snapflowio/snapflow -``` - -`install.sh` always checks the archive against `SHA256SUMS`, and also checks the cosign signature when `cosign` is installed. Machines verify runtime bundles through TUF: the signed channel document pins each bundle's length and SHA-256. - -## Installing - -```sh -curl -fsSL https://snapflow.io/install.sh | sh -``` - -`https://snapflow.io/install.sh` redirects to the `install.sh` of the latest GitHub release, which is always the current stable version. The installer checks for Linux on amd64 or arm64, `/dev/kvm` and systemd, downloads and verifies the CLI, installs `/usr/local/bin/snapflow`, and runs `sudo snapflow setup` when a terminal is attached. Setup installs the runtime bundle from TUF and writes the systemd units; from then on `/usr/local/bin/snapflow` links into the active bundle, so updates replace the CLI too. - -| Option | Environment | Meaning | -| --- | --- | --- | -| `--version X.Y.Z` | `SNAPFLOW_VERSION` | Install this CLI release instead of the latest stable one | -| `--channel stable\|beta` | `SNAPFLOW_CHANNEL` | Channel `snapflow setup` follows (default `stable`) | -| `--dry-run` | | Download and verify, then print every change instead of making it | -| `--no-setup` | | Install the CLI only | - -Remove Snapflow from a machine with `sudo snapflow uninstall` (see `--purge` in its help). - -## Channels and promotion - -- **beta**: every release, automatically after its canaries pass. GitHub shows these as prereleases. -- **stable**: a beta release promoted by a person. GitHub's *latest* release is always the stable one. - -To promote, run **Actions > Promote to stable** with the version. The job waits for a reviewer of the `stable` environment, re-points the TUF `stable` channel at the already published beta release without rebuilding it, and then marks the GitHub release as latest. Machines move channels with `sudo snapflow update --channel beta|stable`. - -## One-time setup - -1. **Repository settings** - - *Settings > General > Releases*: enable **Immutable Releases**. - - The `snapflowio-automation` GitHub App runs release-please: it opens the release pull request (which starts CI like any other) and creates the `vX.Y.Z` tag, which the tag ruleset allows no one else to do. Its setup is in [CONTRIBUTING.md](../CONTRIBUTING.md#repository-setup). - - Branch setup (default branch, merge methods, rulesets) is in [CONTRIBUTING.md](../CONTRIBUTING.md#repository-setup). -2. **Environments** (*Settings > Environments*), both limited to the `main` branch. Each is named after the release channel it publishes to, so the repository's deployment history shows every beta release and every promotion to stable: - - | Environment | Reviewers | Secrets | - | --- | --- | --- | - | `beta` | none (the pipeline runs unattended; `tuf-refresh.yml` also re-signs with it) | `SNAPFLOW_RELEASE_R2_ACCOUNT_ID`, `SNAPFLOW_RELEASE_R2_ACCESS_KEY_ID`, `SNAPFLOW_RELEASE_R2_SECRET_ACCESS_KEY`, `SNAPFLOW_RELEASE_R2_BUCKET`, `SNAPFLOW_TUF_SNAPSHOT_KEY`, `SNAPFLOW_TUF_TIMESTAMP_KEY`, `SNAPFLOW_TUF_BETA_KEY` | - | `stable` | required reviewers; turn on *Prevent self-review* once a second maintainer can approve | the four `SNAPFLOW_RELEASE_R2_*` secrets, `SNAPFLOW_TUF_SNAPSHOT_KEY`, `SNAPFLOW_TUF_TIMESTAMP_KEY`, `SNAPFLOW_TUF_STABLE_KEY` | - - Key secrets hold the PEM file contents. Jobs write them to mode `0600` files under `$RUNNER_TEMP` and delete them when the step ends. -3. **Cloudflare R2**: create a bucket, connect the custom domain `releases.snapflow.io` to it, and create an R2 API token with *Object Read & Write* on that bucket only. The bucket holds only `metadata/` and `targets/`; bundles stay on GitHub Releases. -4. **TUF root ceremony**, on an offline machine with a checkout of the tag you trust: - - ```sh - go run ./apps/snapflow/cmd/snapflow-release tuf init --repository tuf --private-keys keys --policy production - ``` - - The production policy creates three root keys (threshold 2), one targets key, and one key each for snapshot, timestamp, beta and stable. Root and targets keys never leave offline storage: keep each root key on separate encrypted media with a different holder or location, and the targets key with them. Put the snapshot, timestamp, beta and stable keys into the environment secrets above, then delete those local copies (a lost online key is replaced with `tuf resign`). Commit `tuf/metadata/1.root.json` as `apps/snapflow/internal/tuf/trust/root.json`; release builds fail until it is there. Upload the repository as in [Uploading by hand](#uploading-by-hand). -5. **KVM runners**: register one self-hosted runner per architecture on bare metal or a VM with nested virtualization, with the labels `self-hosted`, `linux`, `X64` or `ARM64`, and `snapflow-kvm`. They need `/dev/kvm` and passwordless `sudo`, and should accept only this repository's jobs. Then set the repository variable `SNAPFLOW_KVM_RUNNER_ARCHITECTURES` (*Settings > Secrets and variables > Actions > Variables*) to the registered architectures, for example `["X64"]`; `isolation.yml` is skipped until it is set. The canary works in `/opt/snapflow-canary-*` and removes it afterwards. - -## Routine operations - -- **Refresh**: `tuf-refresh.yml` runs every six hours. It re-signs timestamp (valid three days), snapshot when it nears its 14-day expiry, and beta once half of its year has passed. -- **Expiry alerts**: the refresh job then fails if root, targets, beta or stable expire within 30 days. A failed refresh run is the alert. -- **Silent stop**: GitHub disables scheduled workflows in a public repository after 60 days without activity, and a disabled schedule produces no failed run. The timestamp is valid for three days, so also monitor `https://releases.snapflow.io/metadata/timestamp.json` from outside GitHub (an uptime check that alerts when its `expires` is under two days away). If the workflow was disabled, re-enable it under *Actions > TUF refresh* and run it once. -- **Stable**: promoting re-signs stable, and promoting the version stable already points at only re-signs it. Run **Promote to stable** with the current stable version at least once a year. -- **Root and targets, yearly**: in an offline ceremony (below), run `tuf resign --repository tuf --role root --key ROOT_KEY_1 --key ROOT_KEY_2` and `tuf resign --repository tuf --role targets --key TARGETS_KEY --snapshot-key SNAPSHOT_KEY --timestamp-key TIMESTAMP_KEY`. -- **Root rotation**: create new keys with `tuf generate-root-keys --output new-keys`, then `tuf rotate-root --repository tuf --current-root-key OLD_1 --current-root-key OLD_2 --new-root-key NEW_1 --new-root-key NEW_2 --new-root-key NEW_3`. Clients follow the chain of root versions, so the embedded root in old CLIs keeps working. Commit the new root as `trust/root.json` so fresh installs start from it. -- **Online key compromise** (snapshot, timestamp, beta or stable): create a key with `tuf generate-key --output NEW.pem` and replace it offline. For snapshot or timestamp: `tuf resign --repository tuf --role root --key ROOT_KEY_1 --key ROOT_KEY_2 --replace snapshot=NEW.pem` (or `timestamp=`). For beta or stable: `tuf resign --repository tuf --role targets --key TARGETS_KEY --replace beta=NEW.pem --snapshot-key SNAPSHOT_KEY --timestamp-key TIMESTAMP_KEY`. Upload, update the GitHub secret, and run **TUF refresh**. -- **Targets or root key compromise**: replace targets with `tuf resign --role root ... --replace targets=NEW.pem`; rotate root with `tuf rotate-root`, dropping the compromised key. - -### Uploading by hand - -Disable the **TUF refresh** workflow for the duration of a ceremony so it cannot publish over it, and start from the live repository: - -```sh -export RCLONE_CONFIG_R2_TYPE=s3 RCLONE_CONFIG_R2_PROVIDER=Cloudflare RCLONE_CONFIG_R2_NO_CHECK_BUCKET=true -export RCLONE_CONFIG_R2_ENDPOINT=https://.r2.cloudflarestorage.com -export RCLONE_CONFIG_R2_ACCESS_KEY_ID= RCLONE_CONFIG_R2_SECRET_ACCESS_KEY= -rclone copy --checksum --include "/metadata/**" --include "/targets/**" r2: tuf -``` - -After the ceremony, upload in this order: immutable files first, `timestamp.json` last. `--immutable` refuses to overwrite a published file with different content. - -```sh -rclone copy --checksum --immutable --header-upload "Cache-Control: public, max-age=31536000, immutable" tuf/targets r2:/targets -rclone copy --checksum --immutable --header-upload "Cache-Control: public, max-age=31536000, immutable" --exclude /timestamp.json tuf/metadata r2:/metadata -rclone copyto --checksum --header-upload "Cache-Control: no-store" tuf/metadata/timestamp.json r2:/metadata/timestamp.json -``` - -Then re-enable **TUF refresh**, and update any GitHub secrets whose keys changed. - -## Installer contract - -Installed machines read the bundle manifest and the TUF channel documents (`targets/channels//linux-.json`) with the CLI they already run. Their schemas are a contract with every CLI in the field: a change to either must first ship in a release whose CLI understands both the old and the new form, and only a later release may start producing the new form. The same applies to the `install.sh` asset names and the `SHA256SUMS` format. diff --git a/scripts/download-geoip-database.sh b/scripts/download-geoip-database.sh old mode 100755 new mode 100644 index e8482e7..0434608 --- a/scripts/download-geoip-database.sh +++ b/scripts/download-geoip-database.sh @@ -8,7 +8,9 @@ # Downloads the DB-IP City Lite database (CC BY 4.0, https://db-ip.com) to the # given path. DB-IP publishes one file a month, so the previous month's file is -# used while the current one is not out yet. +# used while the current one is not out yet; rebuild the api image at least +# monthly to keep locations current. The licence requires every page that shows +# a location derived from it to credit DB-IP with a link to https://db-ip.com. set -eu From 46105f7a694eaaaeada483380995f73df465089d Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 21:03:04 -0700 Subject: [PATCH 4/7] chore: remove dead code, configuration and unused runtime options (#17) Removes unused configuration, the tsx dependency, pnpm entries for packages no longer installed, unused API helpers and environment registry fields, and dead microVM runtime code: the never-applied WithVolumeBlockStore and WithGPUManager options, the unwired fork-from-snapshot-record path, and four uncalled methods. GPU passthrough, volumes and standby pause, restore and fork behave as before. --- .devcontainer/Dockerfile | 2 - .devcontainer/devcontainer.json | 1 - .devcontainer/docker-compose.yml | 1 - .dockerignore | 7 - .gitignore | 9 - .vscode/settings.json | 6 +- apps/api/package.json | 1 - apps/api/src/common/utils/api-key.util.ts | 14 -- .../concurrency/sequential-async.util.ts | 24 --- apps/api/src/common/utils/fixed-point.util.ts | 5 - .../namespaces/authentication.environment.ts | 1 - .../namespaces/cloudflare.environment.ts | 1 - .../namespaces/database.environment.ts | 1 - .../namespaces/development.environment.ts | 1 - .../namespaces/email.environment.ts | 1 - .../namespaces/geo-ip.environment.ts | 1 - .../namespaces/network.environment.ts | 1 - .../namespaces/observability.environment.ts | 1 - .../namespaces/process.environment.ts | 1 - .../namespaces/public-urls.environment.ts | 1 - .../namespaces/redis.environment.ts | 1 - .../snapshot-manager.environment.ts | 1 - .../namespaces/social-sign-in.environment.ts | 1 - .../namespaces/storage.environment.ts | 1 - .../namespaces/stripe.environment.ts | 1 - .../namespaces/vault.environment.ts | 1 - .../environment/registry.environment.ts | 7 - .../utils/environment-variable.util.ts | 18 +- apps/api/src/generate-openapi.ts | 2 - .../funds/constants/stripe.constants.ts | 6 - apps/edge/internal/edge/edgetest/tunnels.go | 10 - apps/guest/.gitignore | 1 - apps/marketing/Dockerfile | 2 +- apps/snapflow/internal/sshgateway/service.go | 4 - biome.json | 5 - .../charts/snapflow/ci/production-values.yaml | 10 +- libs/microvm-go/imagecache/platform.go | 6 - .../internal/lifecycle/coordinator.go | 4 - libs/microvm-go/internal/runtime/backend.go | 18 +- libs/microvm-go/internal/runtime/fork.go | 30 +-- .../internal/runtime/fork_source.go | 73 +------ libs/microvm-go/internal/runtime/fork_test.go | 69 ------ .../runtime/snapshot_secret_policy_test.go | 6 +- libs/microvm-go/internal/snapshot/payload.go | 31 --- libs/microvm-go/internal/snapshot/store.go | 17 +- libs/microvm-go/internal/snapshot/types.go | 5 +- libs/microvm-go/microvm.go | 25 +-- libs/microvm-go/types/requests.go | 1 - package.json | 1 - pnpm-lock.yaml | 197 +++++++----------- pnpm-workspace.yaml | 19 -- scripts/cleanup.sh | 1 - 52 files changed, 124 insertions(+), 530 deletions(-) delete mode 100644 apps/api/src/common/utils/api-key.util.ts delete mode 100644 libs/microvm-go/internal/snapshot/payload.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5d1c25e..8e6a4d8 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -23,8 +23,6 @@ FROM moby/buildkit:v0.32.2@sha256:28a898719c18a33f4e8000685287fa36fd0dd9560c6440 FROM mcr.microsoft.com/devcontainers/base:bookworm@sha256:bb7b81b6e5be17b5267f92f4ffda534fea37dab1df97b5e86c1f9b91da5c0b5d -LABEL org.opencontainers.image.source="https://github.com/snapflowio/snapflow" - ARG DEBIAN_FRONTEND=noninteractive ARG MOON_VERSION=2.4.6 ARG CODEX_VERSION=0.145.0 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e727ac3..9498279 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,7 +52,6 @@ "3002": { "label": "React Email Preview" }, "3773": { "label": "T3 Code" }, "4321": { "label": "Marketing (Astro)" }, - "5173": { "label": "Vite Development Server" }, "8082": { "label": "Snapflow Edge HTTP Ingress" }, "8444": { "label": "Snapflow Edge HTTPS Ingress" }, "23000-23031": { "label": "Snapflow Edge Public TCP/UDP Exposures" }, diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f408d02..9aa1f61 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -18,7 +18,6 @@ services: tmpfs: - /run:exec,size=256m ports: - - "127.0.0.1:5173:5173/tcp" - "3774:3773/tcp" - "127.0.0.1:4443:4443/tcp" - "127.0.0.1:4443:4443/udp" diff --git a/.dockerignore b/.dockerignore index e171dc4..51ef852 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,18 +4,12 @@ node_modules **/.pnpm-store **/dist -!dist/ -dist/* -!dist/libs/ -dist/libs/* -!dist/libs/computer-use-amd64 .vite **/.vite .svelte-kit **/.svelte-kit .moon/cache -.moon/docker logs *.log @@ -51,7 +45,6 @@ tmp *.tmp *.md -docs assets **/*.test.ts diff --git a/.gitignore b/.gitignore index 82a8ba5..fb0fc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,6 @@ tmp out-tsc build -/libs/sdk-typescript/cjs/ -/libs/sdk-typescript/esm/ -/libs/api-client/src/**/*.js -/libs/api-client/src/**/*.d.ts -/libs/toolbox-api-client/src/**/*.js -/libs/toolbox-api-client/src/**/*.d.ts - node_modules .pnpm-store .pnpm-debug.log* @@ -17,7 +10,6 @@ node_modules .env .env.local .env.*.local -.env.production /deploy/secrets/production/ .vite @@ -70,4 +62,3 @@ coverage.out *.test .moon/cache -.moon/docker diff --git a/.vscode/settings.json b/.vscode/settings.json index e1edcd4..c0cc3cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -91,8 +91,7 @@ "**/reference/**": true, "**/references/**": true, "**/tmp/**": true, - "**/coverage/**": true, - "**/libs/microvm-go/internal/initrd/static/**": true + "**/coverage/**": true }, "search.exclude": { "**/.svelte-kit/**": true, @@ -106,8 +105,7 @@ "**/reference/**": true, "**/references/**": true, "**/tmp/**": true, - "**/coverage/**": true, - "**/libs/microvm-go/internal/initrd/static/**": true + "**/coverage/**": true }, "git.ignoreLimitWarning": true } diff --git a/apps/api/package.json b/apps/api/package.json index 8ff3bb6..9cf2987 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -95,7 +95,6 @@ "@types/webpack-node-externals": "catalog:", "pino-pretty": "^13.1.2", "tsconfig-paths-webpack-plugin": "catalog:", - "tsx": "catalog:", "typescript": "catalog:", "webpack-node-externals": "catalog:" }, diff --git a/apps/api/src/common/utils/api-key.util.ts b/apps/api/src/common/utils/api-key.util.ts deleted file mode 100644 index 7f09e25..0000000 --- a/apps/api/src/common/utils/api-key.util.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2026 Snapflow. All rights reserved. - * - * Snapflow is licensed under the GNU Affero General Public License v3.0. - * You may obtain a copy of the license at https://www.gnu.org/licenses/agpl-3.0.html - * - * SPDX-License-Identifier: AGPL-3.0 - */ - -import * as crypto from 'node:crypto'; - -export function generateRandomString(size: number): string { - return crypto.randomBytes(size).toString('hex'); -} diff --git a/apps/api/src/common/utils/concurrency/sequential-async.util.ts b/apps/api/src/common/utils/concurrency/sequential-async.util.ts index 79bbf77..02309ba 100644 --- a/apps/api/src/common/utils/concurrency/sequential-async.util.ts +++ b/apps/api/src/common/utils/concurrency/sequential-async.util.ts @@ -35,27 +35,3 @@ export async function settleSequentially( } return results; } - -export async function mapWithConcurrency( - items: readonly TItem[], - concurrency: number, - operation: (item: TItem, index: number) => Promise -): Promise[]> { - if (!Number.isInteger(concurrency) || concurrency < 1) { - throw new RangeError('Concurrency must be a positive integer'); - } - - const results: Awaited[] = new Array(items.length); - let nextIndex = 0; - const workerCount = Math.min(concurrency, items.length); - const workers = Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await operation(items[index], index); - } - }); - - await Promise.all(workers); - return results; -} diff --git a/apps/api/src/common/utils/fixed-point.util.ts b/apps/api/src/common/utils/fixed-point.util.ts index 9dddc92..861b95b 100644 --- a/apps/api/src/common/utils/fixed-point.util.ts +++ b/apps/api/src/common/utils/fixed-point.util.ts @@ -50,8 +50,3 @@ export function fixed(value: bigint, scale: number): string { export function moneyToScaledInteger(value: string): bigint { return parseFixed(value, USAGE_MONEY_SCALE, 'money amount'); } - -export function scaledIntegerToMoney(value: bigint): string { - if (value < 0n) throw new Error('Money amount must be nonnegative'); - return fixed(value, USAGE_MONEY_SCALE); -} diff --git a/apps/api/src/config/environment/namespaces/authentication.environment.ts b/apps/api/src/config/environment/namespaces/authentication.environment.ts index f4d543c..bce0922 100644 --- a/apps/api/src/config/environment/namespaces/authentication.environment.ts +++ b/apps/api/src/config/environment/namespaces/authentication.environment.ts @@ -15,7 +15,6 @@ import { export const authenticationEnvironment = defineEnvironment( { - name: 'authentication', title: 'Authentication', description: 'Session signing secrets and the health check principal', }, diff --git a/apps/api/src/config/environment/namespaces/cloudflare.environment.ts b/apps/api/src/config/environment/namespaces/cloudflare.environment.ts index 22b9334..9090c2e 100644 --- a/apps/api/src/config/environment/namespaces/cloudflare.environment.ts +++ b/apps/api/src/config/environment/namespaces/cloudflare.environment.ts @@ -15,7 +15,6 @@ import { export const cloudflareEnvironment = defineEnvironment( { - name: 'cloudflare', title: 'Cloudflare', description: 'Custom domains through the Cloudflare zone of the platform domain', diff --git a/apps/api/src/config/environment/namespaces/database.environment.ts b/apps/api/src/config/environment/namespaces/database.environment.ts index c3ed6ea..5e5bbad 100644 --- a/apps/api/src/config/environment/namespaces/database.environment.ts +++ b/apps/api/src/config/environment/namespaces/database.environment.ts @@ -18,7 +18,6 @@ import { export const databaseEnvironment = defineEnvironment( { - name: 'database', title: 'Database', description: 'PostgreSQL; one-off processes (migrations, CLI) use a single connection', diff --git a/apps/api/src/config/environment/namespaces/development.environment.ts b/apps/api/src/config/environment/namespaces/development.environment.ts index b455a6d..25c48b1 100644 --- a/apps/api/src/config/environment/namespaces/development.environment.ts +++ b/apps/api/src/config/environment/namespaces/development.environment.ts @@ -22,7 +22,6 @@ const developmentSecret = (description: string) => export const developmentEnvironment = defineEnvironment( { - name: 'development', title: 'Development', description: 'Secrets of the local development stack, shared with its other services', diff --git a/apps/api/src/config/environment/namespaces/email.environment.ts b/apps/api/src/config/environment/namespaces/email.environment.ts index e9c628d..e74a435 100644 --- a/apps/api/src/config/environment/namespaces/email.environment.ts +++ b/apps/api/src/config/environment/namespaces/email.environment.ts @@ -23,7 +23,6 @@ import { export const emailEnvironment = defineEnvironment( { - name: 'email', title: 'Email', description: 'Transactional email sent through Resend', note: 'In development, set RESEND_BASE_URL to the devcontainer Resend emulator and any re_ key to capture email locally; without RESEND_API_KEY the API starts but every email send fails.', diff --git a/apps/api/src/config/environment/namespaces/geo-ip.environment.ts b/apps/api/src/config/environment/namespaces/geo-ip.environment.ts index ee8673b..018741e 100644 --- a/apps/api/src/config/environment/namespaces/geo-ip.environment.ts +++ b/apps/api/src/config/environment/namespaces/geo-ip.environment.ts @@ -15,7 +15,6 @@ import { export const geoIpEnvironment = defineEnvironment( { - name: 'geo-ip', title: 'IP Geolocation', description: 'The IP geolocation database that locates machines and the callers placing sandboxes', diff --git a/apps/api/src/config/environment/namespaces/network.environment.ts b/apps/api/src/config/environment/namespaces/network.environment.ts index e799d6a..1e7806e 100644 --- a/apps/api/src/config/environment/namespaces/network.environment.ts +++ b/apps/api/src/config/environment/namespaces/network.environment.ts @@ -17,7 +17,6 @@ const LOOPBACK_PROXY_CIDRS = '127.0.0.0/8,::1/128'; export const networkEnvironment = defineEnvironment( { - name: 'network', title: 'Network & Proxies', description: 'The HTTP listener and the proxies in front of it', }, diff --git a/apps/api/src/config/environment/namespaces/observability.environment.ts b/apps/api/src/config/environment/namespaces/observability.environment.ts index b7d8b2f..a338825 100644 --- a/apps/api/src/config/environment/namespaces/observability.environment.ts +++ b/apps/api/src/config/environment/namespaces/observability.environment.ts @@ -26,7 +26,6 @@ export type LogLevel = (typeof LOG_LEVELS)[number]; export const observabilityEnvironment = defineEnvironment( { - name: 'observability', title: 'Observability', description: 'Logging and tracing', }, diff --git a/apps/api/src/config/environment/namespaces/process.environment.ts b/apps/api/src/config/environment/namespaces/process.environment.ts index 8a2e5d9..0cf71c4 100644 --- a/apps/api/src/config/environment/namespaces/process.environment.ts +++ b/apps/api/src/config/environment/namespaces/process.environment.ts @@ -17,7 +17,6 @@ import { export const processEnvironment = defineEnvironment( { - name: 'process', title: 'Environment', description: 'The deployment selector and the process role', }, diff --git a/apps/api/src/config/environment/namespaces/public-urls.environment.ts b/apps/api/src/config/environment/namespaces/public-urls.environment.ts index 3c34f29..6a4a4eb 100644 --- a/apps/api/src/config/environment/namespaces/public-urls.environment.ts +++ b/apps/api/src/config/environment/namespaces/public-urls.environment.ts @@ -18,7 +18,6 @@ import { export const publicUrlsEnvironment = defineEnvironment( { - name: 'public-urls', title: 'Public URLs & Domain', description: 'The public origins of the API, website and dashboard, and the sandbox domain', diff --git a/apps/api/src/config/environment/namespaces/redis.environment.ts b/apps/api/src/config/environment/namespaces/redis.environment.ts index 65c7fac..d21162a 100644 --- a/apps/api/src/config/environment/namespaces/redis.environment.ts +++ b/apps/api/src/config/environment/namespaces/redis.environment.ts @@ -15,7 +15,6 @@ import { export const redisEnvironment = defineEnvironment( { - name: 'redis', title: 'Redis', description: 'Redis: locks, throttling, realtime fan-out and caches', }, diff --git a/apps/api/src/config/environment/namespaces/snapshot-manager.environment.ts b/apps/api/src/config/environment/namespaces/snapshot-manager.environment.ts index 873d1b7..bc73e4e 100644 --- a/apps/api/src/config/environment/namespaces/snapshot-manager.environment.ts +++ b/apps/api/src/config/environment/namespaces/snapshot-manager.environment.ts @@ -15,7 +15,6 @@ import { export const snapshotManagerEnvironment = defineEnvironment( { - name: 'snapshot-manager', title: 'Snapshot Manager', description: 'The platform registry that stores snapshot images, backups and build caches', diff --git a/apps/api/src/config/environment/namespaces/social-sign-in.environment.ts b/apps/api/src/config/environment/namespaces/social-sign-in.environment.ts index 7c57681..43a372f 100644 --- a/apps/api/src/config/environment/namespaces/social-sign-in.environment.ts +++ b/apps/api/src/config/environment/namespaces/social-sign-in.environment.ts @@ -15,7 +15,6 @@ import { export const socialSignInEnvironment = defineEnvironment( { - name: 'social-sign-in', title: 'Social sign-in', description: 'OAuth applications for Google and GitHub sign-in; a provider is offered only when both of its variables are set', diff --git a/apps/api/src/config/environment/namespaces/storage.environment.ts b/apps/api/src/config/environment/namespaces/storage.environment.ts index b78ef9b..f566b19 100644 --- a/apps/api/src/config/environment/namespaces/storage.environment.ts +++ b/apps/api/src/config/environment/namespaces/storage.environment.ts @@ -21,7 +21,6 @@ import { export const objectStorageEnvironment = defineEnvironment( { - name: 'object-storage', title: 'Object Storage', description: 'S3-compatible object storage for build contexts, logs and backups', diff --git a/apps/api/src/config/environment/namespaces/stripe.environment.ts b/apps/api/src/config/environment/namespaces/stripe.environment.ts index 8dfd2b8..820af8c 100644 --- a/apps/api/src/config/environment/namespaces/stripe.environment.ts +++ b/apps/api/src/config/environment/namespaces/stripe.environment.ts @@ -15,7 +15,6 @@ import { export const stripeEnvironment = defineEnvironment( { - name: 'stripe', title: 'Stripe', description: 'Stripe Connect top-ups, host payouts and webhooks', }, diff --git a/apps/api/src/config/environment/namespaces/vault.environment.ts b/apps/api/src/config/environment/namespaces/vault.environment.ts index 0bab0cb..e07295a 100644 --- a/apps/api/src/config/environment/namespaces/vault.environment.ts +++ b/apps/api/src/config/environment/namespaces/vault.environment.ts @@ -15,7 +15,6 @@ import { export const vaultEnvironment = defineEnvironment( { - name: 'vault', title: 'Vault', description: 'The Vault Proxy that serves the keyrings and signs tunnel certificates', diff --git a/apps/api/src/config/environment/registry.environment.ts b/apps/api/src/config/environment/registry.environment.ts index a0c1503..8aac35e 100644 --- a/apps/api/src/config/environment/registry.environment.ts +++ b/apps/api/src/config/environment/registry.environment.ts @@ -63,10 +63,3 @@ export type EnvironmentValues< > = UnionToIntersection< Namespace extends RegisteredNamespace ? z.output : never >; - -type NamespaceVariableName = Namespace extends RegisteredNamespace - ? keyof z.input & string - : never; - -export type EnvironmentVariableName = - NamespaceVariableName; diff --git a/apps/api/src/config/environment/utils/environment-variable.util.ts b/apps/api/src/config/environment/utils/environment-variable.util.ts index 8087688..9318c67 100644 --- a/apps/api/src/config/environment/utils/environment-variable.util.ts +++ b/apps/api/src/config/environment/utils/environment-variable.util.ts @@ -29,26 +29,20 @@ export function environmentVariable( export type EnvironmentShape = Readonly>; -interface EnvironmentGroup { - readonly name: Name; +interface EnvironmentGroup { readonly title: string; readonly description: string; readonly note?: string; } -export interface EnvironmentNamespace< - Shape extends EnvironmentShape, - Name extends string = string, -> extends EnvironmentGroup { +export interface EnvironmentNamespace + extends EnvironmentGroup { readonly schema: z.ZodObject; } -export function defineEnvironment< - const Name extends string, - const Shape extends EnvironmentShape, ->( - group: EnvironmentGroup, +export function defineEnvironment( + group: EnvironmentGroup, variables: Shape -): EnvironmentNamespace { +): EnvironmentNamespace { return { ...group, schema: z.object(variables) }; } diff --git a/apps/api/src/generate-openapi.ts b/apps/api/src/generate-openapi.ts index 7f72c02..81adc71 100644 --- a/apps/api/src/generate-openapi.ts +++ b/apps/api/src/generate-openapi.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env tsx - /* * Copyright (c) 2026 Snapflow. All rights reserved. * diff --git a/apps/api/src/payments/funds/constants/stripe.constants.ts b/apps/api/src/payments/funds/constants/stripe.constants.ts index f6e3cb9..85414c2 100644 --- a/apps/api/src/payments/funds/constants/stripe.constants.ts +++ b/apps/api/src/payments/funds/constants/stripe.constants.ts @@ -135,12 +135,6 @@ export const StripeDeclineCode = { AUTHENTICATION_REQUIRED: 'authentication_required', } as const; -export const StripeDisputeStatus = { - WON: 'won', - LOST: 'lost', - UNDER_REVIEW: 'under_review', -} as const; - export const StripePaymentIntentStatus = { SUCCEEDED: 'succeeded', REQUIRES_ACTION: 'requires_action', diff --git a/apps/edge/internal/edge/edgetest/tunnels.go b/apps/edge/internal/edge/edgetest/tunnels.go index a64db74..4d183b9 100644 --- a/apps/edge/internal/edge/edgetest/tunnels.go +++ b/apps/edge/internal/edge/edgetest/tunnels.go @@ -32,7 +32,6 @@ var _ edge.Tunnels = (*Tunnels)(nil) type Tunnels struct { mu sync.Mutex backends map[uuid.UUID]string - opened int dialer net.Dialer changes chan struct{} } @@ -60,9 +59,6 @@ func (tunnels *Tunnels) OpenStream( ) (transport.Stream, error) { tunnels.mu.Lock() backend, found := tunnels.backends[route.BackendID] - if found { - tunnels.opened++ - } tunnels.mu.Unlock() if !found { return nil, tunnel.ErrMachineUnavailable @@ -70,12 +66,6 @@ func (tunnels *Tunnels) OpenStream( return tunnels.dialer.DialContext(ctx, tcpNetwork, backend) } -func (tunnels *Tunnels) OpenedStreams() int { - tunnels.mu.Lock() - defer tunnels.mu.Unlock() - return tunnels.opened -} - func (tunnels *Tunnels) BackendLoad(route routes.Base) (int64, bool) { tunnels.mu.Lock() defer tunnels.mu.Unlock() diff --git a/apps/guest/.gitignore b/apps/guest/.gitignore index 2191add..f8f2faf 100644 --- a/apps/guest/.gitignore +++ b/apps/guest/.gitignore @@ -1,2 +1 @@ internal/terminal/static -!internal/terminal/static/index.html diff --git a/apps/marketing/Dockerfile b/apps/marketing/Dockerfile index d3c6b1b..ef722f7 100644 --- a/apps/marketing/Dockerfile +++ b/apps/marketing/Dockerfile @@ -22,7 +22,7 @@ COPY apps/marketing/package.json apps/marketing/package.json RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm install --filter @snapflow/marketing... --frozen-lockfile --ignore-scripts \ - && pnpm rebuild @parcel/watcher esbuild sharp + && pnpm rebuild esbuild sharp COPY apps/marketing apps/marketing diff --git a/apps/snapflow/internal/sshgateway/service.go b/apps/snapflow/internal/sshgateway/service.go index eb32a7e..0250f2b 100644 --- a/apps/snapflow/internal/sshgateway/service.go +++ b/apps/snapflow/internal/sshgateway/service.go @@ -92,10 +92,6 @@ func New(cfg Config, deps Dependencies, opts ...Option) (*Service, error) { return service, nil } -func (s *Service) GetPort() int { - return s.port -} - func (s *Service) PublicHostKey() string { return s.hostKey } diff --git a/biome.json b/biome.json index f1d9cca..b7ac086 100644 --- a/biome.json +++ b/biome.json @@ -9,9 +9,6 @@ "files": { "includes": [ "**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,json,jsonc,css,html,astro,svelte}", - "!**/*.generated.*", - "!**/src/generated", - "!**/src/**/generated", "!libs/*api-client*", "!libs/machine-proto/gen", "!apps/api/src/migrations/*-migration.ts", @@ -554,8 +551,6 @@ }, { "includes": [ - "apps/api/src/config/environment/registry.environment.ts", - "apps/api/src/config/environment/utils/read-environment.util.ts", "apps/api/src/config/configuration.config.ts", "apps/api/src/config/utils/current-process.util.ts", "apps/api/src/migrations/**", diff --git a/deploy/charts/snapflow/ci/production-values.yaml b/deploy/charts/snapflow/ci/production-values.yaml index 7538154..b48443c 100644 --- a/deploy/charts/snapflow/ci/production-values.yaml +++ b/deploy/charts/snapflow/ci/production-values.yaml @@ -8,19 +8,19 @@ email: from: Snapflow images: api: - tag: main-0123456789abcdef0123456789abcdef01234567-1760000000 + tag: build-0123456789abcdef0123456789abcdef01234567-1760000000 digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef dashboard: - tag: main-0123456789abcdef0123456789abcdef01234567-1760000000 + tag: build-0123456789abcdef0123456789abcdef01234567-1760000000 digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef marketing: - tag: main-0123456789abcdef0123456789abcdef01234567-1760000000 + tag: build-0123456789abcdef0123456789abcdef01234567-1760000000 digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef snapshotManager: - tag: main-0123456789abcdef0123456789abcdef01234567-1760000000 + tag: build-0123456789abcdef0123456789abcdef01234567-1760000000 digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef edge: - tag: main-0123456789abcdef0123456789abcdef01234567-1760000000 + tag: build-0123456789abcdef0123456789abcdef01234567-1760000000 digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef edges: - name: home diff --git a/libs/microvm-go/imagecache/platform.go b/libs/microvm-go/imagecache/platform.go index d319a3b..daf3ffe 100644 --- a/libs/microvm-go/imagecache/platform.go +++ b/libs/microvm-go/imagecache/platform.go @@ -131,12 +131,6 @@ func (p Platform) Matches(other Platform) bool { return p.OS == other.OS && p.Architecture == other.Architecture } -func (p Platform) RequiresHostEmulation() bool { - p = p.Normalize() - host := HostPlatform() - return p.OS == host.OS && p.Architecture != host.Architecture -} - func (p Platform) OCI() ocispec.Platform { return ocispec.Platform{ OS: p.OS, diff --git a/libs/microvm-go/internal/lifecycle/coordinator.go b/libs/microvm-go/internal/lifecycle/coordinator.go index 7d730c2..a0a4788 100644 --- a/libs/microvm-go/internal/lifecycle/coordinator.go +++ b/libs/microvm-go/internal/lifecycle/coordinator.go @@ -65,10 +65,6 @@ func (c *Coordinator) ReadLock(ctx context.Context, key string) (*Guard, error) return c.lockMany(ctx, readLock, key) } -func (c *Coordinator) ReadLockMany(ctx context.Context, keys ...string) (*Guard, error) { - return c.lockMany(ctx, readLock, keys...) -} - func (c *Coordinator) Lock(ctx context.Context, key string) (*Guard, error) { return c.lockMany(ctx, writeLock, key) } diff --git a/libs/microvm-go/internal/runtime/backend.go b/libs/microvm-go/internal/runtime/backend.go index d1a01aa..0657015 100644 --- a/libs/microvm-go/internal/runtime/backend.go +++ b/libs/microvm-go/internal/runtime/backend.go @@ -56,10 +56,8 @@ type Config struct { InitrdPath string MkfsErofsPath string VMMAssets vmm.HostAssets - VolumeBlockStore volumes.BlockStore Builds builds.Config Transfers transferpolicy.Policy - GPUDevices devices.GPUManager GPUQuarantine *devices.GPUQuarantine Capacity func() (admission.Resources, error) GuestBundles []guest.Bundle @@ -156,9 +154,9 @@ func NewBackend(config Config) (*Backend, error) { vmmBinaries: vmmBinaries, networks: networkManager, networkConfig: networkConfig, - gpuDevices: defaultGPUManager(config.GPUDevices, config.HostGPU, gpuQuarantine(config)), + gpuDevices: defaultGPUManager(config.HostGPU, gpuQuarantine(config)), snapshots: snapshot.NewStore(config.Paths), - volumes: volumeManager(config.Paths, config.VolumeBlockStore), + volumes: volumes.NewManager(config.Paths), memory: memoryPolicy, lifecycle: lifecycle.NewCoordinator(), pendingGPU: make(map[string][]string), @@ -245,13 +243,6 @@ func (b *Backend) Recover(ctx context.Context) error { return nil } -func volumeManager(paths layout.Paths, store volumes.BlockStore) *volumes.Manager { - if store == nil { - return volumes.NewManager(paths) - } - return volumes.NewManager(paths, volumes.WithBlockStore(store)) -} - func cloneRegistry(registry *types.Registry) *types.Registry { if registry == nil { return nil @@ -264,10 +255,7 @@ func cloneRegistry(registry *types.Registry) *types.Registry { return &cloned } -func defaultGPUManager(manager devices.GPUManager, gpuInfo host.HostGPUInfo, quarantine *devices.GPUQuarantine) devices.GPUManager { - if manager != nil { - return manager - } +func defaultGPUManager(gpuInfo host.HostGPUInfo, quarantine *devices.GPUQuarantine) devices.GPUManager { inventory := make([]devices.GPUInventoryDevice, 0, len(gpuInfo.Devices)) for _, device := range gpuInfo.Devices { inventory = append(inventory, devices.GPUInventoryDevice{ diff --git a/libs/microvm-go/internal/runtime/fork.go b/libs/microvm-go/internal/runtime/fork.go index 385aeab..230c589 100644 --- a/libs/microvm-go/internal/runtime/fork.go +++ b/libs/microvm-go/internal/runtime/fork.go @@ -26,14 +26,12 @@ import ( "github.com/google/uuid" mvmerrors "github.com/snapflowio/microvm-go/internal/errors" "github.com/snapflowio/microvm-go/internal/lifecycle" - "github.com/snapflowio/microvm-go/internal/snapshot" "github.com/snapflowio/microvm-go/internal/state" "github.com/snapflowio/microvm-go/types" ) type forkSource struct { - metadata state.InstanceMetadata - snapshotRecord *snapshot.Record + metadata state.InstanceMetadata } type forkOptions struct { @@ -79,7 +77,7 @@ func (b *Backend) forkWithOptions(ctx context.Context, instanceID string, reques } func (b *Backend) forkLocked(ctx context.Context, instanceID string, request types.ForkRequest, options forkOptions) (forkID string, returnErr error) { - sourceData, err := b.loadForkSource(instanceID, request.SnapshotID) + sourceData, err := b.loadForkSource(instanceID) if err != nil { return "", err } @@ -132,20 +130,16 @@ func validateForkRequest(instanceID string, sourceData forkSource) error { if err := validateRequestedTransition(lifecycle.OperationFork, source.State); err != nil { return err } - if sourceData.snapshotRecord == nil { - if err := validateForkSource(source); err != nil { - return err - } + if err := validateForkSource(source); err != nil { + return err } if source.ID != instanceID { return stateConflict("snapshot source does not match the requested instance", "MICROVM_SNAPSHOT_SOURCE_MISMATCH") } - if forkCapturesMemory(source, sourceData.snapshotRecord) { - if err := validateMemorySnapshotPolicy(source); err != nil { - return err - } + if forkCapturesMemory(source) { + return validateMemorySnapshotPolicy(source) } - return validateForkSnapshotKind(sourceData.snapshotRecord) + return nil } func (b *Backend) reserveFork( @@ -165,9 +159,6 @@ func (b *Backend) reserveFork( } func (b *Backend) prepareForkSnapshotSource(ctx context.Context, sourceData forkSource) (bool, *forkSourceRestorer, error) { - if sourceData.snapshotRecord != nil { - return sourceData.snapshotRecord.Snapshot.Kind == snapshot.KindStandby, &forkSourceRestorer{}, nil - } fastFork, restoreSource, err := b.prepareFastForkSource(ctx, sourceData.metadata) if err != nil { return false, nil, err @@ -181,7 +172,7 @@ func (b *Backend) materializeForkTarget(transaction *lifecycle.Transaction, options forkOptions, ) error { targetDir := b.paths.InstanceDir(forkID) - if err := b.materializeForkSourceWithOptions(sourceData, targetDir, forkID, options); err != nil { + if err := b.materializeForkSourceWithOptions(sourceData, targetDir, options); err != nil { return errors.Join( fmt.Errorf("materialize instance fork source: %w", err), wrapOptionalError("remove partial fork target", os.RemoveAll(targetDir)), @@ -324,9 +315,6 @@ func (b *Backend) reconcileExistingFork(ctx context.Context, sourceInstanceID st return request.ID, nil } -func forkCapturesMemory(source state.InstanceMetadata, record *snapshot.Record) bool { - if record != nil { - return record.Snapshot.Kind == snapshot.KindStandby - } +func forkCapturesMemory(source state.InstanceMetadata) bool { return source.State == types.RuntimeStateStarted || source.State == types.RuntimeStateStandby } diff --git a/libs/microvm-go/internal/runtime/fork_source.go b/libs/microvm-go/internal/runtime/fork_source.go index fcf9c0d..de29c27 100644 --- a/libs/microvm-go/internal/runtime/fork_source.go +++ b/libs/microvm-go/internal/runtime/fork_source.go @@ -16,82 +16,31 @@ package runtime import ( "context" - "encoding/json" "errors" "fmt" "os" "path/filepath" - mvmerrors "github.com/snapflowio/microvm-go/internal/errors" - "github.com/snapflowio/microvm-go/internal/filemode" "github.com/snapflowio/microvm-go/internal/forkvm" - "github.com/snapflowio/microvm-go/internal/snapshot" "github.com/snapflowio/microvm-go/internal/state" "github.com/snapflowio/microvm-go/types" ) -func (b *Backend) loadForkSource(instanceID, snapshotID string) (forkSource, error) { - if snapshotID == "" { - metadata, err := b.readInstance(instanceID) - return forkSource{metadata: metadata}, err - } - record, err := b.snapshots.LoadRecord(snapshotID) - if err != nil { - if errors.Is(err, snapshot.ErrNotFound) { - return forkSource{}, mvmerrors.NewNotFound(fmt.Errorf("snapshot %s", snapshotID)) - } - return forkSource{}, err - } - if record.Snapshot.SourceInstanceID != instanceID { - return forkSource{}, stateConflict("snapshot source does not match the requested instance", "MICROVM_SNAPSHOT_SOURCE_MISMATCH") - } - var metadata state.InstanceMetadata - if err := json.Unmarshal(record.StoredMetadata, &metadata); err != nil { - return forkSource{}, fmt.Errorf("decode snapshot source metadata: %w", err) - } - if metadata.Hypervisor != record.Snapshot.SourceHypervisor { - return forkSource{}, stateConflict("snapshot hypervisor metadata is inconsistent", "MICROVM_SNAPSHOT_HYPERVISOR_MISMATCH") - } - return forkSource{metadata: metadata, snapshotRecord: &record}, nil +func (b *Backend) loadForkSource(instanceID string) (forkSource, error) { + metadata, err := b.readInstance(instanceID) + return forkSource{metadata: metadata}, err } -func validateForkSnapshotKind(record *snapshot.Record) error { - if record == nil { - return nil - } - switch record.Snapshot.Kind { - case snapshot.KindStopped, snapshot.KindStandby: - return nil - default: - return stateConflict("snapshot kind cannot be forked", "MICROVM_SNAPSHOT_KIND_UNSUPPORTED") - } -} - -func (b *Backend) materializeForkSourceWithOptions(source forkSource, targetDir, targetID string, options forkOptions) error { - if source.snapshotRecord == nil { - copyOptions := forkvm.CopyOptions{ - SkipRelativePaths: map[string]struct{}{ - filepath.Base(b.paths.InstanceMetadata(source.metadata.ID)): {}, - }, - } - if options.shareSnapshotMemory { - copyOptions.HardlinkRelativePaths = map[string]struct{}{templateSnapshotMemoryRelativePath(): {}} - } - return forkvm.CopyDirWithOptions(b.paths.InstanceDir(source.metadata.ID), targetDir, copyOptions) - } - if err := os.MkdirAll(targetDir, filemode.SharedDirectory); err != nil { - return fmt.Errorf("create fork target directory: %w", err) - } - payloadDir := b.paths.SnapshotPayloadDir(source.snapshotRecord.Snapshot.ID) - if err := forkvm.CopyFile(filepath.Join(payloadDir, "overlay.raw"), b.paths.InstanceOverlay(targetID)); err != nil { - return fmt.Errorf("copy snapshot overlay: %w", err) +func (b *Backend) materializeForkSourceWithOptions(source forkSource, targetDir string, options forkOptions) error { + copyOptions := forkvm.CopyOptions{ + SkipRelativePaths: map[string]struct{}{ + filepath.Base(b.paths.InstanceMetadata(source.metadata.ID)): {}, + }, } - if source.snapshotRecord.Snapshot.Kind == snapshot.KindStandby { - if err := b.snapshots.MaterializeVMPayload(source.snapshotRecord.Snapshot.ID, b.paths.InstanceLatestSnapshot(targetID)); err != nil { - return fmt.Errorf("materialize snapshot VM state: %w", err) - } + if options.shareSnapshotMemory { + copyOptions.HardlinkRelativePaths = map[string]struct{}{templateSnapshotMemoryRelativePath(): {}} } - return nil + return forkvm.CopyDirWithOptions(b.paths.InstanceDir(source.metadata.ID), targetDir, copyOptions) } func validateForkSource(source state.InstanceMetadata) error { diff --git a/libs/microvm-go/internal/runtime/fork_test.go b/libs/microvm-go/internal/runtime/fork_test.go index cd9742a..ed81fca 100644 --- a/libs/microvm-go/internal/runtime/fork_test.go +++ b/libs/microvm-go/internal/runtime/fork_test.go @@ -16,12 +16,10 @@ package runtime import ( "encoding/json" - "os" "path/filepath" "testing" "github.com/snapflowio/microvm-go/devices" - "github.com/snapflowio/microvm-go/hypervisor" "github.com/snapflowio/microvm-go/internal/snapshot" "github.com/snapflowio/microvm-go/internal/state" "github.com/snapflowio/microvm-go/layout" @@ -50,62 +48,6 @@ func TestValidateForkSourceRequiresQuiescedInstance(t *testing.T) { } } -func TestLoadForkSourceRejectsInconsistentSnapshotProvenance(t *testing.T) { - paths := layout.NewPaths(t.TempDir()) - store := snapshot.NewStore(paths) - storedMetadata, err := json.Marshal(state.InstanceMetadata{ - ID: "source", - Hypervisor: hypervisor.HypervisorFirecracker, - }) - if err != nil { - t.Fatal(err) - } - if err := store.SaveRecord(snapshot.Record{ - Snapshot: snapshot.Snapshot{ - ID: "snapshot-1", - SourceInstanceID: "different-source", - SourceHypervisor: hypervisor.HypervisorFirecracker, - }, - StoredMetadata: storedMetadata, - }); err != nil { - t.Fatal(err) - } - backend := &Backend{paths: paths, snapshots: store} - if _, err := backend.loadForkSource("source", "snapshot-1"); err == nil { - t.Fatal("expected inconsistent snapshot source to be rejected") - } -} - -func TestMaterializeForkSourceUsesImmutableSnapshotPayload(t *testing.T) { - paths := layout.NewPaths(t.TempDir()) - backend := &Backend{paths: paths, snapshots: snapshot.NewStore(paths)} - snapshotID := "snapshot-1" - payloadDir := paths.SnapshotPayloadDir(snapshotID) - if err := os.MkdirAll(filepath.Join(payloadDir, "vm"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(payloadDir, "overlay.raw"), []byte("snapshot-overlay"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(payloadDir, "vm", "state"), []byte("snapshot-state"), 0o644); err != nil { - t.Fatal(err) - } - - source := forkSource{ - metadata: state.InstanceMetadata{ID: "source"}, - snapshotRecord: &snapshot.Record{Snapshot: snapshot.Snapshot{ - ID: snapshotID, - Kind: snapshot.KindStandby, - }}, - } - if err := backend.materializeForkSourceWithOptions(source, paths.InstanceDir("target"), "target", forkOptions{}); err != nil { - t.Fatal(err) - } - - assertFileContents(t, paths.InstanceOverlay("target"), "snapshot-overlay") - assertFileContents(t, filepath.Join(paths.InstanceLatestSnapshot("target"), "state"), "snapshot-state") -} - func TestProtectedImageDirsIncludesSnapshotReferences(t *testing.T) { paths := layout.NewPaths(t.TempDir()) store := snapshot.NewStore(paths) @@ -158,17 +100,6 @@ func TestProtectedImageDirsIncludesPlatformBundleReferences(t *testing.T) { } } -func assertFileContents(t *testing.T, path, expected string) { - t.Helper() - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(contents) != expected { - t.Fatalf("%s = %q, want %q", path, contents, expected) - } -} - func TestForkMetadataAndNetworkIdentityAreIndependent(t *testing.T) { source := state.InstanceMetadata{ ID: "source", diff --git a/libs/microvm-go/internal/runtime/snapshot_secret_policy_test.go b/libs/microvm-go/internal/runtime/snapshot_secret_policy_test.go index d014f72..d4803c3 100644 --- a/libs/microvm-go/internal/runtime/snapshot_secret_policy_test.go +++ b/libs/microvm-go/internal/runtime/snapshot_secret_policy_test.go @@ -17,7 +17,6 @@ package runtime import ( "testing" - "github.com/snapflowio/microvm-go/internal/snapshot" "github.com/snapflowio/microvm-go/internal/state" "github.com/snapflowio/microvm-go/types" ) @@ -37,17 +36,14 @@ func TestForkCapturesMemory(t *testing.T) { tests := []struct { name string state types.RuntimeState - record *snapshot.Record expected bool }{ {name: "running source", state: types.RuntimeStateStarted, expected: true}, {name: "stopped source", state: types.RuntimeStateStopped, expected: false}, - {name: "standby snapshot", state: types.RuntimeStateStopped, record: &snapshot.Record{Snapshot: snapshot.Snapshot{Kind: snapshot.KindStandby}}, expected: true}, - {name: "stopped snapshot", state: types.RuntimeStateStarted, record: &snapshot.Record{Snapshot: snapshot.Snapshot{Kind: snapshot.KindStopped}}, expected: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - actual := forkCapturesMemory(state.InstanceMetadata{State: test.state}, test.record) + actual := forkCapturesMemory(state.InstanceMetadata{State: test.state}) if actual != test.expected { t.Fatalf("forkCapturesMemory() = %t, want %t", actual, test.expected) } diff --git a/libs/microvm-go/internal/snapshot/payload.go b/libs/microvm-go/internal/snapshot/payload.go deleted file mode 100644 index 77aafff..0000000 --- a/libs/microvm-go/internal/snapshot/payload.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 Snapflow. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package snapshot - -import ( - "fmt" - "path/filepath" - - "github.com/snapflowio/microvm-go/internal/forkvm" -) - -const vmPayloadDirectory = "vm" - -func (s Store) MaterializeVMPayload(id, targetDir string) error { - if err := forkvm.CopyDir(filepath.Join(s.paths.SnapshotPayloadDir(id), vmPayloadDirectory), targetDir); err != nil { - return fmt.Errorf("copy VM snapshot payload: %w", err) - } - return nil -} diff --git a/libs/microvm-go/internal/snapshot/store.go b/libs/microvm-go/internal/snapshot/store.go index 303a6ea..4dbfe2d 100644 --- a/libs/microvm-go/internal/snapshot/store.go +++ b/libs/microvm-go/internal/snapshot/store.go @@ -46,14 +46,10 @@ func NewStore(paths layout.Paths) Store { } func (s Store) CreateStoppedWithID(id string, source state.InstanceMetadata, name string) (Snapshot, error) { - return s.createFromInstance(id, source, name, KindStopped, "") + return s.createFromInstance(id, source, name, KindStopped) } -func (s Store) CreateStandbyWithID(id string, source state.InstanceMetadata, name, vmSnapshotDir string) (Snapshot, error) { - return s.createFromInstance(id, source, name, KindStandby, vmSnapshotDir) -} - -func (s Store) createFromInstance(id string, source state.InstanceMetadata, name string, kind Kind, vmSnapshotDir string) (created Snapshot, returnErr error) { +func (s Store) createFromInstance(id string, source state.InstanceMetadata, name string, kind Kind) (created Snapshot, returnErr error) { if _, err := uuid.Parse(id); err != nil { return Snapshot{}, fmt.Errorf("invalid snapshot id %q: %w", id, err) } @@ -70,7 +66,7 @@ func (s Store) createFromInstance(id string, source state.InstanceMetadata, name returnErr = errors.Join(returnErr, fmt.Errorf("clean up partial snapshot: %w", cleanupErr)) } }() - if err := s.copySnapshotPayload(payloadDir, source, vmSnapshotDir); err != nil { + if err := s.copySnapshotPayload(payloadDir, source); err != nil { return Snapshot{}, err } size, err := DirectorySize(payloadDir) @@ -98,7 +94,7 @@ func (s Store) createFromInstance(id string, source state.InstanceMetadata, name return snapshot, nil } -func (s Store) copySnapshotPayload(payloadDir string, source state.InstanceMetadata, vmSnapshotDir string) error { +func (s Store) copySnapshotPayload(payloadDir string, source state.InstanceMetadata) error { files := []struct { source string name string @@ -112,11 +108,6 @@ func (s Store) copySnapshotPayload(payloadDir string, source state.InstanceMetad return err } } - if vmSnapshotDir != "" { - if err := forkvm.CopyDir(vmSnapshotDir, filepath.Join(payloadDir, vmPayloadDirectory)); err != nil { - return fmt.Errorf("copy VM snapshot payload: %w", err) - } - } return secureSnapshotTree(payloadDir) } diff --git a/libs/microvm-go/internal/snapshot/types.go b/libs/microvm-go/internal/snapshot/types.go index 83e5d0d..4acc875 100644 --- a/libs/microvm-go/internal/snapshot/types.go +++ b/libs/microvm-go/internal/snapshot/types.go @@ -23,10 +23,7 @@ import ( type Kind string -const ( - KindStopped Kind = "stopped" - KindStandby Kind = "standby" -) +const KindStopped Kind = "stopped" type Snapshot struct { ID string `json:"id"` diff --git a/libs/microvm-go/microvm.go b/libs/microvm-go/microvm.go index a2ba731..90456c3 100644 --- a/libs/microvm-go/microvm.go +++ b/libs/microvm-go/microvm.go @@ -33,7 +33,6 @@ import ( "github.com/snapflowio/microvm-go/layout" "github.com/snapflowio/microvm-go/types" "github.com/snapflowio/microvm-go/vmm" - "github.com/snapflowio/microvm-go/volumes" ) type ( @@ -49,7 +48,6 @@ type ( RuntimeState = types.RuntimeState PreparedBootAssets = microvmruntime.PreparedBootAssets Capacity = admission.Resources - VolumeBlockStore = volumes.BlockStore SandboxCapability = guest.SandboxCapability ) @@ -86,21 +84,6 @@ type Dependencies struct { BuildLogPath func(string) (string, error) } -type Option func(*options) - -type options struct { - volumeBlockStore VolumeBlockStore - gpuManager devices.GPUManager -} - -func WithVolumeBlockStore(store VolumeBlockStore) Option { - return func(o *options) { o.volumeBlockStore = store } -} - -func WithGPUManager(manager devices.GPUManager) Option { - return func(o *options) { o.gpuManager = manager } -} - const ( Firecracker Hypervisor = hypervisor.HypervisorFirecracker CloudHypervisor Hypervisor = hypervisor.HypervisorCloudHypervisor @@ -115,17 +98,11 @@ const ( SandboxCapabilityDesktop SandboxCapability = guest.SandboxCapabilityDesktop ) -func New(config Config, dependencies Dependencies, opts ...Option) (*Runtime, error) { +func New(config Config, dependencies Dependencies) (*Runtime, error) { runtimeConfig, err := runtimeConfiguration(config, dependencies) if err != nil { return nil, err } - applied := options{} - for _, option := range opts { - option(&applied) - } - runtimeConfig.VolumeBlockStore = applied.volumeBlockStore - runtimeConfig.GPUDevices = applied.gpuManager backend, err := microvmruntime.NewBackend(runtimeConfig) if err != nil { return nil, err diff --git a/libs/microvm-go/types/requests.go b/libs/microvm-go/types/requests.go index b71a659..4d2039f 100644 --- a/libs/microvm-go/types/requests.go +++ b/libs/microvm-go/types/requests.go @@ -145,7 +145,6 @@ type ExportImageRequest struct { type ForkRequest struct { ID string `json:"id" validate:"required,uuid"` - SnapshotID string `json:"snapshotId,omitempty" validate:"omitempty,uuid"` Start bool `json:"start,omitempty"` SecretEnv map[string]string `json:"secretEnv,omitempty"` AssignmentGeneration int `json:"assignmentGeneration" validate:"min=1"` diff --git a/package.json b/package.json index 8718e4a..e6585f5 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "lint-staged": "^17.0.3", "node-gyp": "catalog:", "t3": "catalog:", - "tsx": "catalog:", "typescript": "catalog:" }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 261dfce..72348d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,9 +297,6 @@ catalogs: tsconfig-paths-webpack-plugin: specifier: 4.2.0 version: 4.2.0 - tsx: - specifier: ^4.20.6 - version: 4.23.12 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -328,20 +325,14 @@ overrides: brace-expansion@>=2.0.0 <2.1.4: 2.1.4 brace-expansion@>=3.0.0 <3.0.6: 3.0.6 brace-expansion@>=5.0.0 <5.0.12: 5.0.12 - css-loader@^6.4.0: 7.1.4 devalue@<5.9.3: 5.9.3 - dompurify@<3.4.13: 3.4.13 - '@electron/rebuild@<4.0.0': 4.2.0 engine.io@>=6.6.0 <6.6.10: 6.6.10 fast-copy@>=4.0.0 <4.1.0: 4.1.0 fast-uri@>=3.0.0 <3.1.9: 3.1.9 glob@>=13.0.0 <14.0.2: 14.0.2 '@grpc/grpc-js@>=1.14.0 <1.14.5': 1.14.5 - hono@<4.12.34: 4.13.0 - ip-address@<=10.3.0: 10.4.0 js-yaml@>=4.0.0 <4.3.2: 4.3.2 js-yaml@>=5.0.0 <5.2.1: 5.2.1 - less@<4.8.1: 4.8.1 minimatch@>=10.0.0 <11.0.1: 11.0.1 multer@<2.5.0: 2.5.0 nanoid@>=3.0.0 <3.3.18: 3.3.18 @@ -401,9 +392,6 @@ importers: t3: specifier: 'catalog:' version: 0.0.31(@shikijs/themes@4.5.0)(ioredis@5.11.1(supports-color@8.1.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.6) - tsx: - specifier: 'catalog:' - version: 4.23.12 typescript: specifier: 'catalog:' version: 6.0.3 @@ -427,13 +415,13 @@ importers: version: 3.1121.0 '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6)) '@better-auth/core': specifier: 'catalog:' version: 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/passkey': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2) '@better-auth/utils': specifier: 'catalog:' version: 0.4.2 @@ -553,7 +541,7 @@ importers: version: 5.1.0(supports-color@8.1.1) better-auth: specifier: 'catalog:' - version: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) better-call: specifier: 'catalog:' version: 1.4.0(zod@4.6.6) @@ -678,9 +666,6 @@ importers: tsconfig-paths-webpack-plugin: specifier: 'catalog:' version: 4.2.0 - tsx: - specifier: 'catalog:' - version: 4.23.12 typescript: specifier: 'catalog:' version: 6.0.3 @@ -692,10 +677,10 @@ importers: dependencies: '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6)) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6)) '@better-auth/passkey': specifier: 'catalog:' - version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2) + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2) '@internationalized/date': specifier: 'catalog:' version: 3.12.4 @@ -737,10 +722,10 @@ importers: version: 3.13.47(svelte@5.57.2) better-auth: specifier: 'catalog:' - version: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) bits-ui: specifier: 'catalog:' - version: 2.19.5(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2) + version: 2.19.5(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -773,7 +758,7 @@ importers: version: 11.1.2 runed: specifier: 'catalog:' - version: 0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(zod@4.6.6) + version: 0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(zod@4.6.6) shiki: specifier: 'catalog:' version: 4.5.0 @@ -798,16 +783,16 @@ importers: devDependencies: '@sveltejs/adapter-static': specifier: 'catalog:' - version: 4.0.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))) + version: 4.0.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))) '@sveltejs/kit': specifier: 'catalog:' - version: 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: 'catalog:' - version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.3.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@types/hast': specifier: 'catalog:' version: 3.0.5 @@ -828,13 +813,13 @@ importers: version: 6.0.3 vite: specifier: 8.3.4 - version: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + version: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) apps/marketing: dependencies: '@astrojs/react': specifier: 'catalog:' - version: 7.0.0(@types/node@24.13.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + version: 7.0.0(@types/node@24.13.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(terser@5.51.2)(yaml@2.9.0) '@astrojs/sitemap': specifier: 'catalog:' version: 3.7.4 @@ -843,7 +828,7 @@ importers: version: 9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1) astro: specifier: 'catalog:' - version: 7.3.5(@types/node@24.13.3)(ioredis@5.11.1(supports-color@8.1.1))(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + version: 7.3.5(@types/node@24.13.3)(ioredis@5.11.1(supports-color@8.1.1))(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) cookie: specifier: 2.0.1 version: 2.0.1 @@ -862,7 +847,7 @@ importers: version: 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.3.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@types/react': specifier: 'catalog:' version: 19.2.18 @@ -898,7 +883,7 @@ importers: devDependencies: better-auth: specifier: 'catalog:' - version: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -959,7 +944,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) libs/toolbox-api-client: devDependencies: @@ -4772,10 +4757,6 @@ packages: resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.24.5: - resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.26.0: resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} @@ -6993,11 +6974,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} - engines: {node: '>=18.0.0'} - hasBin: true - tsyringe@4.10.0: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} @@ -7217,7 +7193,7 @@ packages: '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' - less: 4.8.1 + less: ^4.0.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -7791,17 +7767,17 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@7.0.0(@types/node@24.13.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)': + '@astrojs/react@7.0.0(@types/node@24.13.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(terser@5.51.2)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.11.0 '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@vitejs/plugin-react': 6.1.1(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitejs/plugin-react': 6.1.1(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) devalue: 5.9.4 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) ultrahtml: 1.7.0 - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@rolldown/plugin-babel' - '@types/node' @@ -8121,19 +8097,19 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))': + '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - better-auth: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + better-auth: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) better-call: 1.4.0(zod@4.6.6) zod: 4.6.6 - '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))': + '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - better-auth: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + better-auth: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) better-call: 1.4.0(zod@4.6.6) zod: 4.6.6 @@ -8173,26 +8149,26 @@ snapshots: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2)': + '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2)': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.2 - better-auth: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + better-auth: 1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) better-call: 1.4.0(zod@4.6.6) nanostores: 1.5.2 zod: 4.6.6 - '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2)': + '@better-auth/passkey@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(better-call@1.4.0(zod@4.6.6))(nanostores@1.5.2)': dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.2 - better-auth: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + better-auth: 1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) better-call: 1.4.0(zod@4.6.6) nanostores: 1.5.2 zod: 4.6.6 @@ -10134,15 +10110,15 @@ snapshots: dependencies: acorn: 8.18.0 - '@sveltejs/adapter-static@4.0.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))': + '@sveltejs/adapter-static@4.0.0(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))': dependencies: - '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) - '@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 '@standard-schema/spec': 1.1.0 - '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) cookie: 2.0.1 devalue: 5.9.4 esm-env: 1.2.2 @@ -10150,21 +10126,21 @@ snapshots: mrmime: 2.0.1 sirv: 3.0.2 svelte: 5.57.2 - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) optionalDependencies: '@opentelemetry/api': 1.9.1 typescript: 6.0.3 '@sveltejs/load-config@0.2.3': {} - '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 magic-string: 1.4.1 obug: 2.1.4 svelte: 5.57.2 - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@swc/helpers@0.5.23': dependencies: @@ -10231,19 +10207,19 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) - '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) '@tanstack/devtools-event-client@0.4.4': {} @@ -10494,10 +10470,10 @@ snapshots: '@ungap/structured-clone@1.3.3': {} - '@vitejs/plugin-react@6.1.1(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.1(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) '@vitest/expect@4.1.12': dependencies: @@ -10508,21 +10484,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) - '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) optional: true '@vitest/pretty-format@4.1.12': @@ -10805,7 +10781,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.3.5(@types/node@24.13.3)(ioredis@5.11.1(supports-color@8.1.1))(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + astro@7.3.5(@types/node@24.13.3)(ioredis@5.11.1(supports-color@8.1.1))(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.5.1 '@astrojs/internal-helpers': 0.11.0 @@ -10855,8 +10831,8 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(ioredis@5.11.1(supports-color@8.1.1)) verkit: 0.4.1 - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.6.6 @@ -10913,7 +10889,7 @@ snapshots: baseline-browser-mapping@2.11.18: {} - better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + better-auth@1.7.7(@opentelemetry/api@1.9.1)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) @@ -10933,17 +10909,17 @@ snapshots: nanostores: 1.5.2 zod: 4.6.6 optionalDependencies: - '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) next: 16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) pg: 8.23.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) svelte: 5.57.2 - vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/api' - better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) @@ -10968,11 +10944,11 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) svelte: 5.57.2 - vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/api' - better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + better-auth@1.7.7(@opentelemetry/api@1.9.1)(next@16.4.1(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.57.2)(vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.6))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) @@ -10997,7 +10973,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) svelte: 5.57.2 - vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vitest: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/api' @@ -11010,15 +10986,15 @@ snapshots: optionalDependencies: zod: 4.6.6 - bits-ui@2.19.5(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2): + bits-ui@2.19.5(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2): dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/dom': 1.8.0 '@internationalized/date': 3.12.4 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2) + runed: 0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2) svelte: 5.57.2 - svelte-toolbelt: 0.10.6(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2) + svelte-toolbelt: 0.10.6(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2) tabbable: 6.5.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -11553,11 +11529,6 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.24.5: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 @@ -13453,23 +13424,23 @@ snapshots: esm-env: 1.2.2 svelte: 5.57.2 - runed@0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2): + runed@0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.57.2 optionalDependencies: - '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) - runed@0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(zod@4.6.6): + runed@0.37.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(zod@4.6.6): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.57.2 optionalDependencies: - '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/kit': 3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) zod: 4.6.6 rxjs@7.8.2: @@ -13872,10 +13843,10 @@ snapshots: transitivePeerDependencies: - picomatch - svelte-toolbelt@0.10.6(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2): + svelte-toolbelt@0.10.6(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.57.2) + runed: 0.35.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)))(svelte@5.57.2) style-to-object: 1.0.14 svelte: 5.57.2 transitivePeerDependencies: @@ -14033,7 +14004,7 @@ snapshots: tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.24.5 + enhanced-resolve: 5.26.0 tapable: 2.3.3 tsconfig-paths: 4.2.0 @@ -14049,12 +14020,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.12: - dependencies: - esbuild: 0.28.2 - optionalDependencies: - fsevents: 2.3.3 - tsyringe@4.10.0: dependencies: tslib: 1.14.1 @@ -14200,7 +14165,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -14213,10 +14178,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.51.2 - tsx: 4.23.12 yaml: 2.9.0 - vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -14229,21 +14193,20 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.51.2 - tsx: 4.23.12 yaml: 2.9.0 - vitefu@1.1.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): optionalDependencies: - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) - vitefu@1.1.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): optionalDependencies: - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) - vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0): dependencies: '@vitest/expect': 4.1.12 - '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.12 '@vitest/runner': 4.1.12 '@vitest/snapshot': 4.1.12 @@ -14260,7 +14223,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -14279,10 +14242,10 @@ snapshots: - tsx - yaml - vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0): + vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0): dependencies: '@vitest/expect': 4.1.12 - '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.12 '@vitest/runner': 4.1.12 '@vitest/snapshot': 4.1.12 @@ -14299,7 +14262,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 005b885..7f3d0eb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,20 +25,14 @@ overrides: 'brace-expansion@>=2.0.0 <2.1.4': 2.1.4 'brace-expansion@>=3.0.0 <3.0.6': 3.0.6 'brace-expansion@>=5.0.0 <5.0.12': 5.0.12 - 'css-loader@^6.4.0': 7.1.4 'devalue@<5.9.3': 5.9.3 - 'dompurify@<3.4.13': 3.4.13 - '@electron/rebuild@<4.0.0': 4.2.0 'engine.io@>=6.6.0 <6.6.10': 6.6.10 'fast-copy@>=4.0.0 <4.1.0': 4.1.0 'fast-uri@>=3.0.0 <3.1.9': 3.1.9 'glob@>=13.0.0 <14.0.2': 14.0.2 '@grpc/grpc-js@>=1.14.0 <1.14.5': 1.14.5 - 'hono@<4.12.34': 4.13.0 - 'ip-address@<=10.3.0': 10.4.0 'js-yaml@>=4.0.0 <4.3.2': 4.3.2 'js-yaml@>=5.0.0 <5.2.1': 5.2.1 - 'less@<4.8.1': 4.8.1 'minimatch@>=10.0.0 <11.0.1': 11.0.1 'multer@<2.5.0': 2.5.0 'nanoid@>=3.0.0 <3.3.18': 3.3.18 @@ -62,24 +56,12 @@ overrides: 'ws@>=8.0.0 <8.21.0': 8.21.0 allowBuilds: - '@parcel/watcher': true '@scarf/scarf': false - '@swc/core': true - bufferutil: false - core-js: false - cpu-features: false esbuild: true - electron: true - electron-winstaller: true - less: false msgpackr-extract: true node-pty: true protobufjs: false sharp: true - ssh2: false - sqlite3: true - unrs-resolver: true - utf-8-validate: false minimumReleaseAgeExclude: - '@mikro-orm/cli@7.2.0' @@ -183,7 +165,6 @@ catalog: tailwindcss: ^4.3.3 tw-animate-css: ^1.4.0 three: ^0.185.1 - tsx: ^4.20.6 typescript: ~6.0.3 vite: 8.3.4 vitest: ^4.1.12 diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh index cadf590..390142c 100644 --- a/scripts/cleanup.sh +++ b/scripts/cleanup.sh @@ -101,7 +101,6 @@ add_entry "./tmp" add_entry "./core" add_entry "./.pnpm-store" add_entry "./.moon/cache" -add_entry "./.moon/docker" PRUNE_PATHS=( -path './.git' -o From d92539f3d2632aa172134477f8e000a20ff7073c Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 22:55:00 -0700 Subject: [PATCH 5/7] fix(api): restore the executable bit on the GeoIP download script (#19) The api image runs scripts/download-geoip-database.sh directly; it lost its executable mode in #16, so the image build failed with permission denied. --- scripts/download-geoip-database.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/download-geoip-database.sh diff --git a/scripts/download-geoip-database.sh b/scripts/download-geoip-database.sh old mode 100644 new mode 100755 From 4e2eba8a3a3070a0ff8f62f97ab2b1097930e1ee Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 23:00:28 -0700 Subject: [PATCH 6/7] ci: rebuild the api image when the GeoIP download script changes (#20) The api Dockerfile copies scripts/download-geoip-database.sh, but the image path filter did not list it, so a change to it never rebuilt the image. --- .github/filters/images.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/filters/images.yml b/.github/filters/images.yml index 8ee9a17..c60f4ab 100644 --- a/.github/filters/images.yml +++ b/.github/filters/images.yml @@ -34,6 +34,7 @@ api: - "libs/machine-proto/**" - "libs/toolbox-api-client/**" - "scripts/build-provenance.mts" + - "scripts/download-geoip-database.sh" - "docker-bake.hcl" - "deploy/scripts/image.sh" - ".moon/tasks/deployable.yml" From b12a4eb250299c8555ca2c57035aeef95e6904bb Mon Sep 17 00:00:00 2001 From: Bryan Lawless Date: Wed, 4 Nov 2026 23:00:36 -0700 Subject: [PATCH 7/7] ci: merge pull requests directly into main and retire dev Every pull request is now squash-merged into main, so each commit on main is one described change instead of a promotion or sync merge. Pushes to main run CI, publish the changed images and run release-please; Flux image automation pins the new digests on main (its pin commits skip CI). The dev workflow, sync-dev and the dev ruleset are removed, the main ruleset allows squash merges only with the automation App allowed to push pins, and CI no longer restricts which branches may target main. Renovate, the PR template, image.sh and CONTRIBUTING follow the new flow. --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/filters/ci.yml | 6 +- .github/renovate.json5 | 4 +- .github/rulesets/dev.json | 48 --------------- .github/rulesets/main.json | 7 ++- .github/workflows/ci.yml | 17 +----- .github/workflows/codeql.yml | 2 +- .github/workflows/dev.yml | 38 ------------ .github/workflows/images.yml | 4 +- .github/workflows/isolation.yml | 2 +- .github/workflows/main.yml | 18 ++++++ .github/workflows/security-audit.yml | 2 +- .github/workflows/sync-dev.yml | 89 ---------------------------- .moon/tasks/deployable.yml | 2 +- CONTRIBUTING.md | 32 ++++------ deploy/images/images.yaml | 12 ++-- deploy/scripts/image.sh | 4 +- 17 files changed, 59 insertions(+), 232 deletions(-) delete mode 100644 .github/rulesets/dev.json delete mode 100644 .github/workflows/dev.yml delete mode 100644 .github/workflows/sync-dev.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c0450a..f50ec92 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,8 +13,8 @@ ## Checklist -- [ ] PR targets `dev` (`main` only takes promotions from `dev`) -- [ ] Title follows [Conventional Commits](https://www.conventionalcommits.org/) (mostly lowercase) +- [ ] PR targets `main` +- [ ] Title follows [Conventional Commits](https://www.conventionalcommits.org/) (mostly lowercase); it becomes the squashed commit's subject - [ ] I can explain every change in this diff - [ ] Tests added or updated if needed - [ ] CI is passing diff --git a/.github/filters/ci.yml b/.github/filters/ci.yml index cf1d3b5..4e891fb 100644 --- a/.github/filters/ci.yml +++ b/.github/filters/ci.yml @@ -1,6 +1,6 @@ -# Which CI suites a pull request needs, by the files it changes. Pushes to dev -# and main, manual runs and the main workflow always run every suite. A change -# under .github/ runs every suite, because it can change how any of them run. +# Which CI suites a pull request needs, by the files it changes. Pushes to +# main and manual runs always run every suite. A change under .github/ runs +# every suite, because it can change how any of them run. go: - ".github/**" diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 866a9bf..b55f9bd 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,7 +6,7 @@ "helpers:pinGitHubActionDigests", "docker:pinDigests", ], - baseBranchPatterns: ["dev"], + baseBranchPatterns: ["main"], schedule: ["before 6am on monday"], minimumReleaseAge: "7 days", prConcurrentLimit: 5, @@ -25,7 +25,7 @@ dependencyDashboardApproval: true, }, { - description: "Every non-major update in one weekly pull request that squash-merges itself into dev once CI passes; promoting dev to main stays a manual step", + description: "Every non-major update in one weekly pull request that squash-merges itself into main once CI passes", groupName: "non-major dependencies", groupSlug: "non-major", matchUpdateTypes: ["minor", "patch", "digest", "pinDigest"], diff --git a/.github/rulesets/dev.json b/.github/rulesets/dev.json deleted file mode 100644 index 49baff6..0000000 --- a/.github/rulesets/dev.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "dev", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["refs/heads/dev"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 0, - "dismiss_stale_reviews_on_push": false, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_review_thread_resolution": false, - "allowed_merge_methods": ["squash", "merge"] - } - }, - { - "type": "required_status_checks", - "parameters": { - "strict_required_status_checks_policy": false, - "do_not_enforce_on_create": false, - "required_status_checks": [ - { "context": "CI passed", "integration_id": 15368 } - ] - } - } - ], - "bypass_actors": [ - { - "actor_id": 5586776, - "actor_type": "Integration", - "bypass_mode": "always" - }, - { - "actor_id": 5, - "actor_type": "RepositoryRole", - "bypass_mode": "pull_request" - } - ] -} diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 25627fd..7578ed1 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -19,7 +19,7 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false, - "allowed_merge_methods": ["merge"] + "allowed_merge_methods": ["squash"] } }, { @@ -34,6 +34,11 @@ } ], "bypass_actors": [ + { + "actor_id": 5586776, + "actor_type": "Integration", + "bypass_mode": "always" + }, { "actor_id": 5, "actor_type": "RepositoryRole", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dbbacf..1dca822 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [main, dev] + branches: [main] workflow_call: # Re-runs CI on a branch by hand. workflow_dispatch: @@ -270,21 +270,6 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - # main is the default branch, so new pull requests open against it. Only - # dev promotions, the release-please pull request and hotfixes may merge - # there; sync-dev.yml carries the last two back into dev. - - name: Require pull requests into main to come from dev - if: github.event_name == 'pull_request' && github.base_ref == 'main' - env: - HEAD_REF: ${{ github.head_ref }} - run: | - case "$HEAD_REF" in - dev | release-please--* | hotfix/*) ;; - *) - echo "::error::pull requests into main come only from dev (or hotfix/*); retarget this one to dev" - exit 1 - ;; - esac # A suite the changes job skipped counts; a failed or cancelled one does not. - name: Require every CI job to succeed or be skipped env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index acdfae2..5302a57 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: CodeQL on: pull_request: - branches: [main, dev] + branches: [main] push: branches: [main] schedule: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml deleted file mode 100644 index bcc6956..0000000 --- a/.github/workflows/dev.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Dev - -# Images are built once, here, from the commit that passed CI on dev. Flux -# image automation pins their digests in deploy/apps/production on dev, and -# promoting dev to main deploys exactly those images. - -on: - push: - branches: [dev] - # Flux's digest pins change only these manifests; the promotion pull - # request validates them, so they neither rerun CI nor rebuild images. - paths-ignore: - - "deploy/apps/production/**" - -permissions: {} - -concurrency: - group: dev-${{ github.sha }} - cancel-in-progress: false - -jobs: - ci: - name: CI - uses: ./.github/workflows/ci.yml - permissions: - contents: read - # ci.yml's suite selection reads pull request files; on push it runs everything. - pull-requests: read - - images: - name: Images - needs: ci - uses: ./.github/workflows/images.yml - permissions: - contents: read - packages: write - attestations: write - id-token: write diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 806ff29..1912481 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -50,7 +50,7 @@ jobs: name: Publish ${{ matrix.application }} image needs: changes if: >- - github.ref == 'refs/heads/dev' && + github.ref == 'refs/heads/main' && needs.changes.outputs.applications != '[]' runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -138,5 +138,5 @@ jobs: echo "- Image: \`${IMAGE}\`" echo "- Tag: \`${IMAGE_TAG}\`" echo "- Digest: \`${DIGEST}\`" - echo "- Flux pins this digest on dev; promoting dev to main deploys it." + echo "- Flux pins this digest on main and deploys it." } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/isolation.yml b/.github/workflows/isolation.yml index 03d833d..8b3ebfc 100644 --- a/.github/workflows/isolation.yml +++ b/.github/workflows/isolation.yml @@ -5,7 +5,7 @@ name: Isolation # suite runs after merge (push) or on demand instead. on: push: - branches: [main, dev] + branches: [main] paths: - libs/microvm-go/** - apps/snapflow/** diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ce0e9f7..916bfbc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,8 +1,16 @@ name: Main +# Images are built once, here, from the commit that passed CI on main. Flux +# image automation then pins their digests in deploy/apps/production on main +# and deploys exactly those images. + on: push: branches: [main] + # Flux's digest pins change only these manifests and are never release + # relevant, so they neither rerun CI nor rebuild images, which would loop. + paths-ignore: + - "deploy/apps/production/**" permissions: {} @@ -15,6 +23,16 @@ jobs: # ci.yml's suite selection reads pull request files; on push it runs everything. pull-requests: read + images: + name: Images + needs: ci + uses: ./.github/workflows/images.yml + permissions: + contents: read + packages: write + attestations: write + id-token: write + release-please: name: Release PR needs: ci diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 6907290..1859b7a 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,7 +4,7 @@ on: schedule: - cron: "0 6 * * 1" pull_request: - branches: [main, dev] + branches: [main] paths: - "go.work" - "go.work.sum" diff --git a/.github/workflows/sync-dev.yml b/.github/workflows/sync-dev.yml deleted file mode 100644 index a93e789..0000000 --- a/.github/workflows/sync-dev.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Sync dev - -# Commits that land on main without passing through dev (release-please -# releases, hotfixes and rollbacks) are merged back into dev through a pull -# request, so the next dev -> main promotion carries no stale history. The -# merge is made server-side with the merges API as the snapflowio-automation -# App, so no token is ever written to disk. It runs as its own workflow, not -# one main.yml calls, because a called workflow cannot read the automation -# environment's secret. - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: {} - -concurrency: - group: sync-dev - cancel-in-progress: false - -jobs: - sync: - name: Merge main into dev - runs-on: ubuntu-24.04 - timeout-minutes: 5 - # The App key is an environment secret only main may use. - environment: automation - permissions: - contents: read - env: - SYNC_BRANCH: sync/main-into-dev - SYNC_TITLE: "chore: merge main into dev" - steps: - # The snapflowio-automation App opens the pull request, so CI runs on it - # like any other; workflows: write lets the merge carry workflow changes. - - id: token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 - with: - client-id: ${{ vars.SNAPFLOW_AUTOMATION_CLIENT_ID }} - private-key: ${{ secrets.SNAPFLOW_AUTOMATION_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - permission-workflows: write - - name: Open or refresh the sync pull request - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - run: | - set -euo pipefail - repo="repos/${GITHUB_REPOSITORY}" - main_sha="$(gh api "${repo}/git/ref/heads/main" --jq .object.sha)" - dev_sha="$(gh api "${repo}/git/ref/heads/dev" --jq .object.sha)" - read -r status changed_files < <(gh api "${repo}/compare/${dev_sha}...${main_sha}" \ - --jq '[.status, (.files | length)] | @tsv') - if [ "$status" = identical ] || [ "$status" = behind ]; then - echo "dev already contains main" - exit 0 - fi - # A promotion leaves only its merge commit on main, which changes - # nothing relative to dev; merging it back would be an empty PR. - if [ "$changed_files" -eq 0 ]; then - echo "main is ahead only by commits that change no files" - exit 0 - fi - - point_sync_branch() { - if ! gh api -X PATCH "${repo}/git/refs/heads/${SYNC_BRANCH}" -f sha="$1" -F force=true >/dev/null 2>&1; then - gh api -X POST "${repo}/git/refs" -f ref="refs/heads/${SYNC_BRANCH}" -f sha="$1" >/dev/null - fi - } - - point_sync_branch "$dev_sha" - body="Merges commits that landed on \`main\` outside a promotion back into \`dev\`. Merge this with a merge commit, never squash." - if ! merge_output="$(gh api -X POST "${repo}/merges" -f base="$SYNC_BRANCH" -f head="$main_sha" -f commit_message="$SYNC_TITLE" 2>&1)"; then - if ! grep -q "Merge conflict" <<<"$merge_output"; then - echo "$merge_output" >&2 - exit 1 - fi - point_sync_branch "$main_sha" - body="\`main\` and \`dev\` conflict, so this branch is \`main\` as is. Merge \`dev\` into it locally, resolve the conflicts, push, then merge this with a merge commit." - echo "::warning::main and dev conflict; resolve ${SYNC_BRANCH} by hand" - fi - - open_pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$SYNC_BRANCH" --base dev --state open --json number --jq '.[0].number // empty')" - if [ -z "$open_pr" ]; then - gh pr create --repo "$GITHUB_REPOSITORY" --base dev --head "$SYNC_BRANCH" --title "$SYNC_TITLE" --body "$body" - else - gh pr edit "$open_pr" --repo "$GITHUB_REPOSITORY" --body "$body" - fi diff --git a/.moon/tasks/deployable.yml b/.moon/tasks/deployable.yml index 1ae4b26..f84ec3c 100644 --- a/.moon/tasks/deployable.yml +++ b/.moon/tasks/deployable.yml @@ -11,7 +11,7 @@ tasks: runFromWorkspaceRoot: true publish: - description: "Builds and pushes the build-- image of a dev commit that Flux pins on dev" + description: "Builds and pushes the build-- image of a main commit that Flux pins on main" command: "bash" args: ["deploy/scripts/image.sh", "publish"] deps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f75a529..e8c1cc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,38 +2,32 @@ ## Branches -| Branch | Role | Who writes to it | -| --- | --- | --- | -| `dev` | Integration and staging. Every change lands here first and is tested together. Merges build the app images once, and Flux pins their digests here. | Pull requests (squash-merged), Flux image pins | -| `main` | What is deployed, and the default branch. Flux deploys the image digests pinned here, and release-please cuts Snapflow releases from it. | Promotion pull requests from `dev` (merge commit), the release-please pull request, `hotfix/*` pull requests | +`main` is the only long-lived branch: the default branch and what is deployed. Every change reaches it as a squash-merged pull request; besides those, only Flux image pins and the release-please pull request write to it. ``` -feature/x ──squash──▶ dev ──▶ images ──▶ Flux pins digests on dev - │ - └──merge commit──▶ main ──▶ Flux deploys the pinned images, release-please - ▲ │ - └─── sync-dev.yml ◀───┘ (release commits, hotfixes, rollbacks) +feature/x ──squash──▶ main ──▶ images ──▶ Flux pins digests on main and deploys them + │ + └──▶ release-please ``` -1. Branch from `dev`, open a pull request into `dev` (GitHub proposes `main`, the default branch; change the base). `CI passed` fails any pull request into `main` that does not come from `dev`, release-please or a `hotfix/*` branch. Its title must follow [Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, `chore:` ...) because the squashed commit takes the title, and release-please versions from it. -2. `CI passed` must be green before merging. Squash-merge. A pull request runs only the CI suites its changes can affect ([`.github/filters/ci.yml`](.github/filters/ci.yml)), so a documentation change checks in about a minute; pushes to `dev` and `main` run every suite. -3. A merge into `dev` publishes images for the apps it changed, and Flux pins their digests on `dev` within about five minutes. To deploy, wait for that pin commit, then open a pull request from `dev` into `main` titled `chore: promote dev to main` and merge it with a **merge commit**. Production then runs the exact images built and scanned on `dev`. Never squash or rebase a promotion: `main` would get commits `dev` never had, and every later promotion would conflict. -4. Images are built only from `dev`, so a code fix reaches production through `dev` and a promotion. `hotfix/` branches from `main` are for changes that need no new image: a rollback to earlier image pins, or a deploy configuration fix. Merge them into `main` with a merge commit. -5. Commits that reach `main` outside a promotion (a merged release pull request, a hotfix or rollback) come back to `dev` through the `chore: merge main into dev` pull request that `sync-dev.yml` opens. Merge it with a merge commit as well. If it reports a conflict, merge `dev` into its branch locally, resolve, and push. +1. Branch from `main` and open a pull request into `main`. Its title must follow [Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, `chore:` ...) because it becomes the squashed commit's subject, and release-please versions from it. The pull request body becomes the commit message, so say what changed and why. +2. `CI passed` must be green before merging. Squash-merge. A pull request runs only the CI suites its changes can affect ([`.github/filters/ci.yml`](.github/filters/ci.yml)), so a documentation change checks in about a minute; pushes to `main` run every suite. +3. A merge into `main` publishes images for the apps it changed, and Flux pins their digests on `main` and deploys them within minutes. Production runs the exact images built and scanned from that commit. +4. To roll back, revert the commit with a normal pull request; its merge builds and deploys the reverted code the same way. To pin the previous digests instead, run `flux suspend image update snapflow` first, otherwise image automation pins the newest build again, then change the digests in `deploy/apps/production` with a pull request and resume automation once a fix is merged. ## Dependencies -[Renovate](https://docs.renovatebot.com) keeps dependencies current, configured in [`.github/renovate.json5`](.github/renovate.json5). Every Monday morning it opens one pull request into `dev` with every minor, patch and digest update at least seven days old, and squash-merges it itself once `CI passed` is green. A failing one stays open for you. Go toolchain updates get their own pull request, merged by hand. Major versions wait until they are approved on the *Dependency Dashboard* issue. Security fixes skip the schedule and the wait. Nothing Renovate merges reaches production until you promote `dev`. +[Renovate](https://docs.renovatebot.com) keeps dependencies current, configured in [`.github/renovate.json5`](.github/renovate.json5). Every Monday morning it opens one pull request into `main` with every minor, patch and digest update at least seven days old, and squash-merges it itself once `CI passed` is green. A failing one stays open for you. Go toolchain updates get their own pull request, merged by hand. Major versions wait until they are approved on the *Dependency Dashboard* issue. Security fixes skip the schedule and the wait. What Renovate merges deploys like any other merge into `main`. ## Repository setup Done once per repository: -1. *Settings > General*: default branch `main`. It stays `main` because scheduled workflows run only on the default branch: `tuf-refresh.yml` signs with the `beta` environment, which only `main` may use, and the repository page shows what is deployed. Under *Pull Requests* allow merge commits and squash merging (both titled from the pull request), disallow rebase merging, enable *Always suggest updating pull request branches*, *Allow auto-merge* (Renovate merges its own pull requests with it) and *Automatically delete head branches*. -2. *Settings > Rules > Rulesets > New ruleset > Import a ruleset*: import [`.github/rulesets/main.json`](.github/rulesets/main.json), [`.github/rulesets/dev.json`](.github/rulesets/dev.json) and [`.github/rulesets/tags.json`](.github/rulesets/tags.json). The branch rulesets require a pull request and the `CI passed` check and block force pushes and deletion; `main` accepts only merge commits, `dev` squash and merge commits. The tag ruleset lets only the automation App create, move or delete `v*` release tags. The App is also the only actor that may push to `dev` directly, for Flux image automation's digest pins; nothing bypasses `main`. +1. *Settings > General*: default branch `main`. It stays `main` because scheduled workflows run only on the default branch: `tuf-refresh.yml` signs with the `beta` environment, which only `main` may use, and the repository page shows what is deployed. Under *Pull Requests* allow only squash merging, with the default commit message set to the pull request title and description; disallow merge commits and rebase merging, enable *Always suggest updating pull request branches*, *Allow auto-merge* (Renovate merges its own pull requests with it) and *Automatically delete head branches*. +2. *Settings > Rules > Rulesets > New ruleset > Import a ruleset*: import [`.github/rulesets/main.json`](.github/rulesets/main.json) and [`.github/rulesets/tags.json`](.github/rulesets/tags.json). The branch ruleset requires a pull request and the `CI passed` check, accepts only squash merges, and blocks force pushes and deletion. The tag ruleset lets only the automation App create, move or delete `v*` release tags. The App is also the only actor that may push to `main` directly, for Flux image automation's digest pins. 3. *Settings > Actions > General*: require actions to be pinned to a full-length commit SHA (every workflow already pins them), require approval of workflow runs for all external contributors (the KVM runners are self-hosted), and set *Workflow permissions* to read repository contents (jobs request more themselves). -4. *Automation App*: the `snapflowio-automation` GitHub App (Contents, Pull requests, Issues and Workflows: read and write; no webhook) opens the release-please and main-to-dev pull requests, creates release tags and pushes Flux image pins to `dev`. Pull requests it opens start CI like any other, which `GITHUB_TOKEN` pull requests do not. Install it on this repository only. Store its Client ID in the repository variable `SNAPFLOW_AUTOMATION_CLIENT_ID`, and its private key in the `automation` environment, which only `main` may deploy to, so no workflow run from another branch can use it: `gh secret set SNAPFLOW_AUTOMATION_PRIVATE_KEY --env automation < key.pem`. Flux keeps its own copy in the cluster ([`deploy/scripts/bootstrap-flux.sh`](deploy/scripts/bootstrap-flux.sh)). +4. *Automation App*: the `snapflowio-automation` GitHub App (Contents, Pull requests, Issues and Workflows: read and write; no webhook) opens the release-please pull request, creates release tags and pushes Flux image pin commits to `main`. Pull requests it opens start CI like any other, which `GITHUB_TOKEN` pull requests do not. Install it on this repository only. Store its Client ID in the repository variable `SNAPFLOW_AUTOMATION_CLIENT_ID`, and its private key in the `automation` environment, which only `main` may deploy to, so no workflow run from another branch can use it: `gh secret set SNAPFLOW_AUTOMATION_PRIVATE_KEY --env automation < key.pem`. Flux keeps its own copy in the cluster ([`deploy/scripts/bootstrap-flux.sh`](deploy/scripts/bootstrap-flux.sh)). 5. *Settings > Advanced Security*: enable Dependabot alerts (Renovate reads them for its security pull requests), secret scanning and push protection. Leave Dependabot security updates and version updates off; Renovate opens those pull requests. 6. Install the [Renovate GitHub App](https://github.com/apps/renovate) on this repository only. -7. The [Blacksmith](https://www.blacksmith.sh) GitHub App must have access to this repository: the CI suites, CodeQL, the dependency audits and the dev container build run on its `blacksmith-4vcpu-ubuntu-2404` runners, and their jobs wait in the queue without it. Releases, promotion, TUF signing and image publishing stay on GitHub-hosted runners, so their signatures and build attestations come from GitHub's own builders. +7. The [Blacksmith](https://www.blacksmith.sh) GitHub App must have access to this repository: the CI suites, CodeQL, the dependency audits and the dev container build run on its `blacksmith-4vcpu-ubuntu-2404` runners, and their jobs wait in the queue without it. Releases, stable promotion, TUF signing and image publishing stay on GitHub-hosted runners, so their signatures and build attestations come from GitHub's own builders. 8. The release environments, secrets, TUF ceremony and KVM runners are in [apps/snapflow/internal/tuf/trust/README.md](apps/snapflow/internal/tuf/trust/README.md#one-time-setup). diff --git a/deploy/images/images.yaml b/deploy/images/images.yaml index 3f4fdf0..d78f4d5 100644 --- a/deploy/images/images.yaml +++ b/deploy/images/images.yaml @@ -8,23 +8,23 @@ spec: sourceRef: kind: GitRepository name: flux-system - # Images are built only from dev, so their digests are pinned on dev and - # reach production through the dev -> main promotion. Nothing writes main - # directly, which keeps a revert on main in place. + # Images are built only from main, so their digests are pinned on main, + # which Flux deploys. Main's workflow ignores deploy/apps/production, so a + # pin commit neither reruns CI nor rebuilds images. git: checkout: ref: - branch: dev + branch: main commit: author: name: snapflow-flux email: flux@snapflow.io messageTemplate: | - chore(deploy): pin images built from dev + chore(deploy): pin images built from main {{ range .Changed.Changes }}{{ print .OldValue }} -> {{ println .NewValue }}{{ end }} push: - branch: dev + branch: main update: path: ./deploy/apps/production strategy: Setters diff --git a/deploy/scripts/image.sh b/deploy/scripts/image.sh index 078123b..e6869ab 100644 --- a/deploy/scripts/image.sh +++ b/deploy/scripts/image.sh @@ -12,7 +12,7 @@ readonly usage="Usage: image.sh " readonly bake_file="docker-bake.hcl" readonly builder="snapflow" readonly local_tag="local" -readonly release_ref="origin/dev" +readonly release_ref="origin/main" readonly metadata_directory="dist/images" readonly digest_key="containerimage.digest" readonly cache_tag="buildcache" @@ -47,7 +47,7 @@ publish() { exit 1 fi if ! git merge-base --is-ancestor HEAD "${release_ref}"; then - echo "Publishing requires a commit that is on ${release_ref}; merge it into dev and fetch first" >&2 + echo "Publishing requires a commit that is on ${release_ref}; merge it into main and fetch first" >&2 exit 1 fi local timestamp candidate release metadata digest