From 29e8c41ba7ec9a1fcc6e352fb9cf8ae613021c72 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 29 Aug 2026 23:37:54 -0700 Subject: [PATCH 1/4] Add `varlock freeze` for deploy-time pinned env Resolves every value once and writes an encrypted file that ships inside the deploy artifact. The app boots from that file instead of re-resolving, so config is part of the release: it changes atomically with code and rolls back with it. Aimed at apps with no build step to inline values (Elysia, Hono, Fastify on Bun/Node, distroless images) on platforms where env vars can't be set atomically with a deploy. Also removes the need for the CLI, .env files, or resolver credentials in the runtime image. - `.varlock-frozen-env` is picked up automatically when present, controlled by `_VARLOCK_USE_FROZEN_ENV` (require it, point at a path, or disable it) - A file that is present but unusable is always an error, never a silent fallback to boot-time resolution - Takes precedence over an ambient `__VARLOCK_ENV` blob, and is authoritative: the directory/drift checks that gate blob reuse compare against local .env files, which a frozen deploy does not carry - Encryption is required unless `--allow-plaintext` is passed - Override provenance is cleared, so a key that happened to be set in CI can't become a hole the platform overrides at runtime --- .bumpy/frozen-env-file.md | 5 + .../src/content/docs/guides/frozen-env.mdx | 141 +++++++++ .../content/docs/reference/cli/project.mdx | 44 ++- .../docs/reference/reserved-variables.mdx | 13 + packages/varlock-website/src/sidebar.ts | 1 + packages/varlock/src/auto-load.ts | 17 +- packages/varlock/src/cli/cli-executable.ts | 2 + .../src/cli/commands/freeze.command-spec.ts | 68 +++++ .../src/cli/commands/freeze.command.ts | 94 ++++++ .../varlock/src/cli/commands/run.command.ts | 19 +- .../src/env-graph/lib/reserved-vars.ts | 4 + packages/varlock/src/lib/frozen-env-file.ts | 151 ++++++++++ .../varlock/src/lib/injected-env-reuse.ts | 96 ++++-- .../src/lib/test/frozen-env-file.test.ts | 281 ++++++++++++++++++ 14 files changed, 906 insertions(+), 30 deletions(-) create mode 100644 .bumpy/frozen-env-file.md create mode 100644 packages/varlock-website/src/content/docs/guides/frozen-env.mdx create mode 100644 packages/varlock/src/cli/commands/freeze.command-spec.ts create mode 100644 packages/varlock/src/cli/commands/freeze.command.ts create mode 100644 packages/varlock/src/lib/frozen-env-file.ts create mode 100644 packages/varlock/src/lib/test/frozen-env-file.test.ts diff --git a/.bumpy/frozen-env-file.md b/.bumpy/frozen-env-file.md new file mode 100644 index 000000000..c78df4e41 --- /dev/null +++ b/.bumpy/frozen-env-file.md @@ -0,0 +1,5 @@ +--- +varlock: minor +--- + +Add `varlock freeze` to resolve env values once at deploy time and write them to an encrypted file that ships inside your deploy artifact. Your app boots from that file instead of re-resolving, so config is pinned to the release and rolls back with it. Aimed at apps with no build step (Elysia, Hono, Fastify) on platforms where env vars can't be set atomically with a deploy. diff --git a/packages/varlock-website/src/content/docs/guides/frozen-env.mdx b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx new file mode 100644 index 000000000..fb1e93461 --- /dev/null +++ b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx @@ -0,0 +1,141 @@ +--- +title: Frozen env +description: Resolve env values once at deploy time and ship them inside your deploy artifact +--- +import { Steps, Tabs, TabItem } from '@astrojs/starlight/components'; +import ExecCommandWidget from '@/components/ExecCommandWidget.astro'; + +`varlock freeze` resolves every value once and writes the result to an encrypted file. Your app boots from that file instead of re-resolving, and the file ships inside your deploy artifact so config and code travel together. + +This is for apps where you control the boot command but have no build step that would inline values for you: Elysia, Hono, Fastify, or Express on Bun or Node, and distroless Docker images. If you use a framework integration (Next.js, Vite, Astro, Nuxt, Cloudflare Workers), you already get equivalent behavior and do not need this. See [encrypted deployments](/guides/encrypted-deployments/). + +## The problem + +Setting environment variables on a platform and shipping code are two separate operations. There is no way to make them one, which causes three things: + +- **No atomic change.** A release that changes both code and config is two operations with a window in between, where one is live and the other is not. +- **No rollback.** Config lives in the platform's store, not in the release, so rolling back code leaves the new config in place. +- **No guarantee across replicas.** Values are re-resolved on every boot, so a replica that autoscales up at 3am can resolve differently from the one that booted at deploy time, with no signal that it happened. + +A frozen env file makes config part of the deploy unit, so all three go away. It also means boot no longer depends on your secret backend being reachable, which removes a source of cold-start latency and rate limits when many replicas start at once. + +## Setup + + + +1. **Generate an encryption key, once** + + + + Set the result as `_VARLOCK_ENV_KEY` in your deploy pipeline and in your runtime environment. It is a long-lived bootstrap value, so it does not need to change per release. + +2. **Freeze at deploy time** + + Run this wherever your .env files and resolver credentials are available, usually a CI job: + + ```bash + varlock freeze --env production + ``` + + This writes `.varlock-frozen-env` in the current directory. Add it to your `.gitignore`. + +3. **Ship the file inside your deploy artifact** + + It must be present at boot, in the app's working directory. In Docker that means copying it into the image, not mounting it at runtime. Mounting it separately reintroduces the split this is meant to remove. + +4. **Boot your app normally** + + ```bash + bun server.js + ``` + + As long as your app imports `varlock/auto-load` (or you launch it with `varlock run`), varlock finds the file and uses it. No varlock CLI, no .env files, and no resolver credentials are needed in the runtime image. + + + +## Example: Bun with Docker + +```dockerfile title="Dockerfile" +FROM oven/bun:1 AS builder +WORKDIR /app +COPY . . +RUN bun install --frozen-lockfile +RUN bun build ./src/index.ts --target=bun --outdir dist +# resolver credentials and .env files are only present in this stage +RUN --mount=type=secret,id=varlock_key \ + _VARLOCK_ENV_KEY=$(cat /run/secrets/varlock_key) bunx varlock freeze --env production + +FROM oven/bun:1 +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.varlock-frozen-env ./ +CMD ["bun", "dist/index.js"] +``` + +Your entrypoint imports varlock as usual: + +```ts title="src/index.ts" +import 'varlock/auto-load'; +import { ENV } from 'varlock/env'; +import { Elysia } from 'elysia'; + +new Elysia() + .get('/', () => `hello from ${ENV.PUBLIC_APP_NAME}`) + .listen(ENV.PORT); +``` + +Types and sensitivity travel in the file, so `ENV.PORT` is still a number and log redaction still knows which values are secret. + +## Controlling the file + +Varlock uses `.varlock-frozen-env` in the working directory automatically when it is present. `_VARLOCK_USE_FROZEN_ENV` changes that: + +| Value | Behavior | +| --- | --- | +| unset | Use `.varlock-frozen-env` if present, otherwise resolve normally | +| `1` / `true` | Require `.varlock-frozen-env`. A missing file is an error | +| `0` / `false` | Never use a frozen env file | +| any path | Require a frozen env file at that path, relative to the working directory unless absolute | + +Setting `_VARLOCK_USE_FROZEN_ENV=1` in production is worth doing. Without it, a pipeline that failed to produce the file, or an image that failed to copy it, boots and resolves normally instead, and you have no signal that the pin is not in effect. + +Note that any unrecognized value is treated as a path, so `_VARLOCK_USE_FROZEN_ENV=off` looks for a file named `off` and fails. Only `0` and `false` disable it. + +### Freeze options + +| Flag | Description | +| --- | --- | +| `--out`, `-o` | Output path (default `.varlock-frozen-env`) | +| `--env` | Environment to resolve for (e.g. `production`) | +| `--path`, `-p` | Entry .env file or directory, repeatable | +| `--allow-plaintext` | Write unencrypted when `_VARLOCK_ENV_KEY` is not set | +| `--clear-cache` | Clear the cache and re-resolve everything | +| `--skip-cache` | Skip the cache for this invocation | + +## Encryption is required + +`varlock freeze` fails if `_VARLOCK_ENV_KEY` is not set. The file holds every resolved value, including secrets, and it is a portable standalone file: it gets copied between build stages, retained as a CI artifact, and read by anyone with access to your image layers or registry. That is a wider audience than the people who can reach your running container. + +Encryption protects against exactly that: image layers, registry access, CI artifact retention, and accidental commits. It does not protect against someone who already has code execution in the running container, since the key is in the environment right next to the file. This is the same tradeoff described in [encrypted deployments](/guides/encrypted-deployments/). + +`--allow-plaintext` exists for schemas with nothing sensitive in them. It prints a warning, and there is no config setting for it, so it cannot be turned on once and forgotten. + +## Tradeoffs + +**Rotating a secret takes effect on your next deploy, not on the next restart.** This is the entire point of freezing, but it inverts what most secret managers do, so everyone touching the deploy should know it. To pick up a rotated value, re-run `varlock freeze` and redeploy. + +**Rotating `_VARLOCK_ENV_KEY` breaks rollback.** Releases frozen under the old key cannot be decrypted with the new one, which is a problem precisely when you want to roll back. Keep the key stable, or plan to re-freeze and redeploy the releases you want to keep rollable. + +**A frozen file is used as-is, with no drift checking.** If one is sitting in your project directory during local development, it wins over your .env files even after you edit them. Delete it, or set `_VARLOCK_USE_FROZEN_ENV=0`. + +**Values that expire are frozen too.** If your schema resolves a short-lived credential (an OIDC-exchanged token, an STS credential), freezing captures it at deploy time and it will expire while the deploy is still running. Resolve those at boot instead of freezing them. + +## Related approaches + +| Approach | When it fits | +| --- | --- | +| `varlock freeze` | You control the boot command, have no build step, and want config pinned to the release | +| [`varlock run`](/reference/cli-commands/#run) | The CLI, .env files, and resolver credentials are all available in the runtime environment | +| [`ssrInjectMode: 'resolved-env'`](/guides/encrypted-deployments/) | You use a framework integration that injects the resolved env into build output | +| [`varlock-wrangler deploy`](/integrations/cloudflare/) | Cloudflare Workers, where the resolved env is uploaded as a versioned secret binding | diff --git a/packages/varlock-website/src/content/docs/reference/cli/project.mdx b/packages/varlock-website/src/content/docs/reference/cli/project.mdx index 2f9dac6bf..53e216c68 100644 --- a/packages/varlock-website/src/content/docs/reference/cli/project.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli/project.mdx @@ -1,6 +1,6 @@ --- title: Project commands -description: CLI reference for init, scan, install-plugin, flatten, telemetry, and help +description: CLI reference for init, scan, install-plugin, flatten, freeze, telemetry, and help --- import ExecCommandWidget from "@/components/ExecCommandWidget.astro"; @@ -171,6 +171,48 @@ The output directory is a generated artifact: add it to `.gitignore`, and rerun
+## `varlock freeze` ||freeze|| + +Resolves every value once and writes the result to an encrypted file, so your app can boot from those exact values without re-resolving. Run it at deploy time, and ship the file inside your deploy artifact so config and code travel and roll back as one unit. See [the frozen env guide](/guides/frozen-env/) for the full workflow. + +This is aimed at apps where you control the boot command but have no build step that would inline values (Elysia, Hono, Fastify, Express on Bun or Node, distroless images). Framework integrations already do the equivalent, so you do not need this with Next.js, Vite, Astro, Nuxt, or Cloudflare Workers. + +At boot, varlock uses `.varlock-frozen-env` automatically when it is present in the working directory. Nothing else is needed in the runtime image: no varlock CLI, no `.env` files, and no resolver credentials. `_VARLOCK_ENV_KEY` must be set in the runtime environment so the file can be decrypted. See [`_VARLOCK_USE_FROZEN_ENV`](/reference/reserved-variables/#_varlock_use_frozen_env) to require the file or point at a different path. + +Encryption is required: `freeze` errors when `_VARLOCK_ENV_KEY` is not set, since the file holds every resolved value and travels through image layers, registries, and CI artifacts. `--allow-plaintext` opts out for schemas with nothing sensitive in them. + +Values are pinned once frozen. Rotating a secret takes effect on your next deploy, not on the next restart. + +```bash +varlock freeze [options] +``` + +**Options:** +- `--out `, `-o`: Output file path, relative to the current directory unless absolute (default `.varlock-frozen-env`) +- `--env `: Environment to resolve for (e.g. `production`). Overridden by [`@currentEnv`](/reference/root-decorators/#currentenv) if the schema sets it +- `--path `, `-p`: Entry `.env` file or directory, can be passed multiple times +- `--allow-plaintext`: Write the file unencrypted when `_VARLOCK_ENV_KEY` is not set +- `--clear-cache`: Clear the cache and re-resolve all values +- `--skip-cache`: Skip the cache entirely for this invocation + +**Examples:** +```bash +# Write .varlock-frozen-env in the current directory +varlock freeze + +# Resolve for a specific environment +varlock freeze --env production + +# Custom output location, bypassing the cache +varlock freeze --out dist/env.frozen --skip-cache +``` + +The output file is a generated artifact holding resolved values: add it to `.gitignore`, and rerun `freeze` whenever your env values change. + +
+ +
+ ## `varlock telemetry` ||telemetry|| Opts in/out of anonymous usage analytics. This command creates/updates a configuration file at `$XDG_CONFIG_HOME/varlock/config.json` (defaults to `~/.config/varlock/config.json`) saving your preference. diff --git a/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx b/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx index f0a2c9ed0..539649a3b 100644 --- a/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx +++ b/packages/varlock-website/src/content/docs/reference/reserved-variables.mdx @@ -64,6 +64,19 @@ Values are matched case-insensitively; any other value is ignored (same as unset See [Reusing an injected env blob](/integrations/javascript/#reusing-an-injected-env-blob). +### `_VARLOCK_USE_FROZEN_ENV` + +Controls whether [`varlock/auto-load`](/integrations/javascript/) and [`varlock run`](/reference/cli/load-and-run/#run) boot from a [frozen env file](/guides/frozen-env/) produced by [`varlock freeze`](/reference/cli/project/#freeze): + +- unset (default): use `.varlock-frozen-env` in the working directory if it is present, otherwise resolve normally +- `1` / `true`: require `.varlock-frozen-env`. A missing file is a hard error, so a pipeline that failed to produce it cannot silently fall back to resolving at boot +- `0` / `false`: never use a frozen env file +- any other value: treated as a path to a frozen env file, relative to the working directory unless absolute, and required + +A frozen env file that is present but unusable (unreadable, encrypted with no `_VARLOCK_ENV_KEY`, or encrypted with a different key) is always an error, never a fallback to fresh resolution. Note that `1`/`true`/`0`/`false` are the only recognized toggles, so a value like `off` is read as a path and fails as a missing file. + +This takes precedence over an ambient [`__VARLOCK_ENV`](#__varlock_env) blob, and is independent of [`_VARLOCK_USE_INJECTED_ENV`](#_varlock_use_injected_env): setting that to `0` does not disable frozen env files. `varlock run` flags that change what a resolution produces (`--path`, `--filter`, `--clear-cache`, `--skip-cache`, `--include-internal`) are rejected when a frozen env file is in play. + {/* Intentionally NOT documented for end users; internal/testing only. Kept here (and in VARLOCK_CONFIG_ENV_VARS with internal:true) for maintainer reference. diff --git a/packages/varlock-website/src/sidebar.ts b/packages/varlock-website/src/sidebar.ts index 93e341849..759ae85ab 100644 --- a/packages/varlock-website/src/sidebar.ts +++ b/packages/varlock-website/src/sidebar.ts @@ -46,6 +46,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [ items: [ { label: 'Local encryption', slug: 'guides/local-encryption' }, { label: 'Encrypted deployments', slug: 'guides/encrypted-deployments' }, + { label: 'Frozen env', slug: 'guides/frozen-env', badge: 'new' }, { label: 'Caching', slug: 'guides/caching' }, { label: 'OIDC Workload Identity', slug: 'guides/oidc' }, ], diff --git a/packages/varlock/src/auto-load.ts b/packages/varlock/src/auto-load.ts index 1e8717457..31b9582bc 100644 --- a/packages/varlock/src/auto-load.ts +++ b/packages/varlock/src/auto-load.ts @@ -1,6 +1,7 @@ import { execSyncVarlock, VarlockExecError } from './lib/exec-sync-varlock'; import { encryptEnvBlobSync, generateEncryptionKeyHex, isEncryptedBlob } from './runtime/crypto'; import { evaluateInjectedEnvReuse } from './lib/injected-env-reuse'; +import { FrozenEnvFileError } from './lib/frozen-env-file'; import { createDebug } from './lib/debug'; import { initVarlockEnv, getPreInjectionProcessEnv } from './runtime/env'; @@ -40,11 +41,12 @@ function getPartialResolvedEnv(err: unknown): Record { let strippedInternalKeys: Array = []; try { - // An already-injected __VARLOCK_ENV blob (e.g. from a parent `varlock run`, or handed - // into a sandbox) can be reused directly instead of re-resolving via the CLI - see - // evaluateInjectedEnvReuse for the conditions. Throws in explicit-trust mode - // (_VARLOCK_USE_INJECTED_ENV=1) when the blob is missing/unusable, which flows into - // the same load-failure handling below. + // A pre-resolved env graph can be consumed directly instead of re-resolving via the CLI: + // either a `varlock freeze` file shipped inside the deploy artifact, or an already-injected + // __VARLOCK_ENV blob (e.g. from a parent `varlock run`, or handed into a sandbox) - see + // evaluateInjectedEnvReuse for the conditions. Throws when a frozen env file is present but + // unusable, or in explicit-trust mode (_VARLOCK_USE_INJECTED_ENV=1) when the blob is + // missing/unusable, which flows into the same load-failure handling below. const reuseDecision = evaluateInjectedEnvReuse({ env: process.env, preInjectionEnv: getPreInjectionProcessEnv(), @@ -54,7 +56,7 @@ try { let parsed: any; let parsedJsonStr: string; if (reuseDecision.reuse) { - debug('reusing injected env blob - skipping resolution'); + debug('reusing pre-resolved env from %s - skipping resolution', reuseDecision.source); parsed = reuseDecision.parsedEnv; parsedJsonStr = reuseDecision.blobJson; strippedInternalKeys = reuseDecision.strippedInternalKeys; @@ -106,6 +108,9 @@ try { } catch (err) { if (err instanceof VarlockExecError && err.stderr) { process.stderr.write(err.stderr); + } else if (err instanceof FrozenEnvFileError) { + // a setup/config problem, not a crash - a stack trace here is noise + process.stderr.write(`${err.message}\n`); } else { // eslint-disable-next-line no-console console.error(err); diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 05bb4a987..5610688e1 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -33,6 +33,7 @@ import { commandSpec as helpCommandSpec } from './commands/help.command-spec'; import { commandSpec as telemetryCommandSpec } from './commands/telemetry.command-spec'; import { commandSpec as explainCommandSpec } from './commands/explain.command-spec'; import { commandSpec as flattenCommandSpec } from './commands/flatten.command-spec'; +import { commandSpec as freezeCommandSpec } from './commands/freeze.command-spec'; import { commandSpec as scanCommandSpec } from './commands/scan.command-spec'; import { commandSpec as codegenCommandSpec } from './commands/codegen.command-spec'; import { commandSpec as typegenCommandSpec } from './commands/typegen.command-spec'; @@ -62,6 +63,7 @@ subCommands.set('reveal', lazy(async () => (await import('./commands/reveal.comm // subCommands.set('doctor', lazy(async () => (await import('./commands/doctor.command')).commandFn, doctorCommandSpec)); subCommands.set('explain', lazy(async () => (await import('./commands/explain.command')).commandFn, explainCommandSpec)); subCommands.set('flatten', lazy(async () => (await import('./commands/flatten.command')).commandFn, flattenCommandSpec)); +subCommands.set('freeze', lazy(async () => (await import('./commands/freeze.command')).commandFn, freezeCommandSpec)); subCommands.set('help', lazy(async () => (await import('./commands/help.command')).commandFn, helpCommandSpec)); subCommands.set('telemetry', lazy(async () => (await import('./commands/telemetry.command')).commandFn, telemetryCommandSpec)); subCommands.set('scan', lazy(async () => (await import('./commands/scan.command')).commandFn, scanCommandSpec)); diff --git a/packages/varlock/src/cli/commands/freeze.command-spec.ts b/packages/varlock/src/cli/commands/freeze.command-spec.ts new file mode 100644 index 000000000..6dda1074c --- /dev/null +++ b/packages/varlock/src/cli/commands/freeze.command-spec.ts @@ -0,0 +1,68 @@ +import { define } from 'gunshi'; +import { FROZEN_ENV_FILE_NAME } from '../../lib/frozen-env-file'; + +export const commandSpec = define({ + name: 'freeze', + description: 'Resolve env values once and write them to an encrypted file that ships with your deploy', + args: { + out: { + type: 'string', + short: 'o', + description: 'Output file path (relative to cwd unless absolute)', + default: FROZEN_ENV_FILE_NAME, + }, + env: { + type: 'string', + description: 'Set the environment (e.g., production, development, etc) - will be overridden by @currentEnv in the schema if present', + }, + path: { + type: 'string', + short: 'p', + multiple: true, + description: 'Path to a specific .env file or directory to use as the entry point (can be specified multiple times)', + }, + 'allow-plaintext': { + type: 'boolean', + description: 'Write the file unencrypted when _VARLOCK_ENV_KEY is not set. Every resolved secret will sit in plaintext inside your deploy artifact', + default: false, + }, + 'clear-cache': { + type: 'boolean', + description: 'Clear cache and re-resolve all values', + }, + 'skip-cache': { + type: 'boolean', + description: 'Skip cache entirely for this invocation', + }, + }, + examples: ` +Resolves every value once and writes the result to an encrypted file, so your app can boot +from those exact values without re-resolving. Run it at deploy time, and ship the file +inside your deploy artifact (image layer, deployment bundle) so config and code travel and +roll back as one unit. + +This is aimed at platforms where you can control the boot command but can't feed env vars +in atomically with a deploy, and at apps without a build step that would otherwise inline +them (Elysia/Hono/Fastify on Bun or Node, distroless Docker images). + +At boot, varlock uses the file automatically if it is present at the default path - no CLI, +no .env files, and no resolver credentials needed in the runtime image. Set +_VARLOCK_ENV_KEY on your platform so the file can be decrypted. + +The tradeoff: values are pinned. Rotating a secret takes effect on your next deploy, not on +the next restart. + +Examples: + varlock freeze # write ${FROZEN_ENV_FILE_NAME} in the current directory + varlock freeze --env production # resolve for a specific environment + varlock freeze --out dist/env.frozen # custom output location + varlock freeze --skip-cache # bypass the cache so values are freshly resolved + +Typical CI usage: + varlock generate-key --plain # once - set the result as _VARLOCK_ENV_KEY everywhere + varlock freeze --env production # in your deploy job, with resolver credentials present + docker build . # the file is copied into the image + +Then boot the app normally (\`bun server.js\`) with _VARLOCK_ENV_KEY set in the runtime env. +`.trim(), +}); diff --git a/packages/varlock/src/cli/commands/freeze.command.ts b/packages/varlock/src/cli/commands/freeze.command.ts new file mode 100644 index 000000000..dfb165882 --- /dev/null +++ b/packages/varlock/src/cli/commands/freeze.command.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ansis from 'ansis'; + +import { loadVarlockEnvGraph } from '../../lib/load-graph'; +import { encryptEnvBlobSync } from '../../runtime/crypto'; +import { USE_FROZEN_ENV_VAR } from '../../lib/frozen-env-file'; +import { + checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors, showPluginWarnings, +} from '../helpers/error-checks'; +import { CliExitError } from '../helpers/exit-error'; +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; +import { commandSpec } from './freeze.command-spec'; + +export { commandSpec }; + +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + const allowPlaintext = !!ctx.values['allow-plaintext']; + const encryptionKey = process.env._VARLOCK_ENV_KEY; + + // Check the key before doing any resolution work - a missing key is a setup problem, and + // failing fast avoids hitting every resolver (and any biometric/OAuth prompts) first. + if (!encryptionKey && !allowPlaintext) { + throw new CliExitError('_VARLOCK_ENV_KEY is not set, so the frozen env file cannot be encrypted', { + suggestion: 'Generate one with `varlock generate-key`, then set it both here and on your deployment platform ' + + '(the same key must be present at runtime to decrypt). Use --allow-plaintext only if you accept every ' + + 'resolved secret sitting unencrypted inside your deploy artifact.', + }); + } + + const envGraph = await loadVarlockEnvGraph({ + currentEnvFallback: ctx.values.env, + entryFilePaths: ctx.values.path, + clearCache: ctx.values['clear-cache'], + skipCache: ctx.values['skip-cache'], + }); + checkForSchemaErrors(envGraph); + checkForNoEnvFiles(envGraph); + + // Generate types before resolving values: uses only non-env-specific schema info + await envGraph.runCodeGeneratorsIfNeeded(); + await envGraph.resolveEnvValues(); + // a frozen file is consumed without re-resolution, so a partially-broken graph must never + // be written - there would be no opportunity to surface the failure later + checkForConfigErrors(envGraph); + showPluginWarnings(envGraph); + + const serialized = envGraph.getSerializedGraph(); + + // Override provenance describes process.env overrides at the ORIGINAL invocation, so + // consumers re-apply exactly those keys from their own environment. That makes sense for a + // nested `varlock run`, but here it would mean any schema key that happened to be set in + // CI becomes a key the deployment platform can override at runtime - a hole in the very + // pin this file exists to create. A frozen file has no parent invocation, so: no overrides. + serialized.overrideKeys = []; + + const outPath = path.resolve(process.cwd(), String(ctx.values.out)); + const serializedJson = JSON.stringify(serialized); + const contents = encryptionKey ? encryptEnvBlobSync(serializedJson, encryptionKey) : serializedJson; + + try { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + // 0600 so the resolved values aren't readable by other users on a shared build machine + fs.writeFileSync(outPath, `${contents}\n`, { mode: 0o600 }); + } catch (err) { + throw new CliExitError(`Failed to write frozen env file to ${outPath}: ${(err as Error).message}`); + } + + const itemCount = Object.keys(serialized.config).length; + const relOutPath = path.relative(process.cwd(), outPath) || outPath; + + console.log(`Froze ${itemCount} env var${itemCount === 1 ? '' : 's'} into ${ansis.bold(relOutPath)}`); + console.log(ansis.gray(` ${encryptionKey ? 'encrypted with _VARLOCK_ENV_KEY' : 'UNENCRYPTED'}`)); + console.log(''); + + if (!encryptionKey) { + console.log(`${ansis.yellow('⚠')} This file holds every resolved value in plaintext, including secrets.`); + console.log(ansis.gray(' Anyone who can read your image layers, registry, or CI artifacts can read them.')); + console.log(''); + } + + console.log('Next steps:'); + console.log(ansis.gray(` 1. Ship ${relOutPath} inside your deploy artifact (it must be present at boot).`)); + if (encryptionKey) { + console.log(ansis.gray(' 2. Set _VARLOCK_ENV_KEY in the runtime environment so it can be decrypted.')); + console.log(ansis.gray(' 3. Boot your app as usual - varlock picks the file up automatically.')); + } else { + console.log(ansis.gray(' 2. Boot your app as usual - varlock picks the file up automatically.')); + } + console.log(''); + console.log(ansis.gray(`Add ${relOutPath} to your .gitignore - it is a generated artifact holding resolved values.`)); + console.log(ansis.gray('Values are now pinned: rotating a secret takes effect on your next deploy, not on restart.')); + console.log(ansis.gray(`Set ${USE_FROZEN_ENV_VAR}=1 at runtime to make a missing file a hard error rather than falling back to normal resolution.`)); +}; diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 355ce3a08..1038e2a2c 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -12,6 +12,7 @@ import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; import { resolveInjectMode } from '../helpers/inject-mode'; import { CliExitError } from '../helpers/exit-error'; import { evaluateInjectedEnvReuse, getUseInjectedEnvMode, USE_INJECTED_ENV_VAR } from '../../lib/injected-env-reuse'; +import { FrozenEnvFileError, getFrozenEnvFileInPlay, USE_FROZEN_ENV_VAR } from '../../lib/frozen-env-file'; import { injectedEnvStringForm } from '../../lib/injected-env-provenance'; import { isEncryptedBlob, encryptEnvBlobSync } from '../../runtime/crypto'; import { getPreInjectionProcessEnv } from '../../runtime/env'; @@ -132,6 +133,15 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = let reuseDecision: ReturnType; if (resolutionFlags.length) { + // A frozen env file is a deploy-time pin, so silently ignoring it and re-resolving would + // defeat the point just as much as it would for an explicitly-forced blob. + const frozenFilePath = getFrozenEnvFileInPlay(process.env, process.cwd()); + if (frozenFilePath) { + throw new CliExitError(`a frozen env file (${frozenFilePath}) cannot be combined with ${resolutionFlags.join(', ')}`, { + suggestion: 'These flags change what a fresh resolution produces, so there is nothing to reuse. Drop them, ' + + `re-run \`varlock freeze\` with them, or set ${USE_FROZEN_ENV_VAR}=0 to resolve from .env files.`, + }); + } if (getUseInjectedEnvMode(process.env) === 'force') { throw new CliExitError(`${USE_INJECTED_ENV_VAR} cannot be combined with ${resolutionFlags.join(', ')}`, { suggestion: 'These flags change what a fresh resolution produces, so there is nothing to reuse. Drop them, or unset the env var to resolve normally.', @@ -146,7 +156,14 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = cwd: process.cwd(), }); } catch (err) { - // explicit trust mode with a missing/unusable blob + // a frozen env file that is present but unusable, or explicit trust mode with a + // missing/unusable blob - neither ever falls back to a fresh resolution + if (err instanceof FrozenEnvFileError) { + throw new CliExitError((err as Error).message.replace(/^\[varlock\] /, ''), { + suggestion: 'Re-create it with `varlock freeze`, make sure _VARLOCK_ENV_KEY matches the key it was frozen with, ' + + `or set ${USE_FROZEN_ENV_VAR}=0 to resolve from .env files instead.`, + }); + } throw new CliExitError((err as Error).message.replace(/^\[varlock\] /, ''), { suggestion: 'Provide a valid __VARLOCK_ENV blob (e.g. captured via `varlock load --format json-full --compact`), ' + `or unset ${USE_INJECTED_ENV_VAR} to resolve from .env files.`, diff --git a/packages/varlock/src/env-graph/lib/reserved-vars.ts b/packages/varlock/src/env-graph/lib/reserved-vars.ts index 414eac3f3..0586280f9 100644 --- a/packages/varlock/src/env-graph/lib/reserved-vars.ts +++ b/packages/varlock/src/env-graph/lib/reserved-vars.ts @@ -53,6 +53,10 @@ export const VARLOCK_CONFIG_ENV_VARS: Array = [ name: '_VARLOCK_USE_INJECTED_ENV', description: 'Controls whether `varlock/auto-load` reuses an already-injected `__VARLOCK_ENV` blob instead of re-resolving via the CLI. `1`/`true` always trusts the blob (e.g. handing a blob into a sandbox with no .env files); `0`/`false` always re-resolves. Unset, auto-load reuses the blob only when it was resolved in the same directory.', }, + { + name: '_VARLOCK_USE_FROZEN_ENV', + description: 'Controls whether `varlock/auto-load` and `varlock run` boot from a frozen env file produced by `varlock freeze`. Unset, `.varlock-frozen-env` is used if present; `1`/`true` requires it; `0`/`false` never uses one; any other value is treated as a required path.', + }, { name: '_VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK', description: 'Forces the file-based local encryption fallback instead of the native binary. Intended for testing/debugging.', diff --git a/packages/varlock/src/lib/frozen-env-file.ts b/packages/varlock/src/lib/frozen-env-file.ts new file mode 100644 index 000000000..2750eea9f --- /dev/null +++ b/packages/varlock/src/lib/frozen-env-file.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; + +/** + * A "frozen env" file is a deploy-time pin: `varlock freeze` resolves every value once, + * encrypts the serialized graph, and writes it to a file that ships INSIDE the deploy unit + * (image layer, deployment bundle). At boot the app consumes that file instead of + * re-resolving. + * + * The motivation is atomicity, not convenience. Setting env vars on a platform and shipping + * code are two separate operations, so config and code can never be updated as one unit, and + * rolling back code does not roll back config. An artifact that travels with the release + * makes them a single versioned thing. Secondary benefits: boot stops depending on the + * availability (and latency, and rate limits) of 1Password/Vault/etc, and every replica in a + * deploy is guaranteed to see identical values. + * + * The tradeoff is the whole point, and needs to be understood before using this: rotating a + * secret no longer takes effect on restart. It takes effect on the next deploy. + */ + +/** Default filename, resolved relative to cwd (the app dir at boot, per-package in a monorepo). */ +export const FROZEN_ENV_FILE_NAME = '.varlock-frozen-env'; + +/** user-controllable behavior flag (leading single underscore per convention) */ +export const USE_FROZEN_ENV_VAR = '_VARLOCK_USE_FROZEN_ENV'; + +type EnvRecord = Record; + +/** Thrown when a frozen env file is in play but cannot be used. Never falls back to fresh resolution. */ +export class FrozenEnvFileError extends Error { + constructor(message: string) { + super(`[varlock] ${message}`); + this.name = 'FrozenEnvFileError'; + } +} + +/** `off` never reads a file; `auto` uses one only if present; `required` errors when it is missing */ +export type FrozenEnvFileMode = | { mode: 'off' } + /** use the file at this path if it exists; absence falls through to normal resolution */ + | { mode: 'auto', filePath: string } + /** the file at this path MUST exist and be usable */ + | { mode: 'required', filePath: string }; + +/** + * Interpret `_VARLOCK_USE_FROZEN_ENV`: + * - unset -> auto, at the default path + * - `1`/`true` -> required, at the default path (assert the pin is actually in effect) + * - `0`/`false`-> off + * - anything else -> required, treating the value as a path + * + * Note this deliberately differs from `getUseInjectedEnvMode`, which maps unrecognized + * values back to `auto` so that `=no`/`=off` can never silently grant blob trust. Here an + * unrecognized value is a path, so `=off` resolves to a file named `off` and hard-errors as + * missing. That keeps the same property (a typo is never silently permissive) while letting + * one variable carry both the toggle and the location. + */ +export function resolveFrozenEnvFileMode(env: EnvRecord, cwd: string): FrozenEnvFileMode { + const rawValue = env[USE_FROZEN_ENV_VAR]; + const defaultPath = path.resolve(cwd, FROZEN_ENV_FILE_NAME); + + if (rawValue === undefined || rawValue.trim() === '') { + return { mode: 'auto', filePath: defaultPath }; + } + const normalized = rawValue.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return { mode: 'required', filePath: defaultPath }; + if (normalized === '0' || normalized === 'false') return { mode: 'off' }; + + return { mode: 'required', filePath: path.resolve(cwd, rawValue.trim()) }; +} + +/** + * Whether a frozen env file will be consumed on this invocation - i.e. it is required, or it + * is present at the auto-discovered path. Callers use this to reject flags that would change + * what gets loaded (rather than silently ignoring the pin). + */ +export function getFrozenEnvFileInPlay(env: EnvRecord, cwd: string): string | undefined { + const resolved = resolveFrozenEnvFileMode(env, cwd); + if (resolved.mode === 'off') return undefined; + if (resolved.mode === 'required') return resolved.filePath; + return fs.existsSync(resolved.filePath) ? resolved.filePath : undefined; +} + +export type FrozenEnvFileResult = | { found: false, reason: string } + | { found: true, filePath: string, blobJson: string }; + +/** + * Read + decrypt the frozen env file, if one applies. + * + * Only ABSENCE in auto mode falls through to normal resolution. A file that is present but + * unusable (unreadable, encrypted with no key available, wrong key, etc) always throws. + * Falling back there would silently un-pin the deploy and re-resolve at boot, which is + * exactly the behavior freezing exists to eliminate, and it would do it invisibly. + */ +export function readFrozenEnvFile(opts: { env: EnvRecord, cwd?: string }): FrozenEnvFileResult { + const { env } = opts; + const cwd = opts.cwd ?? process.cwd(); + + const resolved = resolveFrozenEnvFileMode(env, cwd); + if (resolved.mode === 'off') { + return { found: false, reason: `${USE_FROZEN_ENV_VAR} disabled frozen env files` }; + } + const { filePath, mode } = resolved; + + if (!fs.existsSync(filePath)) { + if (mode === 'required') { + throw new FrozenEnvFileError( + `${USE_FROZEN_ENV_VAR} requires a frozen env file at ${filePath}, but none is present`, + ); + } + return { found: false, reason: `no frozen env file at ${filePath}` }; + } + + // A frozen file is an unscoped snapshot of the whole graph, so honoring _VARLOCK_FILTER + // would hand over values the caller expected to exclude (same reasoning as the blob path). + if (env._VARLOCK_FILTER) { + throw new FrozenEnvFileError( + `a frozen env file (${filePath}) cannot be combined with _VARLOCK_FILTER - freeze with --filter instead`, + ); + } + + let rawContents: string; + try { + rawContents = fs.readFileSync(filePath, 'utf8').trim(); + } catch (err) { + throw new FrozenEnvFileError(`failed to read frozen env file ${filePath}: ${(err as Error).message}`); + } + if (!rawContents) { + throw new FrozenEnvFileError(`frozen env file ${filePath} is empty`); + } + + if (!isEncryptedBlob(rawContents)) { + // plaintext is only produced by `varlock freeze --allow-plaintext`, which warns loudly + // at write time - no need to re-warn on every boot + return { found: true, filePath, blobJson: rawContents }; + } + + const key = env._VARLOCK_ENV_KEY; + if (!key) { + throw new FrozenEnvFileError( + `frozen env file ${filePath} is encrypted but _VARLOCK_ENV_KEY is not set in the environment`, + ); + } + try { + return { found: true, filePath, blobJson: decryptEnvBlobSync(rawContents, key) }; + } catch (err) { + throw new FrozenEnvFileError( + `failed to decrypt frozen env file ${filePath}: ${(err as Error).message.replace(/^\[varlock\] /, '')}`, + ); + } +} diff --git a/packages/varlock/src/lib/injected-env-reuse.ts b/packages/varlock/src/lib/injected-env-reuse.ts index be290fdae..7eebe8ba6 100644 --- a/packages/varlock/src/lib/injected-env-reuse.ts +++ b/packages/varlock/src/lib/injected-env-reuse.ts @@ -5,6 +5,7 @@ import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { envValueMatchesBlobItem } from './injected-env-provenance'; import { hashEnvSourceContents } from './env-source-fingerprint'; +import { readFrozenEnvFile } from './frozen-env-file'; /** * Decides whether a consumer (`varlock/auto-load`, or a `varlock run` that finds a blob @@ -32,6 +33,8 @@ export type InjectedEnvReuseDecision = | { parsedEnv: SerializedEnvGraph, /** plaintext JSON of the (sanitized) blob - used when it needs re-serialization/re-encryption */ blobJson: string, + /** where the graph came from - an ambient `__VARLOCK_ENV`, or a `varlock freeze` artifact on disk */ + source: 'env-blob' | 'frozen-file', /** * `@internal` item keys that were stripped from the blob on consumption. Fresh-resolution * blobs never carry internal items, but the inspection command (`load --format json-full @@ -94,6 +97,43 @@ function isSamePath(a: string, b: string): boolean { return normalize(a) === normalize(b); } +/** + * Parse a serialized graph and strip anything that must never reach the app. + * + * `@internal` items are never handed to the app/child by any fresh resolution path, but a + * blob produced by the inspection command (`load --format json-full --include-internal`) + * can carry them - strip on consumption and re-serialize so a forwarded blob is clean too. + * + * Returns undefined when the input isn't a serialized env graph, so each caller can frame + * the failure in terms of where the graph came from. + */ +function parseAndSanitizeBlob(rawJson: string): { + parsedEnv: SerializedEnvGraph, blobJson: string, strippedInternalKeys: Array, +} | undefined { + let parsedEnv: SerializedEnvGraph; + try { + parsedEnv = JSON.parse(rawJson); + } catch { + return undefined; + } + if (!parsedEnv || typeof parsedEnv !== 'object' || !parsedEnv.config || typeof parsedEnv.config !== 'object') { + return undefined; + } + + const strippedInternalKeys: Array = []; + for (const itemKey of Object.keys(parsedEnv.config)) { + if (parsedEnv.config[itemKey].isInternal) { + strippedInternalKeys.push(itemKey); + delete parsedEnv.config[itemKey]; + } + } + return { + parsedEnv, + blobJson: strippedInternalKeys.length ? JSON.stringify(parsedEnv) : rawJson, + strippedInternalKeys, + }; +} + export function evaluateInjectedEnvReuse(opts: { /** env holding the blob/key/flags - normally the live process.env */ env: EnvRecord, @@ -110,6 +150,34 @@ export function evaluateInjectedEnvReuse(opts: { const preInjectionEnv = opts.preInjectionEnv ?? env; const cwd = opts.cwd ?? process.cwd(); + // A frozen env file (`varlock freeze`) is a deploy-time pin that ships inside the deploy + // unit. It is checked first and wins over an ambient __VARLOCK_ENV: naming a file on disk + // is the more deliberate act, and the two are governed by separate flags so + // _VARLOCK_USE_INJECTED_ENV=0 does not disable it (use _VARLOCK_USE_FROZEN_ENV=0). + // + // Like the force path it is authoritative with no directory/drift verification, because + // the checks below compare a blob against local .env files which a frozen deploy by design + // does not carry. readFrozenEnvFile throws (never returns found:false) when a file is + // present but unusable, so a broken pin can never silently degrade into a boot-time + // re-resolution. + const frozen = readFrozenEnvFile({ env, cwd }); + if (frozen.found) { + const sanitizedFrozen = parseAndSanitizeBlob(frozen.blobJson); + if (!sanitizedFrozen) { + throw new Error(`[varlock] frozen env file ${frozen.filePath} is not a valid serialized env graph`); + } + if (sanitizedFrozen.parsedEnv.errors) { + throw new Error(`[varlock] frozen env file ${frozen.filePath} was created from a failed resolution and contains errors`); + } + return { + reuse: true, + parsedEnv: sanitizedFrozen.parsedEnv, + blobJson: sanitizedFrozen.blobJson, + strippedInternalKeys: sanitizedFrozen.strippedInternalKeys, + source: 'frozen-file', + }; + } + const mode = getUseInjectedEnvMode(env); if (mode === 'never') return { reuse: false, reason: `${USE_INJECTED_ENV_VAR} disabled reuse` }; @@ -150,37 +218,21 @@ export function evaluateInjectedEnvReuse(opts: { } } - let parsedEnv: SerializedEnvGraph; - try { - parsedEnv = JSON.parse(blobJson); - if (!parsedEnv || typeof parsedEnv !== 'object' || !parsedEnv.config || typeof parsedEnv.config !== 'object') { - throw new Error('not a serialized env graph'); - } - } catch { + const sanitized = parseAndSanitizeBlob(blobJson); + if (!sanitized) { if (mode === 'force') { throw new Error(`[varlock] ${USE_INJECTED_ENV_VAR} is enabled but the __VARLOCK_ENV blob is not a valid serialized env graph`); } return { reuse: false, reason: 'blob is not a valid serialized env graph' }; } - - // @internal items are never handed to the app/child by any fresh resolution path, but a - // blob produced by the inspection command (`load --format json-full --include-internal`) - // can carry them - strip on consumption, in every mode, and re-serialize so a forwarded - // blob is clean too - const strippedInternalKeys: Array = []; - for (const itemKey of Object.keys(parsedEnv.config)) { - if (parsedEnv.config[itemKey].isInternal) { - strippedInternalKeys.push(itemKey); - delete parsedEnv.config[itemKey]; - } - } - if (strippedInternalKeys.length) blobJson = JSON.stringify(parsedEnv); + const { parsedEnv, strippedInternalKeys } = sanitized; + blobJson = sanitized.blobJson; // explicit trust - the sandbox path. The blob is authoritative regardless of where it // was resolved; directory/drift checks make no sense for a blob from another machine. if (mode === 'force') { return { - reuse: true, parsedEnv, blobJson, strippedInternalKeys, + reuse: true, parsedEnv, blobJson, strippedInternalKeys, source: 'env-blob', }; } @@ -254,6 +306,6 @@ export function evaluateInjectedEnvReuse(opts: { } return { - reuse: true, parsedEnv, blobJson, strippedInternalKeys, + reuse: true, parsedEnv, blobJson, strippedInternalKeys, source: 'env-blob', }; } diff --git a/packages/varlock/src/lib/test/frozen-env-file.test.ts b/packages/varlock/src/lib/test/frozen-env-file.test.ts new file mode 100644 index 000000000..19101c2b2 --- /dev/null +++ b/packages/varlock/src/lib/test/frozen-env-file.test.ts @@ -0,0 +1,281 @@ +import { + describe, test, expect, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + FROZEN_ENV_FILE_NAME, + FrozenEnvFileError, + USE_FROZEN_ENV_VAR, + getFrozenEnvFileInPlay, + readFrozenEnvFile, + resolveFrozenEnvFileMode, +} from '../frozen-env-file'; +import { evaluateInjectedEnvReuse, USE_INJECTED_ENV_VAR } from '../injected-env-reuse'; +import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../../runtime/crypto'; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-frozen-env-')); + // realpath so assertions aren't confused by symlinked tmp dirs (e.g. /tmp on macOS) + tempDir = fs.realpathSync(tempDir); +}); + +afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function graphJson(overrides?: Record) { + return JSON.stringify({ + basePath: tempDir, + sources: [], + settings: {}, + config: { + FOO: { value: 'foo-val', isSensitive: false }, + SECRET: { value: 'secret-val', isSensitive: true }, + }, + overrideKeys: [], + ...overrides, + }); +} + +/** write a frozen env file, encrypted unless `key` is null */ +function writeFrozenFile(opts?: { key?: string | null, contents?: string, fileName?: string }) { + const key = opts?.key === undefined ? generateEncryptionKeyHex() : opts.key; + const json = opts?.contents ?? graphJson(); + const filePath = path.join(tempDir, opts?.fileName ?? FROZEN_ENV_FILE_NAME); + fs.writeFileSync(filePath, `${key ? encryptEnvBlobSync(json, key) : json}\n`); + return { filePath, key }; +} + +describe('resolveFrozenEnvFileMode', () => { + test('defaults to auto at the default path', () => { + expect(resolveFrozenEnvFileMode({}, tempDir)).toEqual({ + mode: 'auto', filePath: path.join(tempDir, FROZEN_ENV_FILE_NAME), + }); + }); + + test.each(['1', 'true', 'TRUE', ' True '])('%s requires the file at the default path', (rawValue) => { + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: rawValue }, tempDir)).toEqual({ + mode: 'required', filePath: path.join(tempDir, FROZEN_ENV_FILE_NAME), + }); + }); + + test.each(['0', 'false', 'False'])('%s disables frozen env files', (rawValue) => { + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: rawValue }, tempDir)).toEqual({ mode: 'off' }); + }); + + test('an empty value is treated as unset', () => { + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: ' ' }, tempDir).mode).toBe('auto'); + }); + + test('any other value is a required path, resolved against cwd', () => { + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: 'dist/env.frozen' }, tempDir)).toEqual({ + mode: 'required', filePath: path.join(tempDir, 'dist/env.frozen'), + }); + }); + + test('absolute paths are used as-is', () => { + const abs = path.join(tempDir, 'somewhere', 'env.frozen'); + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: abs }, tempDir)).toEqual({ + mode: 'required', filePath: abs, + }); + }); + + // unlike _VARLOCK_USE_INJECTED_ENV (which maps unknown values back to auto), an + // unrecognized value here is a path - so a typo hard-errors as a missing file rather than + // silently disabling the pin + test('a typo`d disable value becomes a required path rather than disabling', () => { + expect(resolveFrozenEnvFileMode({ [USE_FROZEN_ENV_VAR]: 'off' }, tempDir)).toEqual({ + mode: 'required', filePath: path.join(tempDir, 'off'), + }); + expect(() => readFrozenEnvFile({ env: { [USE_FROZEN_ENV_VAR]: 'off' }, cwd: tempDir })) + .toThrow(/requires a frozen env file/); + }); +}); + +describe('readFrozenEnvFile', () => { + test('returns not-found when no file is present', () => { + expect(readFrozenEnvFile({ env: {}, cwd: tempDir })).toMatchObject({ + found: false, reason: expect.stringContaining('no frozen env file'), + }); + }); + + test('reads and decrypts a file at the default path', () => { + const { key } = writeFrozenFile(); + const result = readFrozenEnvFile({ env: { _VARLOCK_ENV_KEY: key! }, cwd: tempDir }); + expect(result.found).toBe(true); + if (result.found) expect(JSON.parse(result.blobJson).config.FOO.value).toBe('foo-val'); + }); + + test('reads a plaintext file', () => { + writeFrozenFile({ key: null }); + const result = readFrozenEnvFile({ env: {}, cwd: tempDir }); + expect(result.found).toBe(true); + if (result.found) expect(JSON.parse(result.blobJson).config.FOO.value).toBe('foo-val'); + }); + + test('reads a file at an explicit path', () => { + const { key } = writeFrozenFile({ fileName: 'custom.frozen' }); + const result = readFrozenEnvFile({ + env: { _VARLOCK_ENV_KEY: key!, [USE_FROZEN_ENV_VAR]: 'custom.frozen' }, + cwd: tempDir, + }); + expect(result.found).toBe(true); + }); + + test('does not read anything when disabled', () => { + writeFrozenFile({ key: null }); + expect(readFrozenEnvFile({ env: { [USE_FROZEN_ENV_VAR]: '0' }, cwd: tempDir })).toMatchObject({ + found: false, reason: expect.stringContaining('disabled'), + }); + }); + + test('throws when required but missing', () => { + expect(() => readFrozenEnvFile({ env: { [USE_FROZEN_ENV_VAR]: '1' }, cwd: tempDir })) + .toThrow(FrozenEnvFileError); + }); + + describe('fail-closed on a present but unusable file', () => { + test('throws when encrypted and no key is set', () => { + writeFrozenFile(); + expect(() => readFrozenEnvFile({ env: {}, cwd: tempDir })) + .toThrow(/_VARLOCK_ENV_KEY is not set/); + }); + + test('throws when the key is wrong', () => { + writeFrozenFile(); + expect(() => readFrozenEnvFile({ env: { _VARLOCK_ENV_KEY: generateEncryptionKeyHex() }, cwd: tempDir })) + .toThrow(/failed to decrypt/); + }); + + test('throws when the file is empty', () => { + fs.writeFileSync(path.join(tempDir, FROZEN_ENV_FILE_NAME), '\n'); + expect(() => readFrozenEnvFile({ env: {}, cwd: tempDir })).toThrow(/is empty/); + }); + + test('throws when combined with _VARLOCK_FILTER', () => { + writeFrozenFile({ key: null }); + expect(() => readFrozenEnvFile({ env: { _VARLOCK_FILTER: 'FOO' }, cwd: tempDir })) + .toThrow(/_VARLOCK_FILTER/); + }); + }); +}); + +describe('getFrozenEnvFileInPlay', () => { + test('undefined when disabled, or when nothing is present in auto mode', () => { + expect(getFrozenEnvFileInPlay({}, tempDir)).toBeUndefined(); + writeFrozenFile({ key: null }); + expect(getFrozenEnvFileInPlay({ [USE_FROZEN_ENV_VAR]: '0' }, tempDir)).toBeUndefined(); + }); + + test('returns the path when present, or when required but missing', () => { + expect(getFrozenEnvFileInPlay({ [USE_FROZEN_ENV_VAR]: '1' }, tempDir)) + .toBe(path.join(tempDir, FROZEN_ENV_FILE_NAME)); + const { filePath } = writeFrozenFile({ key: null }); + expect(getFrozenEnvFileInPlay({}, tempDir)).toBe(filePath); + }); +}); + +describe('evaluateInjectedEnvReuse with a frozen env file', () => { + test('consumes the file with no env files present, and reports its source', () => { + const { key } = writeFrozenFile(); + const decision = evaluateInjectedEnvReuse({ env: { _VARLOCK_ENV_KEY: key! }, cwd: tempDir }); + expect(decision.reuse).toBe(true); + if (decision.reuse) { + expect(decision.source).toBe('frozen-file'); + expect(decision.parsedEnv.config.SECRET.value).toBe('secret-val'); + } + }); + + // the whole point of freezing is that a deploy carries no .env files, so the basePath and + // source-fingerprint checks that gate automatic blob reuse cannot apply here + test('is authoritative even when resolved in a different directory', () => { + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-other-')); + try { + const { key } = writeFrozenFile({ + contents: graphJson({ + basePath: otherDir, + sources: [ + { + type: 'file', label: '.env', enabled: true, path: '.env', + }, + ], + }), + }); + const decision = evaluateInjectedEnvReuse({ env: { _VARLOCK_ENV_KEY: key! }, cwd: tempDir }); + expect(decision.reuse).toBe(true); + } finally { + fs.rmSync(otherDir, { recursive: true, force: true }); + } + }); + + test('wins over an ambient __VARLOCK_ENV blob', () => { + const { key } = writeFrozenFile(); + const decision = evaluateInjectedEnvReuse({ + env: { + _VARLOCK_ENV_KEY: key!, + __VARLOCK_ENV: graphJson({ config: { FOO: { value: 'from-ambient-blob', isSensitive: false } } }), + }, + cwd: tempDir, + }); + expect(decision.reuse).toBe(true); + if (decision.reuse) { + expect(decision.source).toBe('frozen-file'); + expect(decision.parsedEnv.config.FOO.value).toBe('foo-val'); + } + }); + + // the two sources are governed by separate flags + test('is not disabled by _VARLOCK_USE_INJECTED_ENV=0', () => { + const { key } = writeFrozenFile(); + const decision = evaluateInjectedEnvReuse({ + env: { _VARLOCK_ENV_KEY: key!, [USE_INJECTED_ENV_VAR]: '0' }, + cwd: tempDir, + }); + expect(decision.reuse).toBe(true); + }); + + test('falls through to the normal blob path when disabled', () => { + const { key } = writeFrozenFile(); + const decision = evaluateInjectedEnvReuse({ + env: { _VARLOCK_ENV_KEY: key!, [USE_FROZEN_ENV_VAR]: '0' }, + cwd: tempDir, + }); + expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('no injected env blob') }); + }); + + test('strips @internal items', () => { + const { key } = writeFrozenFile({ + contents: graphJson({ + config: { + FOO: { value: 'foo-val', isSensitive: false }, + SECRET_ZERO: { value: 'nope', isSensitive: true, isInternal: true }, + }, + }), + }); + const decision = evaluateInjectedEnvReuse({ env: { _VARLOCK_ENV_KEY: key! }, cwd: tempDir }); + expect(decision.reuse).toBe(true); + if (decision.reuse) { + expect(decision.strippedInternalKeys).toEqual(['SECRET_ZERO']); + expect(decision.parsedEnv.config.SECRET_ZERO).toBeUndefined(); + expect(JSON.parse(decision.blobJson).config.SECRET_ZERO).toBeUndefined(); + } + }); + + test('throws rather than falling back when the file is not a serialized graph', () => { + writeFrozenFile({ key: null, contents: JSON.stringify({ nope: true }) }); + expect(() => evaluateInjectedEnvReuse({ env: {}, cwd: tempDir })) + .toThrow(/not a valid serialized env graph/); + }); + + test('throws when the file was created from a failed resolution', () => { + writeFrozenFile({ key: null, contents: graphJson({ errors: { schemaErrors: [{ message: 'bad' }] } }) }); + expect(() => evaluateInjectedEnvReuse({ env: {}, cwd: tempDir })) + .toThrow(/contains errors/); + }); +}); From c4782708d1666fb34c72fd1ea8a98ac1de480791 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 29 Aug 2026 23:53:32 -0700 Subject: [PATCH 2/4] Add Elysia guide and frozen-env smoke tests - Elysia integration guide, covering setup, leak prevention through Elysia's Response-based handlers, and the freeze-based deploy flow for both `bun build` bundles and `--compile` binaries - End-to-end smoke tests: freeze, then boot from the artifact in a directory with no .env files, asserting values, coerced types, and sensitivity survive the round trip, and that a broken file fails closed even where resolution would otherwise succeed `varlock freeze` now refuses `--env` when the schema sets `@currentEnv`, rather than inheriting `load`'s silent-fallback behavior. There, a wrong `--env` just prints wrong output; here it bakes the wrong environment's values into a deploy artifact that is consumed without re-resolution, so nothing downstream gets another chance to catch it. The summary also names the environment it froze. --- .gitignore | 1 + .../src/content/docs/integrations/elysia.mdx | 143 +++++++++++++ packages/varlock-website/src/sidebar.ts | 1 + .../src/cli/commands/freeze.command.ts | 20 ++ .../varlock/src/cli/commands/run.command.ts | 2 +- .../smoke-test-frozen-env/.env.production | 3 + smoke-tests/smoke-test-frozen-env/.env.schema | 14 ++ smoke-tests/smoke-test-frozen-env/app.mjs | 9 + smoke-tests/tests/frozen-env.test.ts | 198 ++++++++++++++++++ smoke-tests/tests/injected-env-reuse.test.ts | 2 +- 10 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 packages/varlock-website/src/content/docs/integrations/elysia.mdx create mode 100644 smoke-tests/smoke-test-frozen-env/.env.production create mode 100644 smoke-tests/smoke-test-frozen-env/.env.schema create mode 100644 smoke-tests/smoke-test-frozen-env/app.mjs create mode 100644 smoke-tests/tests/frozen-env.test.ts diff --git a/.gitignore b/.gitignore index 03a150cb4..ab1ed8536 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ vite.config.ts.timestamp* env.d.ts smoke-tests/pnpm-lock.yaml +smoke-tests/bun.lock framework-tests/.packed framework-tests/.test-projects .magent diff --git a/packages/varlock-website/src/content/docs/integrations/elysia.mdx b/packages/varlock-website/src/content/docs/integrations/elysia.mdx new file mode 100644 index 000000000..c40a3a8bb --- /dev/null +++ b/packages/varlock-website/src/content/docs/integrations/elysia.mdx @@ -0,0 +1,143 @@ +--- +title: Elysia +description: How to use varlock with an Elysia server on Bun, including deploy-time frozen env +--- +import { Steps, Tabs, TabItem } from "@astrojs/starlight/components"; +import ExecCommandWidget from '@/components/ExecCommandWidget.astro'; +import InstallJsDepsWidget from '@/components/InstallJsDepsWidget.astro'; + +[Elysia](https://elysiajs.com) needs no integration package. Import `varlock/auto-load` and read values through the typed `ENV` proxy, the same as any other [JavaScript project](/integrations/javascript/). + +The part worth reading is [deploying](#deploying). Elysia has no build step that inlines env values, so unlike Next.js or Vite there is nothing to bake your config into. `varlock freeze` fills that gap. + +Check out the [Elysia example project](https://github.com/dmno-dev/varlock-examples/tree/main/integrations/elysia) for a working reference. + +--- + +## Setup + + + +1. **Install varlock** + + +1. **Run `varlock init` to set up your `.env.schema`** + + + +1. **Turn off Bun's own `.env` loading** + + Bun loads `.env` files itself based on `NODE_ENV`/`BUN_ENV`, which feeds values into varlock behind its back. Let varlock own env loading: + + ```toml title="bunfig.toml" + env = false + ``` + + See the [Bun integration docs](/integrations/bun/) for the other ways to disable it, including for compiled binaries. + +1. **Import `varlock/auto-load` first in your entrypoint** + + ```ts title="src/server.ts" ins={1,2} + import 'varlock/auto-load'; + import { ENV } from 'varlock/env'; + import { Elysia } from 'elysia'; + + new Elysia() + .get('/', () => ({ message: ENV.PUBLIC_MESSAGE })) + .listen(ENV.PORT); + ``` + + The import must come first, so env is loaded and validated before anything else runs. If you would rather keep it out of app code, drop the import and launch with `varlock run -- bun src/server.ts` instead. + + + +## Leak prevention + +Because varlock knows which items are `@sensitive`, both protections work in Elysia with no extra setup: + +```ts +.get('/log-demo', () => { + console.log('SOME_API_KEY =', ENV.SOME_API_KEY); // logged redacted + return 'ok'; +}) + +.get('/leak-demo', () => { + return { oops: ENV.SOME_API_KEY }; // blocked, request fails with a 500 +}) +``` + +Elysia builds responses with the standard `Response` API, which varlock patches, so a sensitive value returned from a handler is caught before it reaches the client. See [leak prevention](/guides/secrets/#leak-prevention). + +## Deploying + +Locally, varlock resolves your `.env` files on every boot. In a deploy that is usually the wrong default: it means config lives in your platform's settings rather than in the release, so it can't change atomically with your code and doesn't roll back with it. It also makes every boot depend on your secret backend being reachable. + +[`varlock freeze`](/guides/frozen-env/) resolves everything once at deploy time and writes an encrypted file that ships inside your deploy artifact. Your app boots from that file. + + + +1. **Generate a key, once** + + + + Set the result as `_VARLOCK_ENV_KEY` in your deploy pipeline and your runtime environment. + +1. **Bundle and freeze at deploy time** + + ```bash + bun build ./src/server.ts --target=bun --outdir dist + APP_ENV=production varlock freeze + ``` + + Run `freeze` where your `.env` files and secret backends are reachable, usually a CI job. It writes `.varlock-frozen-env` and prints which environment it captured. + +1. **Ship the frozen file with your code** + + ```dockerfile title="Dockerfile" + FROM oven/bun:1 + WORKDIR /app + COPY dist/server.js bunfig.toml ./ + COPY .varlock-frozen-env ./ + CMD ["bun", "server.js"] + ``` + + Copy it into the image rather than mounting it at runtime. Mounting it separately puts config back outside the release, which is the thing this avoids. + +1. **Boot normally** + + ```bash + bun server.js + ``` + + + +That image needs nothing else: no `.env` files, no `node_modules`, and no varlock CLI. Values, coerced types, and sensitivity all travel inside the frozen file, so `ENV.PORT` is still a number and log redaction still works. + +Set `_VARLOCK_USE_FROZEN_ENV=1` in production so a missing frozen file is a hard error instead of a silent fall back to normal resolution. + +:::caution[Values are pinned once frozen] +Rotating a secret takes effect on your next deploy, not on the next restart. Re-run `varlock freeze` and redeploy. See [tradeoffs](/guides/frozen-env/#tradeoffs) for the full list, including how key rotation interacts with rollback. +::: + +### Standalone binaries + +`bun build --compile` works the same way. The binary reads `.varlock-frozen-env` from its working directory, so ship the two together: + +```bash +bun build ./src/server.ts --compile --outfile server --no-compile-autoload-dotenv +APP_ENV=production varlock freeze +``` + +`--no-compile-autoload-dotenv` stops the compiled binary from doing Bun's own `.env` loading, matching the `bunfig.toml` setting above. + +### Choosing an environment + +If your schema sets [`@currentEnv`](/reference/root-decorators/#currentenv), the `--env` flag does not apply, and `varlock freeze` errors rather than silently capturing the wrong environment. Set the flag item instead: + +```bash +APP_ENV=production varlock freeze +``` + +### Other options + +`freeze` is not the only way to deploy. If the varlock CLI, your `.env` files, and your resolver credentials are all available in the runtime environment, `varlock run -- bun server.js` works too and re-resolves on every boot. See [related approaches](/guides/frozen-env/#related-approaches) for the comparison. diff --git a/packages/varlock-website/src/sidebar.ts b/packages/varlock-website/src/sidebar.ts index 759ae85ab..843ca0e80 100644 --- a/packages/varlock-website/src/sidebar.ts +++ b/packages/varlock-website/src/sidebar.ts @@ -156,6 +156,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [ items: [ { label: 'JavaScript / Node.js', slug: 'integrations/javascript' }, { label: 'Bun', slug: 'integrations/bun' }, + { label: 'Elysia', slug: 'integrations/elysia', badge: 'new' }, { label: 'Next.js', slug: 'integrations/nextjs' }, { label: 'Nuxt', slug: 'integrations/nuxt' }, { label: 'Vite-based', slug: 'integrations/vite' }, diff --git a/packages/varlock/src/cli/commands/freeze.command.ts b/packages/varlock/src/cli/commands/freeze.command.ts index dfb165882..3c6f7846e 100644 --- a/packages/varlock/src/cli/commands/freeze.command.ts +++ b/packages/varlock/src/cli/commands/freeze.command.ts @@ -45,6 +45,23 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = checkForConfigErrors(envGraph); showPluginWarnings(envGraph); + // Which environment actually got frozen. `--env` is only a fallback, so a schema using + // `@currentEnv` ignores it (same as `varlock load --env`) - but here that silently bakes + // the wrong environment's values into a deploy artifact, which is the exact failure this + // command exists to prevent. Refuse rather than warn: a frozen file is consumed without + // re-resolution, so nothing downstream gets another chance to catch it. + const envFlagKey = envGraph.rootDataSource?.envFlagKey; + const frozenEnv = envGraph.rootDataSource?.envFlagValue; + const requestedEnv = ctx.values.env; + if (requestedEnv && envFlagKey && String(frozenEnv) !== requestedEnv) { + throw new CliExitError( + `--env ${requestedEnv} was ignored: this schema sets @currentEnv, so the environment comes from ${envFlagKey} (currently "${frozenEnv}")`, + { + suggestion: `Set the value instead, e.g. \`${envFlagKey}=${requestedEnv} varlock freeze\`, and drop --env.`, + }, + ); + } + const serialized = envGraph.getSerializedGraph(); // Override provenance describes process.env overrides at the ORIGINAL invocation, so @@ -70,6 +87,9 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const relOutPath = path.relative(process.cwd(), outPath) || outPath; console.log(`Froze ${itemCount} env var${itemCount === 1 ? '' : 's'} into ${ansis.bold(relOutPath)}`); + // always state the environment - this file gets shipped, and picking the wrong one is + // the easiest mistake to make and the hardest to notice + if (frozenEnv !== undefined) console.log(ansis.gray(` environment: ${ansis.bold(String(frozenEnv))}`)); console.log(ansis.gray(` ${encryptionKey ? 'encrypted with _VARLOCK_ENV_KEY' : 'UNENCRYPTED'}`)); console.log(''); diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 1038e2a2c..c6f7906e8 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -181,7 +181,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = let serializedGraph: SerializedEnvGraph; if (reuseDecision.reuse) { - debug('reusing injected env blob - skipping resolution'); + debug('reusing pre-resolved env from %s - skipping resolution', reuseDecision.source); serializedGraph = reuseDecision.parsedEnv; // same shape as getResolvedEnvStringObject: unset items stay undefined, so they still // mask any inherited value when the child env is built. The blob never carries diff --git a/smoke-tests/smoke-test-frozen-env/.env.production b/smoke-tests/smoke-test-frozen-env/.env.production new file mode 100644 index 000000000..09e41fec7 --- /dev/null +++ b/smoke-tests/smoke-test-frozen-env/.env.production @@ -0,0 +1,3 @@ +PUBLIC_VAR=public-value-prod +SECRET_TOKEN=prod-token +COERCED_FLAG=true diff --git a/smoke-tests/smoke-test-frozen-env/.env.schema b/smoke-tests/smoke-test-frozen-env/.env.schema new file mode 100644 index 000000000..a29131276 --- /dev/null +++ b/smoke-tests/smoke-test-frozen-env/.env.schema @@ -0,0 +1,14 @@ +# @defaultSensitive=false +# @currentEnv=$APP_ENV +# --- + +# @type=enum(development,production) +APP_ENV=development + +PUBLIC_VAR=public-value + +# @sensitive +SECRET_TOKEN=dev-token + +# @type=boolean +COERCED_FLAG=false diff --git a/smoke-tests/smoke-test-frozen-env/app.mjs b/smoke-tests/smoke-test-frozen-env/app.mjs new file mode 100644 index 000000000..30ac3ac71 --- /dev/null +++ b/smoke-tests/smoke-test-frozen-env/app.mjs @@ -0,0 +1,9 @@ +import 'varlock/auto-load'; +import { ENV } from 'varlock/env'; + +// print comparison results rather than raw values, so redaction can't hide what we assert on +console.log(`APP_ENV=${ENV.APP_ENV}`); +console.log(`PUBLIC_VAR=${ENV.PUBLIC_VAR}`); +console.log(`SECRET_OK=${ENV.SECRET_TOKEN === 'prod-token' && process.env.SECRET_TOKEN === 'prod-token'}`); +// types survive the freeze/thaw round trip - a string "true" would fail this +console.log(`COERCED_FLAG_IS_BOOL=${ENV.COERCED_FLAG === true}`); diff --git a/smoke-tests/tests/frozen-env.test.ts b/smoke-tests/tests/frozen-env.test.ts new file mode 100644 index 000000000..86fd8a7f4 --- /dev/null +++ b/smoke-tests/tests/frozen-env.test.ts @@ -0,0 +1,198 @@ +import { + describe, test, expect, beforeAll, afterAll, afterEach, +} from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; +import { runVarlock, varlockRun } from '../helpers/run-varlock.js'; + +// End-to-end tests for `varlock freeze` + booting from the resulting `.varlock-frozen-env`: +// - the artifact is consumed in a directory with NO .env files and no varlock CLI +// - values, coerced types, and sensitivity all survive the round trip +// - a file that is present but unusable fails closed rather than re-resolving +// See https://varlock.dev/guides/frozen-env/ + +const SCENARIO = 'smoke-test-frozen-env'; +const SCENARIO_DIR = join(import.meta.dirname, '..', SCENARIO); +const FROZEN_FILE = join(SCENARIO_DIR, '.varlock-frozen-env'); + +let encryptionKey: string; +/** a deploy-like dir: the app + the frozen artifact, and nothing else */ +let deployDir: string; + +/** env vars that could leak in from the test runner's own environment and mask a failure */ +const ISOLATED_KEYS = [ + '__VARLOCK_ENV', + '_VARLOCK_ENV_KEY', + '_VARLOCK_USE_INJECTED_ENV', + '_VARLOCK_USE_FROZEN_ENV', + 'APP_ENV', + 'PUBLIC_VAR', + 'SECRET_TOKEN', + 'COERCED_FLAG', +]; + +function runApp(opts: { cwd?: string, env?: Record } = {}) { + const env: Record = { ...process.env, ...opts.env }; + for (const key of ISOLATED_KEYS) { + if (!(opts.env && key in opts.env)) delete env[key]; + } + const result = spawnSync(process.execPath, ['app.mjs'], { + cwd: opts.cwd ?? deployDir, + env: env as NodeJS.ProcessEnv, + encoding: 'utf-8', + }); + return { + exitCode: result.status ?? 1, + output: (result.stdout ?? '') + (result.stderr ?? ''), + }; +} + +function freeze(opts?: { args?: Array, env?: Record }) { + return runVarlock(['freeze', ...(opts?.args ?? [])], { + cwd: SCENARIO, + env: { APP_ENV: 'production', _VARLOCK_ENV_KEY: encryptionKey, ...opts?.env }, + }); +} + +beforeAll(() => { + const keyResult = runVarlock(['generate-key', '--plain']); + expect(keyResult.exitCode).toBe(0); + encryptionKey = keyResult.stdout.trim(); + + const result = freeze(); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('environment: production'); + + // node_modules is symlinked so `varlock/auto-load` resolves, but there are no .env files + // here at all - everything the app sees has to come out of the frozen artifact + deployDir = fs.mkdtempSync(join(os.tmpdir(), 'varlock-frozen-deploy-')); + fs.copyFileSync(join(SCENARIO_DIR, 'app.mjs'), join(deployDir, 'app.mjs')); + fs.copyFileSync(FROZEN_FILE, join(deployDir, '.varlock-frozen-env')); + fs.symlinkSync( + join(import.meta.dirname, '..', 'node_modules'), + join(deployDir, 'node_modules'), + 'dir', + ); +}); + +afterAll(() => { + fs.rmSync(FROZEN_FILE, { force: true }); + if (deployDir) fs.rmSync(deployDir, { recursive: true, force: true }); +}); + +describe('varlock freeze', () => { + test('refuses to write an unencrypted file without a key', () => { + const result = runVarlock(['freeze', '--out', '.varlock-frozen-env-nokey'], { + cwd: SCENARIO, + env: { APP_ENV: 'production', _VARLOCK_ENV_KEY: '' }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('_VARLOCK_ENV_KEY is not set'); + expect(fs.existsSync(join(SCENARIO_DIR, '.varlock-frozen-env-nokey'))).toBe(false); + }); + + test('writes an encrypted file', () => { + const contents = fs.readFileSync(FROZEN_FILE, 'utf8'); + expect(contents.startsWith('varlock:v1:')).toBe(true); + // no resolved value should be greppable in the artifact + expect(contents).not.toContain('prod-token'); + }); + + // --env is only a fallback, so a @currentEnv schema ignores it - silently freezing the + // wrong environment into a deploy artifact is the failure this command exists to prevent + test('refuses --env when the schema sets @currentEnv', () => { + const result = runVarlock(['freeze', '--out', '.varlock-frozen-env-badenv', '--env', 'production'], { + cwd: SCENARIO, + env: { _VARLOCK_ENV_KEY: encryptionKey, APP_ENV: 'development' }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('was ignored'); + expect(result.output).toContain('APP_ENV'); + expect(fs.existsSync(join(SCENARIO_DIR, '.varlock-frozen-env-badenv'))).toBe(false); + }); +}); + +describe('booting from a frozen env file', () => { + test('hydrates everything with no .env files and no CLI present', () => { + const result = runApp({ env: { _VARLOCK_ENV_KEY: encryptionKey } }); + expect(result.exitCode, result.output).toBe(0); + // the production values, not the schema defaults + expect(result.output).toContain('APP_ENV=production'); + expect(result.output).toContain('PUBLIC_VAR=public-value-prod'); + expect(result.output).toContain('SECRET_OK=true'); + // coerced types survive the round trip (a string "true" would fail this) + expect(result.output).toContain('COERCED_FLAG_IS_BOOL=true'); + }); + + test('varlock run consumes it too', () => { + const result = varlockRun(['node', 'app.mjs'], { + cwd: SCENARIO, + env: { _VARLOCK_ENV_KEY: encryptionKey, DEBUG: 'varlock:auto-load' }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('reusing pre-resolved env from frozen-file'); + expect(result.output).toContain('APP_ENV=production'); + }); + + describe('fails closed rather than silently re-resolving', () => { + test('when the key is missing', () => { + const result = runApp(); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('_VARLOCK_ENV_KEY is not set'); + }); + + test('when the key is wrong', () => { + const otherKey = runVarlock(['generate-key', '--plain']).stdout.trim(); + const result = runApp({ env: { _VARLOCK_ENV_KEY: otherKey } }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('failed to decrypt'); + }); + + // this is the case that matters most: the scenario dir HAS .env files, so falling back + // would boot happily on re-resolved values and never signal that the pin was lost + test('when the file is broken in a directory that could otherwise resolve', () => { + const brokenFile = join(SCENARIO_DIR, '.varlock-frozen-env'); + const original = fs.readFileSync(brokenFile, 'utf8'); + fs.writeFileSync(brokenFile, 'varlock:v1:not-a-real-blob\n'); + try { + const result = runApp({ cwd: SCENARIO_DIR, env: { _VARLOCK_ENV_KEY: encryptionKey } }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('failed to decrypt'); + expect(result.output).not.toContain('APP_ENV=development'); + } finally { + fs.writeFileSync(brokenFile, original); + } + }); + }); + + describe('_VARLOCK_USE_FROZEN_ENV', () => { + afterEach(() => { + const movedFile = `${join(deployDir, '.varlock-frozen-env')}.bak`; + if (fs.existsSync(movedFile)) fs.renameSync(movedFile, join(deployDir, '.varlock-frozen-env')); + }); + + test('=1 makes a missing file a hard error', () => { + fs.renameSync(join(deployDir, '.varlock-frozen-env'), `${join(deployDir, '.varlock-frozen-env')}.bak`); + const result = runApp({ env: { _VARLOCK_ENV_KEY: encryptionKey, _VARLOCK_USE_FROZEN_ENV: '1' } }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('requires a frozen env file'); + }); + + test('accepts an explicit path', () => { + fs.renameSync(join(deployDir, '.varlock-frozen-env'), `${join(deployDir, '.varlock-frozen-env')}.bak`); + const result = runApp({ + env: { _VARLOCK_ENV_KEY: encryptionKey, _VARLOCK_USE_FROZEN_ENV: '.varlock-frozen-env.bak' }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('APP_ENV=production'); + }); + + test('=0 ignores the file entirely', () => { + // the deploy dir has no .env files, so ignoring the artifact leaves nothing to resolve + const result = runApp({ env: { _VARLOCK_ENV_KEY: encryptionKey, _VARLOCK_USE_FROZEN_ENV: '0' } }); + expect(result.output).not.toContain('PUBLIC_VAR=public-value-prod'); + }); + }); +}); diff --git a/smoke-tests/tests/injected-env-reuse.test.ts b/smoke-tests/tests/injected-env-reuse.test.ts index 121f414b9..810e5f128 100644 --- a/smoke-tests/tests/injected-env-reuse.test.ts +++ b/smoke-tests/tests/injected-env-reuse.test.ts @@ -13,7 +13,7 @@ import { varlockRun, runVarlock, VARLOCK_CLI } from '../helpers/run-varlock.js'; const SCENARIO = 'smoke-test-injected-env'; const SCENARIO_DIR = join(import.meta.dirname, '..', SCENARIO); -const REUSED_MSG = 'reusing injected env blob'; +const REUSED_MSG = 'reusing pre-resolved env from env-blob'; const RESOLVED_MSG = 'resolving env via CLI'; function runNodeApp(opts: { cwd?: string; env?: Record } = {}) { From bf7849d0cf13e9163b4cf9e0eda39cc1c6afe5b8 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 1 Sep 2026 13:49:22 -0700 Subject: [PATCH 3/4] Fix _VARLOCK_FILTER conflict message pointing at a nonexistent flag The error told users to "freeze with --filter instead", but `varlock freeze` has no --filter and ignores _VARLOCK_FILTER, so the suggested remedy could never work. That asymmetry is deliberate: a partial seal would leave keys outside the filter neither sealed nor validated, which is the split-validation state freezing exists to prevent. So the remedy is to drop one or the other, and the message now says that. --- packages/varlock/src/lib/frozen-env-file.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/varlock/src/lib/frozen-env-file.ts b/packages/varlock/src/lib/frozen-env-file.ts index 2750eea9f..70acbef5c 100644 --- a/packages/varlock/src/lib/frozen-env-file.ts +++ b/packages/varlock/src/lib/frozen-env-file.ts @@ -111,11 +111,16 @@ export function readFrozenEnvFile(opts: { env: EnvRecord, cwd?: string }): Froze return { found: false, reason: `no frozen env file at ${filePath}` }; } - // A frozen file is an unscoped snapshot of the whole graph, so honoring _VARLOCK_FILTER - // would hand over values the caller expected to exclude (same reasoning as the blob path). + // A frozen file is a complete, already-validated snapshot of the graph, so honoring + // _VARLOCK_FILTER would hand over values the caller expected to exclude (same reasoning as + // the blob path). `varlock freeze` deliberately has no --filter: a partial seal would mean + // keys outside the scope are neither sealed nor validated, which is the split-validation + // state the whole feature exists to prevent. So the remedy is to drop one or the other, + // never to re-freeze with a matching filter. if (env._VARLOCK_FILTER) { throw new FrozenEnvFileError( - `a frozen env file (${filePath}) cannot be combined with _VARLOCK_FILTER - freeze with --filter instead`, + `a frozen env file (${filePath}) cannot be combined with _VARLOCK_FILTER` + + ' - unset _VARLOCK_FILTER to use the frozen env, or set _VARLOCK_USE_FROZEN_ENV=0 to resolve from .env files instead', ); } From 605e44c214d21e29bebd0af20ab1fe130234243f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 1 Sep 2026 14:24:28 -0700 Subject: [PATCH 4/4] Document the total seal, and pin it with tests A frozen file is authoritative: it wins over env supplied at boot, and process.env is kept in agreement with ENV. That is the most surprising thing about the feature and the guide never said it, which is a problem because it is the exact shape that took production down in #1055 (`docker run -e REDIS_URL=...` against an image whose snapshot lacked the key). The behavior is correct and deliberate. `injectedAtBuild` exists so an *implicit* bake preserves runtime env, because those users never asked for a seal. Freezing is opt-in and its whole promise is a validated unit, so a value injected afterwards was part of neither resolution. Tests pin both halves plus a control showing the same ambient value is honored as a normal override when no seal is present, so a later change can't quietly give freeze the baked-snapshot semantics. Also states plainly that freezing is all or nothing today: if some values must come from the container, freeze is not the right tool for that service yet, and marking the key @optional to get past the validation refusal is the wrong fix (it weakens the schema and the seal still clears the operator's value). --- .../src/content/docs/guides/frozen-env.mdx | 21 ++++++++++ smoke-tests/smoke-test-frozen-env/.env.schema | 5 +++ smoke-tests/smoke-test-frozen-env/app.mjs | 7 ++++ smoke-tests/tests/frozen-env.test.ts | 40 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/packages/varlock-website/src/content/docs/guides/frozen-env.mdx b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx index fb1e93461..d1e1d1bd6 100644 --- a/packages/varlock-website/src/content/docs/guides/frozen-env.mdx +++ b/packages/varlock-website/src/content/docs/guides/frozen-env.mdx @@ -121,6 +121,27 @@ Encryption protects against exactly that: image layers, registry access, CI arti `--allow-plaintext` exists for schemas with nothing sensitive in them. It prints a warning, and there is no config setting for it, so it cannot be turned on once and forgotten. +## The frozen file wins over runtime env + +This is the most important thing to understand before you use it. + +A frozen file is a complete, already-validated snapshot, and it is **authoritative**. Environment variables set when the container starts do not override it: + +```bash +# the frozen file has DATABASE_URL, so this is ignored +docker run -e DATABASE_URL=postgres://somewhere-else/db my-image +``` + +For a key that resolved to a value at freeze time, the frozen value wins. For a key that resolved to nothing, `process.env` is cleared to match, so `process.env.KEY` and `ENV.KEY` always agree. Nothing silently reads one resolution while something else reads another. + +This is deliberate: freezing means the config was resolved and validated as a unit, and a value injected afterwards was part of neither. Accepting it would mean running on config that nothing validated. + +:::caution[Config that genuinely varies per container] +Freezing is all or nothing: there is currently no way to seal most of your config while leaving a few keys to the runtime. If some values must come from the container rather than the deploy (a per-container `DATABASE_URL`, say), freezing is not the right tool for that service yet. Use [`varlock run`](/reference/cli/load-and-run/#run), or let the app resolve at boot, so every value is validated where it is supplied. + +In particular, marking such a key `@optional` so that `varlock freeze` stops refusing to write is the **wrong fix**. It gets the freeze to succeed, but it permanently weakens the schema, so nothing enforces the value at boot either, and the seal then clears whatever the operator supplied. You end up with no value and no error. +::: + ## Tradeoffs **Rotating a secret takes effect on your next deploy, not on the next restart.** This is the entire point of freezing, but it inverts what most secret managers do, so everyone touching the deploy should know it. To pick up a rotated value, re-run `varlock freeze` and redeploy. diff --git a/smoke-tests/smoke-test-frozen-env/.env.schema b/smoke-tests/smoke-test-frozen-env/.env.schema index a29131276..c0e64b24e 100644 --- a/smoke-tests/smoke-test-frozen-env/.env.schema +++ b/smoke-tests/smoke-test-frozen-env/.env.schema @@ -12,3 +12,8 @@ SECRET_TOKEN=dev-token # @type=boolean COERCED_FLAG=false + +# declared but unset: the shape of the REDIS_URL incident, where an operator +# supplies a value at boot that the seal does not have +# @optional +UNSET_IN_SEAL= diff --git a/smoke-tests/smoke-test-frozen-env/app.mjs b/smoke-tests/smoke-test-frozen-env/app.mjs index 30ac3ac71..2f4bc2db0 100644 --- a/smoke-tests/smoke-test-frozen-env/app.mjs +++ b/smoke-tests/smoke-test-frozen-env/app.mjs @@ -7,3 +7,10 @@ console.log(`PUBLIC_VAR=${ENV.PUBLIC_VAR}`); console.log(`SECRET_OK=${ENV.SECRET_TOKEN === 'prod-token' && process.env.SECRET_TOKEN === 'prod-token'}`); // types survive the freeze/thaw round trip - a string "true" would fail this console.log(`COERCED_FLAG_IS_BOOL=${ENV.COERCED_FLAG === true}`); + +// the seal is total: it wins over anything the operator sets at boot, and process.env +// is kept in agreement with ENV so nothing reads one resolution while something else +// reads another. printed raw so the tests can assert on both halves. +console.log(`SEALED_UNSET_env=${JSON.stringify(process.env.UNSET_IN_SEAL)}`); +console.log(`SEALED_UNSET_ENV=${JSON.stringify(ENV.UNSET_IN_SEAL)}`); +console.log(`SEALED_SET_env=${JSON.stringify(process.env.PUBLIC_VAR)}`); diff --git a/smoke-tests/tests/frozen-env.test.ts b/smoke-tests/tests/frozen-env.test.ts index 86fd8a7f4..4d2250d75 100644 --- a/smoke-tests/tests/frozen-env.test.ts +++ b/smoke-tests/tests/frozen-env.test.ts @@ -31,6 +31,7 @@ const ISOLATED_KEYS = [ 'PUBLIC_VAR', 'SECRET_TOKEN', 'COERCED_FLAG', + 'UNSET_IN_SEAL', ]; function runApp(opts: { cwd?: string, env?: Record } = {}) { @@ -167,6 +168,45 @@ describe('booting from a frozen env file', () => { }); }); + // The seal is authoritative: it wins over env supplied at boot, and process.env is kept + // in agreement with ENV. This is the opposite of a build-baked snapshot, which sets + // `injectedAtBuild` so runtime env survives (see PR #1055) - baking is implicit and never + // asked for a seal, freezing is opt-in and its whole promise is a validated unit. + // These pin the behavior so a later change can't quietly give freeze the baked semantics. + describe('the seal is total', () => { + test('a value defined in the seal wins over an ambient one', () => { + const result = runApp({ + env: { _VARLOCK_ENV_KEY: encryptionKey, PUBLIC_VAR: 'from-operator' }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('PUBLIC_VAR=public-value-prod'); + // process.env agrees with ENV rather than keeping the operator's value + expect(result.output).toContain('SEALED_SET_env="public-value-prod"'); + }); + + test('a key that resolved to nothing clears an ambient value', () => { + const result = runApp({ + env: { _VARLOCK_ENV_KEY: encryptionKey, UNSET_IN_SEAL: 'from-operator' }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('SEALED_UNSET_env=undefined'); + expect(result.output).toContain('SEALED_UNSET_ENV=undefined'); + }); + + // the control: without a seal, the same ambient value acts as an override and is + // resolved + validated normally. This is what shows the clearing above is specific to + // sealed payloads rather than general varlock behavior. + test('control: without a seal the same ambient value is honored as an override', () => { + const result = runApp({ + cwd: SCENARIO_DIR, + env: { _VARLOCK_USE_FROZEN_ENV: '0', APP_ENV: 'production', UNSET_IN_SEAL: 'from-operator' }, + }); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain('SEALED_UNSET_env="from-operator"'); + expect(result.output).toContain('SEALED_UNSET_ENV="from-operator"'); + }); + }); + describe('_VARLOCK_USE_FROZEN_ENV', () => { afterEach(() => { const movedFile = `${join(deployDir, '.varlock-frozen-env')}.bak`;