diff --git a/.eslintrc.json b/.eslintrc.json index 90daeec..37eb955 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,5 +3,20 @@ "parserOptions": { "ecmaVersion": 2017 }, + "overrides": [ + { + "files": ["test/**/*.js", "vitest.config.mjs"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + "node/no-unsupported-features/es-syntax": "off", + "node/no-unsupported-features/node-builtins": "off", + "node/no-missing-import": "off", + "node/no-unpublished-import": "off" + } + } + ], "root": true } diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..182f314 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + cache: npm + node-version: 22 + + # The real 1Password CLI for the op stdin-detection tests - no + # session or account is needed (op validates stdin before + # authenticating). Pinned so the detection-error expectations + # stay stable; bump deliberately alongside a local check. + - name: Install 1Password CLI + run: | + curl -sSfL "https://cache.agilebits.com/dist/1P/op2/pkg/v2.39.0/op_linux_amd64_v2.39.0.zip" -o op.zip + unzip -o -d op op.zip + install -m 755 op/op /usr/local/bin/op + op --version + + - run: npm ci + + - run: npm test diff --git a/.npmignore b/.npmignore index 2185696..58524c8 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,5 @@ .editorconfig .eslintrc +.github +test +vitest.config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8635bd1..a494022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ This file is a history of the changes made to @idearium/cli. +## v6.2.0 - 2026-09-30 + +### Added + +- The secrets contract: a `secrets` block in `c.js` (services, a sibling `shared` block for keys whose GSM ids carry no service segment, and `dev` build-time consumers) that becomes the single source of truth for secret-shaped manifests. With a contract present, `c kc manifests|apply|start` generate SecretProviderClasses, SecretSyncs, the secret-sync ServiceAccount (deployed environments) and local Secrets (1Password references); a conflicting committed file is a hard error. Without a contract, manifest behaviour is unchanged. +- `c op get|set` and `c secrets get|push|add|verify|ls|consumers|init`. Values are only ever displayed as truncated hashes (`--plaintext` is terminal-gated and agent-blocked with a discouraged `--force`); writes flow through stdin pipes; `push` verifies its GSM readback by hash; `add` is ordered, idempotent and rollback-safe across c.js, 1Password and GSM and never touches the cluster; `consumers` gates deletions and annotates each consuming function with the env parsed from its name; `init` drafts a contract from existing declarations (warm 1Password session required), binding `shared.opItem` and removing legacy duplicates by comparing value hashes. +- `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. +- Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. +- The cli owns the 1Password session lifecycle: `c op session` signs in and stores the raw session token at `~/.local/state/idearium/op-session` (`0600`, enforced on overwrite), and `c op cmd [command...]` runs any `op` command with that session passed via `--session` (exit code propagates). Every internal `op` invocation (`c op get|set`, `c secrets *`, `c kc` template injection) passes the session the same way, so no `OP_SESSION_` env var name is guessed anywhere; an exported `OP_SESSION_idearium` still takes precedence for `eval $(op signin)` users. +- `c secrets verify` prints a headed section per check phase with output streaming as each check runs, prefetches its data in bulk (one 1Password item fetch per item, one GSM list, bounded-concurrency GSM reads, one kubectl fetch per secret), and reports function bindings referencing deleted GSM secrets (`MISSING`), contract drift (`STALE`) and cross-env misbindings (`MISBOUND`). + +### Fixed + +- c.js contract edits are anchored to `secrets.services` and verified after writing: `c op set` and `c secrets add` previously matched the first `name: {` block anywhere in `c.js`, silently editing a same-named docker location instead of the contract service. Every edit is now re-parsed from disk and rolled back when it does not land in the contract. +- Generating local Secret manifests for unbound owners (a service without an `opItem`, or an unbound `shared` block) embedded a literal `null` as the secret value; generation now fails with a remedy instead. +- `c op get` no longer creates 1Password items for unbound services (reads never create; write paths still resolve-or-create). +- A bare or invalid `--length`/`--type` on `c op set --generate` and `c secrets add --generate` failed with a raw crypto error (`NaN`); both now fail with usage hints. +- `c secrets verify` now compares each synced Kubernetes Secret's value hash against GSM (hashes were previously displayed with a hard-coded `ok`, so drifted values still reported `VERIFIED`). +- `c op cmd` and `c kc cmd` now dispatch with the verbatim argv: commander's git-style dispatch re-parsed subcommand args at every layer first, silently dropping `--` and unknown options (breaking e.g. `c op cmd run -- sh -c ...` and `c kc cmd` commands carrying kubectl flags). + ## v6.1.0 - 2026-09-16 ### Added diff --git a/README.md b/README.md index f9afd66..a931027 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ The following is a summary of the top level commands. - `c mk` is for everything Minikube. - `c mongo` is for MongoDB connections. - `c npm` is for everything NPM. +- `c op` manages 1Password secret values for the secrets contract. - `c project` is for project management. +- `c secrets` manages project secrets across their stores (1Password, GSM, Kubernetes). Be aware that `kubectl` uses a global configuration, but the `c kc` command supercedes those where possible and ensures kubectl runs in the context of the project you're within. @@ -271,6 +273,8 @@ The `c kc apply` will automatically provide the values for `namespace`, `prefix` ##### Secret templates +> With a secrets contract in `c.js` (see [Secrets configuration](#secrets-configuration) below), secret-shaped templates are generated automatically — you don't commit them. This section applies to repositories without a contract. + Any template (`.yaml.tmpl`) can contain [1Password secret references](https://www.1password.dev/connect/knowledge-base/secrets-references/) (i.e. `op://vault/item/section/field`). The template is rendered (placeholders) first, and then any references are resolved with the 1Password cli (`op inject`) - only when present. Rendering first means secret values never flow through the template engine (a value containing curly braces could otherwise be mangled). As a consequence, references must be **bare** (i.e. `op://vault/item/section/field`, not wrapped in `{{ }}`): placeholders are gone by the time references are resolved, and a wrapped reference would be blanked as an unknown placeholder. When references are present, the cli first ensures the 1Password cli is installed and authenticated (via `op whoami`), halting with a friendly message if not (`eval $(op signin)`). Templates without secret references never invoke `op`. This makes it possible to commit a template containing secret references, and have the compiled manifest (within `.compiled`, which should be gitignored) contain the actual secrets, ready to be deployed to Kubernetes. @@ -292,6 +296,60 @@ When the template is compiled, each `stringData` value will be base64 encoded an Compiled secret manifests contain plaintext secrets, so they are treated as sensitive: they're written with `0600` permissions, and removed once they've been applied to Kubernetes. `c kc start` and `c kc apply` remove them after applying, and `c skaffold dev` removes them (via `c kc secrets-clean`) when it exits. `c kc manifests` intentionally leaves them in place, as its compiled output is its purpose. This only applies to the local environment; other environments are unaffected. +### Secrets configuration + +The `secrets` block of `c.js` is the secrets contract: the single source of truth for every secret-shaped manifest and value reference. With a contract present, the cli generates SecretProviderClasses, SecretSyncs and the secret-sync ServiceAccount for deployed environments, and local Secrets (1Password references) for the local environment, whenever manifests are compiled (`c kc manifests`, `c kc apply`, `c kc start`). A committed manifest file that the contract also generates is a hard error - the contract is the one true source. Without a contract, nothing changes. + +```js +secrets: { + dev: { + opItem: '{dev-item-id}', // {org}-{repo}/dev + consumers: { + 'site-build': { keys: ['FONTAWESOME_AUTH_TOKEN'] }, + }, + }, + services: { + site: { + opItem: '{item-id}', + keys: ['CACHE_URL', 'RAS_API_KEY'], // owned + sharedKeys: ['IMGIX_TOKEN'], // borrowed + }, + 'prismic-published-workflow': { + sharedKeys: ['prismic-published-algolia.BASIC_AUTH'], // dotted owner.KEY + }, + }, + shared: { + // keys whose GSM ids carry no service segment + // (org-repo-env-key); opItem points at the item storing the + // values, usually a reference - never auto-created. + opItem: 'services.site', + keys: ['IMGIX_TOKEN'], + }, +}, +``` + +Rules (enforced at load): + +- `opItem` values are 1Password item ids or `services.`/`dev` references (one hop - each id appears exactly once). Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. +- A bare `sharedKeys` name resolves to its unique owner across `services` and `shared`; dotted `'owner.KEY'` disambiguates. +- GSM secret ids derive as `{org}-{repo}-{env}[-{service}]-{key}` - no literal ids anywhere. +- Region comes from `gcloud.region`; project from `project.gcpProjectId`. + +Commands (values are only ever displayed as truncated hashes; `--plaintext` requires a terminal and is blocked in agent/CI contexts unless forced): + +- `c secrets init` - draft a contract from existing declarations (requires a warm 1Password session; no-op when a contract exists). Binds `shared.opItem` by comparing value hashes and removes value-verified legacy duplicates. +- `c secrets add ` - add a key end to end (1Password, GSM, c.js). Never touches the cluster. +- `c secrets push ` - push the 1Password value to GSM, verified by hash readback. +- `c secrets get ` / `c op get` - value summaries (hash, reference, length). +- `c op set ` - upsert a value from stdin (or `--generate`). +- `c secrets verify ` - reconcile structure + 1Password/GSM/Kubernetes hashes, stale 1Password fields (`op:EXTRA`), deployed-functions drift (`STALE`, `MISSING`) and cross-env misbindings (`MISBOUND`). +- `c secrets ls ` - the environment's contract-derived GSM inventory. +- `c secrets consumers ` - everything referencing a GSM secret id, with each consuming function's name-parsed env. Gate every deletion on this. + +Commands that need 1Password, gcloud or kubectl fail fast with friendly re-authentication instructions when their credentials have expired. + +**A note on the 1Password session.** `c op session` stores the session token at `~/.local/state/idearium/op-session` (`0600`), and every `op` invocation the cli makes passes it via `--session` (as does `c op cmd`). That argument is visible in the local process list - the same exposure any `op --session` or `OP_SESSION_*` environment carries. It is machine-local and expires, and secret values themselves never appear in argv: they move via stdin pipes only. + ### MongoDB configuration The Idearium cli supports a MongoDB configuration. The MongoDB configuration can be used to access local and remote databases. diff --git a/bin/c-kc-apply.js b/bin/c-kc-apply.js index f5000c1..19d74ed 100644 --- a/bin/c-kc-apply.js +++ b/bin/c-kc-apply.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -18,6 +17,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .arguments('') @@ -101,32 +101,31 @@ return Promise.all([loadConfig(), loadState()]) return reject(e); } - return resolve([services, path, state]); + return resolve([services, path, state, config, namespace]); }) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state, config, namespace]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [services, path, state]) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state]) => + ensureServiceFilesExist(path, services).then(() => [ + services, + path, + state, + ]) + ) + .then(([services, path, state]) => + renderServicesTemplates(path, services).then(() => [ + services, + path, + state, + ]) ) .then( ([services, path, state]) => diff --git a/bin/c-kc-cmd.js b/bin/c-kc-cmd.js index 92df2ff..f47405e 100644 --- a/bin/c-kc-cmd.js +++ b/bin/c-kc-cmd.js @@ -1,10 +1,14 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); const getPropertyPath = require('get-value'); -const { spawn } = require('child_process'); -const { loadConfig, loadState, reportError } = require('./lib/c'); + +const { + loadConfig, + loadState, + reportError, + spawnWithExitCode, +} = require('./lib/c'); const { formatProjectPrefix } = require('./lib/c-project'); program @@ -26,26 +30,20 @@ return Promise.all([loadState('env'), loadConfig()]) `kubernetes.environments.${env}.namespace` ) || formatProjectPrefix(organisation, name, env, true, true); - // Remove the first two entries. - const parts = program.rawArgs.slice(2); - - // The command starts with kubectrl. - parts.unshift('kubectl'); - - // Mandatory namespace and context. - parts.push( - '--namespace', - namespace, - '--context', - environments[env].context - ); - - // The rest of the command. - const cmd = parts.join(' '); - - spawn(cmd, { - shell: true, - stdio: 'inherit', + // The context and namespace flags precede the user's command: + // anything after kubectl's -- would otherwise be executed inside + // the pod, letting kubectl fall back to the ambient context and + // namespace. The argv is an array (never a shell string), so + // nothing is re-parsed or interpolated, and the child's exit code + // propagates. + return spawnWithExitCode({ + args: [ + '--context', + environments[env].context, + '--namespace', + namespace, + ].concat(program.rawArgs.slice(2)), + command: 'kubectl', }); }) .catch((err) => reportError(err, false, true)); diff --git a/bin/c-kc-manifests.js b/bin/c-kc-manifests.js index 2924387..b8716f9 100644 --- a/bin/c-kc-manifests.js +++ b/bin/c-kc-manifests.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -15,6 +14,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .description('This command will compile any found template manifests.') @@ -41,10 +41,10 @@ return Promise.all([loadState(), loadConfig()]) `kubernetes.environments.${state.env}.namespace` ) || formatProjectPrefix(organisation, name, state.env, true, true); - return [locations, prefix, namespace, path, state]; + return [config, locations, prefix, namespace, path, state]; }) .then( - ([kubernetesLocations, prefix, namespace, path, state]) => + ([config, kubernetesLocations, prefix, namespace, path, state]) => new Promise((resolve, reject) => { const services = kubernetesLocationsToObjects(kubernetesLocations); @@ -55,33 +55,22 @@ return Promise.all([loadState(), loadConfig()]) return reject(e); } - return resolve([services, path]); + return resolve([config, namespace, path, services, state]); }) ) - .then( - ([services, path]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path]); - }) + .then(([config, namespace, path, services, state]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [path, services]) ) - .then( - ([services, path]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve(); - }) + .then(([path, services]) => + ensureServiceFilesExist(path, services).then(() => [path, services]) ) + .then(([path, services]) => renderServicesTemplates(path, services)) .catch((err) => { if (err.code === 'ENOENT') { return reportError( diff --git a/bin/c-kc-start.js b/bin/c-kc-start.js index c62d09b..4908211 100644 --- a/bin/c-kc-start.js +++ b/bin/c-kc-start.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -18,6 +17,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .description('This command will start all of your Kubernetes locations.') @@ -44,10 +44,10 @@ return Promise.all([loadState(), loadConfig()]) `kubernetes.environments.${state.env}.namespace` ) || formatProjectPrefix(organisation, name, state.env, true, true); - return [locations, prefix, namespace, path, state]; + return [config, locations, prefix, namespace, path, state]; }) .then( - ([kubernetesLocations, prefix, namespace, path, state]) => + ([config, kubernetesLocations, prefix, namespace, path, state]) => new Promise((resolve, reject) => { const services = kubernetesLocationsToObjects(kubernetesLocations); @@ -58,32 +58,31 @@ return Promise.all([loadState(), loadConfig()]) return reject(e); } - return resolve([services, path, state]); + return resolve([config, namespace, services, path, state]); }) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([config, namespace, services, path, state]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [services, path, state]) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state]) => + ensureServiceFilesExist(path, services).then(() => [ + services, + path, + state, + ]) + ) + .then(([services, path, state]) => + renderServicesTemplates(path, services).then(() => [ + services, + path, + state, + ]) ) .then( ([services, path, state]) => diff --git a/bin/c-kc.js b/bin/c-kc.js index 333b110..2d303ea 100644 --- a/bin/c-kc.js +++ b/bin/c-kc.js @@ -1,7 +1,21 @@ 'use strict'; const program = require('commander'); -const { missingCommand } = require('./lib/c'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); + +// The cmd passthrough needs the verbatim argv (bin/c.js forwards this +// subtree raw): commander's git-style dispatch would re-parse the args +// first, dropping -- and unknown options like kubectl flags. +if (process.argv[2] === 'cmd') { + const { join } = require('path'); + + spawnWithExitCode({ + args: [join(__dirname, 'c-kc-cmd.js')].concat(process.argv.slice(3)), + command: 'node', + }); + + return; +} program .command('apply ', 'Deploy a particular Kubernetes locations.') diff --git a/bin/c-op-cmd.js b/bin/c-op-cmd.js new file mode 100644 index 0000000..072756c --- /dev/null +++ b/bin/c-op-cmd.js @@ -0,0 +1,37 @@ +'use strict'; + +const program = require('commander'); + +const { reportError, spawnWithExitCode } = require('./lib/c'); +const { assertOpSession, opSession } = require('./lib/c-secrets-io'); + +program + .arguments('[command...]') + .description( + 'Runs a 1Password cli command with the c-managed session wired in (--session), rather than guessing an OP_SESSION_* env var name.' + ) + .parse(process.argv); + +try { + // Fail fast with a friendly error before handing over to op. + assertOpSession(); + + const opArgs = ['--session', opSession()].concat(program.rawArgs.slice(2)); + + // Programmatic callers pipe stdin from Node, whose pipe stdio is an + // AF_UNIX socket - op only detects input on real pipe(2) FIFOs. When + // stdin is not a terminal, bridge it through a shell pipe: cat reads + // the socket fine and writes into a genuine pipe op accepts. The + // command lives in the shell string and arguments pass verbatim + // through "$@", so no caller input is interpolated. + if (process.stdin.isTTY) { + return spawnWithExitCode({ args: opArgs, command: 'op' }); + } + + return spawnWithExitCode({ + args: ['-c', 'cat | op "$@"', 'sh'].concat(opArgs), + command: 'sh', + }); +} catch (e) { + reportError(e, false, true); +} diff --git a/bin/c-op-get.js b/bin/c-op-get.js new file mode 100644 index 0000000..9f33652 --- /dev/null +++ b/bin/c-op-get.js @@ -0,0 +1,108 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, newline, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + opRef, + resolveConsumerKey, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertOpSession, + guardPlaintext, + opRead, + resolveOwnerItem, + sha12, +} = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments(' ') + .option( + '-f, --force', + 'Allow plaintext outside a personal terminal (discouraged).' + ) + .option('-n', 'Do not print the trailing newline character.') + .option('-p, --plaintext', 'Print the value itself instead of its hash.') + .description( + 'Get a secret value summary from 1Password: a truncated sha256, the op:// reference and the length. Values are never displayed unless --plaintext is used.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + const resolved = + identity.kind === 'service' + ? resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }) + : resolveConsumerKey({ + contract, + consumer: identity.consumer, + key, + }); + + // The resolved key usually carries its item already (a bound + // owner, or the dev item for consumer-owned keys); only unbound + // owners resolve lazily by title, and reads never create (the + // shared block is never auto-created either). + const item = resolved.opItem + ? resolved.opItem + : resolveOwnerItem({ + owner: resolved.owner, + title: `${contract.organisation}-${contract.name}/${resolved.owner.name}`, + }).id; + + const ref = + resolved.opRef || + opRef({ + contract, + env: identity.kind === 'service' ? env : 'local', + key, + opItem: item, + }); + + const value = opRead({ ref }); + + if (program.plaintext) { + guardPlaintext({ force: program.force }); + + return process.stdout.write(`${value}${newline(program.N)}`); + } + + return print( + `${sha12({ value: Buffer.from(value, 'utf8') })} ${ref} len=${ + value.length + }` + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-op-session.js b/bin/c-op-session.js new file mode 100644 index 0000000..57d4ba5 --- /dev/null +++ b/bin/c-op-session.js @@ -0,0 +1,45 @@ +'use strict'; + +const { execFileSync } = require('child_process'); +const { chmodSync, mkdirSync, writeFileSync } = require('fs'); +const { dirname } = require('path'); +const program = require('commander'); + +const { reportError } = require('./lib/c'); +const { OP_SESSION_FILE } = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .description( + 'Sign in to 1Password and store the session token where every c command (and c op cmd) reads it (~/.local/state/idearium/op-session, 0600). The token itself is never printed.' + ) + .parse(process.argv); + +try { + const token = execFileSync('op', ['signin', '--raw'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'inherit'], + }).trim(); + + if (!token) { + throw new Error( + 'op signin produced no session token (1Password desktop-app integration can do this) - sign in with your account password instead.' + ); + } + + mkdirSync(dirname(OP_SESSION_FILE), { recursive: true }); + + writeFileSync(OP_SESSION_FILE, `${token}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + + // writeFileSync only applies the mode on creation - enforce it on + // overwrite too (the file may exist with wider permissions). + chmodSync(OP_SESSION_FILE, 0o600); + + print(`op session warmed: ${OP_SESSION_FILE}`); +} catch (e) { + reportError(e, false, true); +} diff --git a/bin/c-op-set.js b/bin/c-op-set.js new file mode 100644 index 0000000..851319f --- /dev/null +++ b/bin/c-op-set.js @@ -0,0 +1,228 @@ +'use strict'; + +const { readFileSync } = require('fs'); +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + resolveIdentity, +} = require('./lib/c-secrets'); +const { + assertOpSession, + generateValueForProgram, + opFieldExists, + opUpsertField, + registerServiceInCjs, + resolveOrCreateOwnerItem, + setOpItemInCjs, + sha12, +} = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments(' ') + .option('-g, --generate', 'Generate a value instead of reading stdin.') + .option( + '-t [type]', + 'Value type for --generate: hex, base64, alphanumeric.' + ) + .option('-l [length]', 'Value length for --generate.') + .description( + 'Upsert a secret value into 1Password for a key the contract knows (owned or borrowed). Pipe the value via stdin, or use --generate. Unbound services resolve (or create) their item automatically and register into c.js.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +const readStdin = () => { + if (process.stdin.isTTY) { + throw new Error( + 'Pipe the value via stdin (printf "value" | ...) or use --generate.' + ); + } + + return readFileSync(0, 'utf8').replace(/\r?\n$/, ''); +}; + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertKnownEnv({ config, env }); + + const value = program.generate + ? generateValueForProgram({ program }) + : readStdin(); + + const title = (serviceName) => + `${contract.organisation}-${contract.name}/${serviceName}`; + + const reportBinding = ({ binding, serviceName }) => { + if (binding.created) { + print( + `created 1Password item ${title(serviceName)} (${ + binding.id + })` + ); + } + + if (!binding.created && binding.persisted) { + print( + `bound existing 1Password item ${title(serviceName)} (${ + binding.id + }) into c.js` + ); + } + }; + + let item; + let ref; + let section; + + // Resolve the destination: the key's owner (an existing entry, + // owned or borrowed), or a brand new service. Unbound services + // resolve (or create) their item and persist the binding. + try { + const identity = resolveIdentity({ contract, env, name }); + + const entry = + identity.kind === 'service' + ? identity.service + : identity.consumer; + + const borrowed = entry.sharedKeys.find( + (shared) => shared.key === key + ); + + const owned = entry.keys.includes(key); + + // The contract is the source of truth: a key no entry owns + // or borrows would write an orphan field (verify's + // op:EXTRA), so stop at the door with the remedy. + if (!owned && !borrowed) { + const remedy = + identity.kind === 'service' + ? `declare it with \`c secrets add ${name} ${env} ${key}\`` + : "add it to the consumer's keys in c.js first"; + + throw new Error( + `'${key}' is neither owned nor borrowed by '${name}' - fix the typo, or ${remedy}.` + ); + } + + if (identity.kind === 'consumer') { + const binding = owned + ? { + created: false, + id: contract.dev.opItem, + persisted: false, + } + : resolveOrCreateOwnerItem({ + owner: borrowed.owner, + title: title(borrowed.owner.name), + }); + + // Only borrowed keys resolve (or persist) an owner's + // item; owned keys already live in the dev item. + if (borrowed && binding.persisted) { + setOpItemInCjs({ + name: borrowed.owner.name, + opItem: binding.id, + }); + + reportBinding({ + binding, + serviceName: borrowed.owner.name, + }); + } + + item = binding.id; + section = owned ? identity.consumer.name : 'local'; + } + + if (identity.kind === 'service') { + const target = owned ? identity.service : borrowed.owner; + const serviceName = target.name; + const binding = resolveOrCreateOwnerItem({ + owner: target, + title: title(serviceName), + }); + + if (binding.persisted) { + setOpItemInCjs({ name: serviceName, opItem: binding.id }); + } + + reportBinding({ binding, serviceName }); + + item = binding.id; + section = env; + } + + ref = `op://${contract.vault}/${item}/${section}/${key}`; + } catch (e) { + if (!e.message.includes('No service or dev consumer')) { + throw e; + } + + // A brand new service: resolve-by-title first (no duplicates), + // creating only when the item truly doesn't exist. + if (name === 'shared') { + throw new Error( + 'Use the secrets.shared block for shared keys - add it to c.js (its opItem points at the item storing the values).' + ); + } + + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new Error( + `New service names must be kebab-case ('${name}')` + ); + } + + const binding = resolveOrCreateOwnerItem({ + owner: null, + title: title(name), + }); + + reportBinding({ binding, serviceName: name }); + + item = binding.id; + section = env; + ref = `op://${contract.vault}/${item}/${section}/${key}`; + + registerServiceInCjs({ key, name, opItem: item }); + } + + opUpsertField({ field: key, item, section, value }); + + if (program.generate) { + return print(ref); + } + + return print( + `${ref} ${sha12({ value: Buffer.from(value, 'utf8') })} len=${ + value.length + }${ + opFieldExists({ field: key, item, section }) + ? '' + : ' (WARNING: field not found after write)' + }` + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-op.js b/bin/c-op.js new file mode 100644 index 0000000..2b68987 --- /dev/null +++ b/bin/c-op.js @@ -0,0 +1,42 @@ +'use strict'; + +const program = require('commander'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); + +// The cmd passthrough needs the verbatim argv (bin/c.js forwards this +// subtree raw): commander's git-style dispatch would re-parse the args +// first, dropping -- and unknown options. +if (process.argv[2] === 'cmd') { + const { join } = require('path'); + + spawnWithExitCode({ + args: [join(__dirname, 'c-op-cmd.js')].concat(process.argv.slice(3)), + command: 'node', + }); + + return; +} + +program + .command( + 'get ', + 'Get a secret value summary from 1Password (hash by default).' + ) + .command( + 'set ', + 'Upsert a secret value into 1Password from stdin (or --generate).' + ) + .command( + 'session', + 'Sign in to 1Password and store the session token for every c command.' + ) + .command( + 'cmd [command...]', + 'Run a 1Password cli command with the c-managed session.' + ) + .description( + 'Manage 1Password secret values for the secrets contract in c.js.' + ) + .parse(process.argv); + +missingCommand(program); diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js new file mode 100644 index 0000000..7b2a1ca --- /dev/null +++ b/bin/c-secrets-add.js @@ -0,0 +1,271 @@ +'use strict'; + +const { readFileSync, writeFileSync } = require('fs'); +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + gsmId, + resolveIdentity, +} = require('./lib/c-secrets'); +const { + addKeyToEntryInCjs, + assertGcloudAuth, + assertOpSession, + cjsPath, + generateValueForProgram, + gsmDelete, + gsmPushVerified, + opUpsertField, + registerServiceInCjs, + resolveOrCreateOwnerItem, + setOpItemInCjs, +} = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments(' ') + .option('-g, --generate', 'Generate a value instead of reading stdin.') + .option( + '-t [type]', + 'Value type for --generate: hex, base64, alphanumeric.' + ) + .option('-l [length]', 'Value length for --generate.') + .description( + 'Add a key end to end, in order: 1Password, GSM (created with labels if missing, verified by hash), then the c.js contract (re-validated after the edit). Rolls back what it can on failure. Never touches the cluster.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +const readStdin = () => { + if (process.stdin.isTTY) { + throw new Error( + 'Pipe the value via stdin (printf "value" | ...) or use --generate.' + ); + } + + return readFileSync(0, 'utf8').replace(/\r?\n$/, ''); +}; + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const value = program.generate + ? generateValueForProgram({ program }) + : readStdin(); + + const backup = readFileSync(cjsPath(), 'utf8'); + + let id; + let binding = { created: false, id: null, persisted: false }; + let borrowedKey = false; + let isNewService = false; + let item; + let ownerName; + let section; + + // Step 1: 1Password - upsert the field; unbound services resolve + // (or create) their item and persist the binding. + try { + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op set\`.` + ); + } + + const { service } = identity; + const borrowed = service.sharedKeys.find( + (shared) => shared.key === key + ); + borrowedKey = Boolean(borrowed); + const known = service.keys.includes(key) || borrowed; + + ownerName = (borrowed || {}).owner + ? borrowed.owner.name + : service.name; + + binding = resolveOrCreateOwnerItem({ + owner: borrowed ? borrowed.owner : service, + title: `${contract.organisation}-${contract.name}/${ownerName}`, + }); + + if (binding.persisted) { + setOpItemInCjs({ name: ownerName, opItem: binding.id }); + } + + item = binding.id; + section = env; + + id = known + ? gsmId({ + contract, + env, + key, + owner: borrowed ? borrowed.owner : service, + }) + : gsmId({ contract, env, key, owner: service }); + } catch (e) { + if (!e.message.includes('No service or dev consumer')) { + throw e; + } + + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new Error( + `New service names must be kebab-case ('${name}')` + ); + } + + if (name === 'shared') { + throw new Error( + "'shared' is reserved for the secrets.shared block - add the key there instead" + ); + } + + binding = resolveOrCreateOwnerItem({ + owner: null, + title: `${contract.organisation}-${contract.name}/${name}`, + }); + + item = binding.id; + id = gsmId({ + contract, + env, + key, + owner: { name }, + }); + isNewService = true; + ownerName = name; + section = env; + } + + opUpsertField({ field: key, item, section, value }); + + // Step 2: GSM - create (with labels) if missing, add a version, + // verify the readback by hash. + return gsmPushVerified({ + contract, + env, + id, + owner: ownerName, + value, + }).then(({ created, readbackSha, sourceSha }) => { + const gsmSummary = `${ + created ? 'created' : 'version added to' + } ${id} op:${sourceSha} gsm:${readbackSha} VERIFIED`; + + // Step 3: c.js - register (a new service, or a new owned key; + // a borrowed key is already declared by its owner), then + // re-load and re-validate. + try { + if (isNewService) { + registerServiceInCjs({ key, name, opItem: item }); + } + + if (!isNewService && !borrowedKey) { + addKeyToEntryInCjs({ key, name }); + } + + delete require.cache[require.resolve(cjsPath())]; + + const fresh = getSecretsContract({ + config: require(cjsPath()), + }); + const freshService = + name === 'shared' ? fresh.shared : fresh.services[name]; + + if (!freshService) { + throw new Error( + `service '${name}' missing from the edited contract` + ); + } + + const owned = freshService.keys.includes(key); + const borrowedFresh = freshService.sharedKeys.find( + (shared) => shared.key === key + ); + + if (!owned && !borrowedFresh) { + throw new Error( + `key '${key}' not resolvable in the edited contract` + ); + } + + const freshId = gsmId({ + contract: fresh, + env, + key, + owner: borrowedFresh ? borrowedFresh.owner : freshService, + }); + + if (freshId !== id) { + throw new Error( + `edited contract derives '${freshId}', expected '${id}'` + ); + } + } catch (e) { + writeFileSync(cjsPath(), backup); + + const rollback = created + ? `; GSM secret ${id} deleted` + : `; GSM version for ${id} left in place`; + + const orphans = created + ? gsmDelete({ id, project: contract.project }).then( + () => '', + () => + ' (WARNING: deleting the created GSM secret failed - delete it manually)' + ) + : Promise.resolve(''); + + return orphans.then((warning) => { + throw new Error( + `Rolled back c.js (edit failed: ${e.message})${rollback}. The 1Password field remains - re-run to complete.${warning}` + ); + }); + } + + const opSummary = binding.created + ? `1Password item ${contract.organisation}-${contract.name}/${ownerName} created (${item}); field ${section}/${key} upserted` + : `1Password ${item}/${section}/${key} upserted${ + binding.persisted ? `; item bound into c.js` : '' + }`; + + let cjsSummary = 'c.js key registered'; + + if (isNewService) { + cjsSummary = 'c.js service registered'; + } + + if (borrowedKey) { + cjsSummary = + 'borrowed key - the owner already declares it in c.js'; + } + + return print(`${opSummary}; ${gsmSummary}; ${cjsSummary}`); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-consumers.js b/bin/c-secrets-consumers.js new file mode 100644 index 0000000..01127cf --- /dev/null +++ b/bin/c-secrets-consumers.js @@ -0,0 +1,87 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { getSecretsContract } = require('./lib/c-secrets'); +const { assertGcloudAuth } = require('./lib/c-secrets-io'); +const { findConsumers } = require('./lib/c-secrets-verify'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments('') + .description( + "List everything referencing a GSM secret id: contract consumers, the environment's SecretProviderClasses and deployed Cloud Functions. Gate every deletion on this." + ) + .parse(process.argv); + +const [secretId] = program.args; + +if (!secretId) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + + return findConsumers({ config, contract, secretId }); + }) + .then(({ contract: consumers, env, functions, k8s }) => { + const total = consumers.length + functions.length + k8s.length; + + print(`env: ${env}\n`); + + print('contract:'); + if (consumers.length === 0) { + print(' (none)'); + } + consumers.forEach(({ borrowed, key, owner, service }) => { + print( + ` ${service} ${key} (owner ${owner}${ + borrowed ? ', borrowed' : ', owned' + })` + ); + }); + + print('\nkubernetes:'); + if (k8s.length === 0) { + print(' (none)'); + } + k8s.forEach(({ name, namespace }) => { + print(` secretproviderclass ${name} (${namespace})`); + }); + + print('\nfunctions:'); + if (functions.length === 0) { + print(' (none)'); + } + functions.forEach(({ env: nameEnv, name }) => { + print( + ` ${name}${ + nameEnv ? ` (name env ${nameEnv})` : ' (no env in name)' + }` + ); + }); + + print( + total === 0 + ? `\nUNREFERENCED - safe to delete (gate satisfied).` + : `\nREFERENCED by ${total} consumer(s) - do not delete.` + ); + + return null; + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-get.js b/bin/c-secrets-get.js new file mode 100644 index 0000000..116ce35 --- /dev/null +++ b/bin/c-secrets-get.js @@ -0,0 +1,88 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, newline, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertGcloudAuth, + gsmRead, + guardPlaintext, + sha12, +} = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments(' ') + .option( + '-f, --force', + 'Allow plaintext outside a personal terminal (discouraged).' + ) + .option('-n', 'Do not print the trailing newline character.') + .option('-p, --plaintext', 'Print the value itself instead of its hash.') + .description( + 'Get a GSM secret value summary: a truncated sha256, the secret id and the length. Values are never displayed unless --plaintext is used.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op\`.` + ); + } + + const resolved = resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }); + + return gsmRead({ + id: resolved.gsmId, + project: contract.project, + }).then((value) => { + if (program.plaintext) { + guardPlaintext({ force: program.force }); + + return process.stdout.write( + `${value.toString('utf8')}${newline(program.N)}` + ); + } + + return print( + `${sha12({ value })} ${resolved.gsmId} len=${value.length}` + ); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-init.js b/bin/c-secrets-init.js new file mode 100644 index 0000000..0d555b5 --- /dev/null +++ b/bin/c-secrets-init.js @@ -0,0 +1,113 @@ +'use strict'; + +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { getSecretsContract } = require('./lib/c-secrets'); +const { assertGcloudAuth, assertOpSession } = require('./lib/c-secrets-io'); +const { draftContract } = require('./lib/c-secrets-init'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .description( + 'Draft a secrets contract into c.js by scanning existing declarations (SecretProviderClasses, local secret templates, --set-secrets scripts, env.*.yaml references, docker build args and 1Password item names). Requires a warm 1Password session (`c op session`) so item bindings resolve. The draft is a starting point: review ownership, opItems and sharedKeys, then verify. Exits without changes when a secrets contract already exists.' + ) + .parse(process.argv); + +const cjsPath = () => join(process.cwd(), 'c.js'); + +return loadConfig() + .then((config) => { + if (config.secrets) { + return print( + 'A secrets contract already exists in c.js - nothing to init.' + ); + } + + // A warm session is required: without the vault scan, services + // draft unbound and (worse) a cold draft cannot be re-run to + // completion. + assertOpSession(); + assertGcloudAuth(); + + return draftContract({ config }).then((draft) => { + const backup = readFileSync(cjsPath(), 'utf8'); + let text = backup; + + // Ensure gcloud.region (the contract requires it; derive it from + // regional resourceNames or the kubectl context). + const gcloud = config.gcloud || {}; + + if (!gcloud.region && !draft.region) { + throw new Error( + 'Could not derive a region for this project (no regional resourceNames, no gke_ kubectl context) - add gcloud.region to c.js, then re-run.' + ); + } + + if (!gcloud.region && draft.region) { + const gcloudMatch = text.match(/\n(\s*)gcloud:\s*\{/); + + if (gcloudMatch) { + text = text.replace( + gcloudMatch[0], + `${gcloudMatch[0]}\n${gcloudMatch[1]} region: '${draft.region}',` + ); + } + + if (!gcloudMatch) { + // No gcloud block at all: add one alongside the secrets + // block (projectId from the project configuration). + draft.blockText = [ + ` gcloud: {`, + ` projectId: '${config.project.gcpProjectId}',`, + ` region: '${draft.region}',`, + ` },`, + draft.blockText, + ].join('\n'); + } + } + + // Insert the secrets block before the module's closing brace. + const closing = text.lastIndexOf('};'); + + if (closing === -1) { + throw new Error( + 'Could not find the end of module.exports in c.js - add the secrets block manually.' + ); + } + + text = `${text.slice(0, closing)}${draft.blockText}${text.slice( + closing + )}`; + + writeFileSync(cjsPath(), text); + + // The edited c.js must load and validate. + try { + delete require.cache[require.resolve(cjsPath())]; + + const contract = getSecretsContract({ + config: require(cjsPath()), + }); + + if (!contract) { + throw new Error( + 'edited c.js still has no secrets contract' + ); + } + } catch (e) { + writeFileSync(cjsPath(), backup); + + throw new Error( + `Rolled back c.js - the drafted contract did not validate: ${e.message}` + ); + } + + print('Drafted a secrets contract into c.js.\n'); + + return draft.notes.forEach((note) => print(`- ${note}`)); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-ls.js b/bin/c-secrets-ls.js new file mode 100644 index 0000000..7c442c2 --- /dev/null +++ b/bin/c-secrets-ls.js @@ -0,0 +1,71 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + serviceKeys, +} = require('./lib/c-secrets'); +const { assertGcloudAuth, gsmList } = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments('') + .description( + "List an environment's contract-derived GSM secrets and whether each exists in the project." + ) + .parse(process.argv); + +const [env] = program.args; + +if (!env) { + return reportError(new Error('You need to provide '), program, true); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + return gsmList({ project: contract.project }).then((ids) => { + const existing = new Set(ids); + + Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((service) => { + serviceKeys({ contract, env, service }).forEach( + (resolved) => { + print( + `${ + existing.has(resolved.gsmId) + ? 'EXISTS ' + : 'MISSING ' + } ${service.name} ${resolved.key} ${ + resolved.gsmId + }` + ); + } + ); + }); + + if (env === 'local') { + print( + '\nNote: local Secrets resolve 1Password references at dev time; GSM existence is only expected for function/workflow-consumed secrets.' + ); + } + + return null; + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-push.js b/bin/c-secrets-push.js new file mode 100644 index 0000000..a836f0f --- /dev/null +++ b/bin/c-secrets-push.js @@ -0,0 +1,98 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + opRef, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertGcloudAuth, + assertOpSession, + gsmPushVerified, + opRead, + resolveOwnerItem, +} = require('./lib/c-secrets-io'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments(' ') + .description( + "Push the key's 1Password value to GSM: create the secret (with labels, pinned-global replication) if needed, add a version otherwise, and verify the readback by hash." + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op\`.` + ); + } + + const resolved = resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }); + + // Unbound services resolve their item lazily (reads never create); + // the shared block resolves too but is never auto-created. + const ref = + resolved.opRef || + opRef({ + contract, + env, + key, + opItem: resolveOwnerItem({ + owner: resolved.owner, + title: `${contract.organisation}-${contract.name}/${resolved.owner.name}`, + }).id, + }); + + const value = opRead({ ref }); + + return gsmPushVerified({ + contract, + env, + id: resolved.gsmId, + owner: resolved.owner.name, + value, + }).then(({ created, readbackSha, sourceSha }) => + print( + `${created ? 'created' : 'version added to'} ${ + resolved.gsmId + } op:${sourceSha} gsm:${readbackSha} VERIFIED` + ) + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-verify.js b/bin/c-secrets-verify.js new file mode 100644 index 0000000..96c21a0 --- /dev/null +++ b/bin/c-secrets-verify.js @@ -0,0 +1,62 @@ +'use strict'; + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { assertKnownEnv, getSecretsContract } = require('./lib/c-secrets'); +const { + assertGcloudAuth, + assertKubectl, + assertOpSession, +} = require('./lib/c-secrets-io'); +const { kubernetesEnv, verifyEnv } = require('./lib/c-secrets-verify'); + +const print = (line) => process.stdout.write(`${line}\n`); + +program + .arguments('') + .description( + 'Verify an environment against the contract: GSM secrets exist and hash-match 1Password, Kubernetes Secrets (for synced services) match structure and hashes, and deployed Cloud Functions reference only contract-derived secret ids (STALE findings otherwise).' + ) + .parse(process.argv); + +const [env] = program.args; + +if (!env) { + return reportError(new Error('You need to provide '), program, true); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + + const kenv = kubernetesEnv({ config, env }); + + if (kenv && kenv.context) { + assertKubectl({ context: kenv.context }); + } + + assertKnownEnv({ config, env }); + + return verifyEnv({ config, contract, env }); + }) + .then(({ ok, problems }) => { + if (ok) { + return print('\nVERIFIED'); + } + + return reportError( + new Error(`\n${problems.length} PROBLEM(S) FOUND`), + false, + true + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets.js b/bin/c-secrets.js new file mode 100644 index 0000000..6e6db31 --- /dev/null +++ b/bin/c-secrets.js @@ -0,0 +1,37 @@ +'use strict'; + +const program = require('commander'); +const { missingCommand } = require('./lib/c'); + +program + .command( + 'add ', + 'Add a key end to end: 1Password, GSM and the c.js contract. Never touches the cluster.' + ) + .command( + 'consumers ', + 'List everything referencing a GSM secret id. The deletion gate.' + ) + .command( + 'get ', + 'Get a GSM secret value summary (hash by default).' + ) + .command( + 'init', + 'Draft a secrets contract into c.js from existing declarations. Requires a warm 1Password session. No-op when a contract exists.' + ) + .command('ls ', "List an environment's contract-derived GSM secrets.") + .command( + 'push ', + 'Push the 1Password value to GSM (create or addVersion) and verify the readback by hash.' + ) + .command( + 'verify ', + 'Verify an environment: contract structure, op/GSM/k8s hashes, functions drift.' + ) + .description( + 'Manage secrets for the contract in c.js across 1Password, GSM and Kubernetes.' + ) + .parse(process.argv); + +missingCommand(program); diff --git a/bin/c.js b/bin/c.js index e363a0d..14b8744 100755 --- a/bin/c.js +++ b/bin/c.js @@ -3,7 +3,31 @@ 'use strict'; const program = require('commander'); -const { missingCommand } = require('./lib/c'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); + +// Commander's git-style dispatch re-parses subcommand args at every layer +// before spawning (dropping -- and unknown options, breaking passthroughs +// like `c op cmd run -- sh -c ...` and `c kc cmd` commands carrying +// kubectl flags), so the passthrough-owning subtrees are dispatched with +// the verbatim argv instead - their own cmd guards carry it the rest of +// the way down. +const VERBATIM_DISPATCH = { + kc: 'c-kc.js', + op: 'c-op.js', +}; + +const verbatimChild = VERBATIM_DISPATCH[process.argv[2]]; + +if (verbatimChild) { + const { join } = require('path'); + + spawnWithExitCode({ + args: [join(__dirname, verbatimChild)].concat(process.argv.slice(3)), + command: 'node', + }); + + return; +} // The basic program, which uses sub-commands. program @@ -18,8 +42,13 @@ program .command('mk ', 'Shortcuts to control Minikube.') .command('mongo ', 'Shortcuts to help with the database.') .command('npm ', 'Shortcuts to help with NPM.') + .command('op ', 'Shortcuts to manage 1Password secret values.') .command('project ', 'Shortcuts to help with project management.') .command('sdp ', "Shortcuts to help with Section's devpop.") + .command( + 'secrets ', + 'Shortcuts to manage project secrets across their stores.' + ) .command('skaffold ', 'Shortcuts to help with Skaffold.') .command('ts ', 'Shortcuts to help with Tailscale.') .command( diff --git a/bin/lib/c-kc.js b/bin/lib/c-kc.js index 796619e..cbae168 100644 --- a/bin/lib/c-kc.js +++ b/bin/lib/c-kc.js @@ -8,6 +8,8 @@ const Mustache = require('mustache'); const { promisify } = require('util'); const { execFile } = require('child_process'); +const { opSession } = require('./c-secrets-io'); + const { constants } = fs; const access = promisify(fs.access); @@ -51,11 +53,19 @@ const ensureServiceFilesExist = async (path = '', services = []) => { await ensureDir(destinationFolder); await copy(`${sourcePath}.yaml`, `${destinationPath}.yaml`); } catch (err) { - throw new Error( - new Error( - `Neither ${path}/${service.path}.yaml.tmpl or ${path}/${service.path}.yaml could be found` - ) - ); + try { + // Contract-generated manifests have no committed source + // file - their compiled output already exists. + await access(`${destinationPath}.yaml`, constants.R_OK); + + return; + } catch (generatedErr) { + throw new Error( + new Error( + `Neither ${path}/${service.path}.yaml.tmpl or ${path}/${service.path}.yaml could be found` + ) + ); + } } } }); @@ -160,9 +170,27 @@ const formatBuildArgs = (args) => { */ let opAuthenticated = null; +/** + * The leading op arguments: --session with the c-managed token (from + * `c op session` or an exported OP_SESSION_idearium), or none at all + * when no session exists (the cli may still be authenticated some + * other way). + * @returns {Array} The arguments to prepend to every op invocation. + */ +const opSessionArgs = () => { + try { + return ['--session', opSession()]; + } catch (e) { + return []; + } +}; + const ensureOpAuthenticated = () => { if (!opAuthenticated) { - opAuthenticated = execFileAsync('op', ['whoami']).catch((e) => { + opAuthenticated = execFileAsync( + 'op', + opSessionArgs().concat(['whoami']) + ).catch((e) => { if (e.code === 'ENOENT') { throw new Error( 'The 1Password cli (op) is not installed. Install it to resolve secret references within templates.' @@ -170,7 +198,7 @@ const ensureOpAuthenticated = () => { } throw new Error( - 'Not signed in to the 1Password cli. Run `eval $(op signin)` in this shell and try again.' + 'Not signed in to the 1Password cli. Run `c op session` and try again.' ); }); } @@ -195,13 +223,16 @@ const injectSecretReferences = async (content) => { try { await writeFile(inPath, content, { mode: 0o600 }); - await execFileAsync('op', [ - 'inject', - '--in-file', - inPath, - '--out-file', - outPath, - ]); + await execFileAsync( + 'op', + opSessionArgs().concat([ + 'inject', + '--in-file', + inPath, + '--out-file', + outPath, + ]) + ); return await readFile(outPath, 'utf-8'); } finally { @@ -361,9 +392,11 @@ const validateBuildArgs = (args = []) => args.filter((arg) => arg.split('=').length == 2); module.exports = { + encodeSecretStringData, ensureServiceFilesExist, flagBuildArgs, formatBuildArgs, + injectSecretReferences, renderServicesTemplates, removeCompiledSecrets, setLocalsForServices, diff --git a/bin/lib/c-secrets-init.js b/bin/lib/c-secrets-init.js new file mode 100644 index 0000000..6be6430 --- /dev/null +++ b/bin/lib/c-secrets-init.js @@ -0,0 +1,985 @@ +'use strict'; + +const { readdirSync, readFileSync, statSync } = require('fs'); +const { join } = require('path'); + +const { + gsmDescribe, + gsmRead, + opRead, + quoteIfNeeded, + runOp, + sha12, +} = require('./c-secrets-io'); + +const SKIP_DIRS = ['.compiled', '.git', 'docs', 'node_modules']; + +/** + * Walk a directory tree (bounded depth), returning file paths, skipping + * build output and dependencies. + * @param {Object} options + * @param {String} options.dir The directory to walk. + * @param {Number} [options.depth] The maximum depth. + * @returns {Array} Absolute file paths. + */ +const walkFiles = ({ dir, depth = 4 }) => { + let entries = []; + + try { + entries = readdirSync(dir); + } catch (e) { + return []; + } + + const files = []; + + entries.forEach((entry) => { + if (SKIP_DIRS.includes(entry)) { + return; + } + + const full = join(dir, entry); + + let stats; + + try { + stats = statSync(full); + } catch (e) { + return; + } + + if (stats.isDirectory()) { + if (depth > 0) { + files.push(...walkFiles({ depth: depth - 1, dir: full })); + } + + return; + } + + files.push(full); + }); + + return files; +}; + +/** + * Uppercase a kebab-case path into its environment-variable style key. + * @param {Object} options + * @param {String} options.path The lowercase hyphenated path. + * @returns {String} The environment-variable style key. + */ +const pathToKey = ({ path }) => path.toUpperCase().replace(/-/g, '_'); + +/** + * List the 1Password items for a project prefix, mapped by service name. + * Best-effort: returns null when no session is available. + * @param {Object} options + * @param {String} options.prefix The item title prefix ({org}-{repo}/). + * @returns {Object|null} A map of service name to item id. + */ +const opItemNames = ({ prefix }) => { + let raw; + + try { + raw = runOp({ + args: ['item', 'list', '--vault=Team', '--format=json'], + }); + } catch (e) { + return { error: e.message, names: null }; + } + + const names = {}; + + JSON.parse(raw).forEach((item) => { + if (item.title && item.title.startsWith(prefix)) { + names[item.title.slice(prefix.length)] = item.id; + } + }); + + return { error: null, names }; +}; + +/** + * Parse a GSM secret id against known envs and service names. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {Array} options.envs The known environments. + * @param {Object} options.serviceNames The known service names. + * @param {String} options.repoPrefix The {org}-{repo}- prefix. + * @returns {Object|null} { ambiguous, key, service } - service null for + * segment-less (shared) keys. + */ +const parseId = ({ envs, id, repoPrefix, serviceNames }) => { + if (!id.startsWith(repoPrefix)) { + return null; + } + + const env = envs.find((candidate) => + id.startsWith(`${repoPrefix}${candidate}-`) + ); + + if (!env) { + return null; + } + + const remainder = id.slice(`${repoPrefix}${env}-`.length); + + // Known service names (1Password items, manifest stems) win with a + // longest-prefix match, so multi-segment names split correctly. + const [known] = Object.keys(serviceNames) + .filter((name) => remainder.startsWith(`${name}-`)) + .sort((a, b) => b.length - a.length); + + if (known) { + return { + ambiguous: false, + key: remainder.slice(known.length + 1), + service: known, + }; + } + + const [head, ...rest] = remainder.split('-'); + + // Without a known name, a service'd id keeps at least a two-segment + // key; anything else is treated as segment-less. + if (rest.length >= 2) { + return { ambiguous: false, key: rest.join('-'), service: head }; + } + + return { ambiguous: true, key: remainder, service: null }; +}; + +/** + * Read a JSON file as text for scanning (raw, so scripts strings are + * included), or null when unparseable. + * @param {Object} options + * @param {String} options.file The file path. + * @returns {String|null} + */ +const safeReadJson = ({ file }) => { + try { + return readFileSync(file, 'utf8'); + } catch (e) { + return null; + } +}; + +/** + * Read a text file for scanning, or null. + * @param {Object} options + * @param {String} options.file The file path. + * @returns {String|null} + */ +const safeReadText = ({ file }) => { + if (!/\.(json|ya?ml|tmpl)$/.test(file)) { + return null; + } + + try { + return readFileSync(file, 'utf8'); + } catch (e) { + return null; + } +}; + +/** + * Scan every source that declares secrets (SecretProviderClasses, local + * secret templates, --set-secrets deploy scripts, env.*.yaml references, + * c.js docker build args) and draft a secrets contract block for c.js. + * The draft is for human reconciliation: ownership of shared keys and + * borrowed keys must be reviewed. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @returns {Object} { blockText, notes, region } - blockText is the + * `secrets: {...},` c.js snippet. + */ +/** + * Notes for the minimal scaffold: the no-declarations note plus the vault + * scan status (observable either way). + * @param {Object} options + * @param {String} options.itemPrefix The item title prefix. + * @param {Object} options.opScan The vault scan result. + * @returns {Array} The notes. + */ +const minimalNotes = ({ itemPrefix, opScan }) => { + const notes = [ + 'No existing secret declarations found - drafted a minimal contract. Add services with `c secrets add ` (items are created and bound automatically).', + ]; + + if (!opScan.names) { + notes.push(`1Password scan skipped (${opScan.error}).`); + + return notes; + } + + if (Object.keys(opScan.names).length === 0) { + notes.push(`1Password scan found no '${itemPrefix}' items.`); + } + + return notes; +}; + +const draftContract = async ({ config }) => { + const { gcpProjectId: project, name, organisation } = config.project; + const repoPrefix = `${organisation}-${name}-`; + + const environments = + (config.kubernetes && config.kubernetes.environments) || {}; + const envs = Object.keys(environments); + + const itemPrefix = `${organisation}-${name}/`; + const opScan = opItemNames({ prefix: itemPrefix }); + const nameHints = opScan.names || {}; + + const ids = new Map(); + const localRefs = new Map(); + const consumerRefs = []; + let region = null; + + const relPath = ({ file }) => file.replace(`${process.cwd()}/`, ''); + + const fileService = ({ file }) => { + const relative = relPath({ file }); + const [head] = relative.split('/'); + + return relative.includes('/') ? head : null; + }; + + const recordId = ({ id, key, source }) => { + if (!ids.has(id)) { + ids.set(id, { key: null, sources: [] }); + } + + const entry = ids.get(id); + entry.sources.push(source); + + if (key) { + entry.key = key; + } + }; + + envs.forEach((env) => { + const kenv = environments[env] || {}; + const dir = kenv.path ? join(process.cwd(), kenv.path) : null; + + if (!dir) { + return; + } + + walkFiles({ depth: 1, dir }).forEach((file) => { + const text = readFileSync(file, 'utf8'); + + // SecretProviderClasses: resourceName/path pairs. + let rest = text; + let match = + /resourceName:\s*'projects\/[^/]+\/(?:locations\/([^/]+)\/)?secrets\/([^'/]+)\/versions\/latest'\s*\n\s*path:\s*'([^']+)'/.exec( + rest + ); + + while (match) { + region = region || match[1]; + + recordId({ + id: match[2], + key: pathToKey({ path: match[3] }), + source: file, + }); + + consumerRefs.push({ + consumerKey: pathToKey({ path: match[3] }), + file: relPath({ file }), + fileService: file + .split('/') + .pop() + .replace(/\.secretproviderclass\.yaml$/, ''), + id: match[2], + }); + + rest = rest.slice(match.index + match[0].length); + match = + /resourceName:\s*'projects\/[^/]+\/(?:locations\/([^/]+)\/)?secrets\/([^'/]+)\/versions\/latest'\s*\n\s*path:\s*'([^']+)'/.exec( + rest + ); + } + + // Local secret templates: op:// references. + if (/\.secret\.yaml\.tmpl$/.test(file)) { + const service = file + .split('/') + .pop() + .replace(/\.secret\.yaml\.tmpl$/, ''); + + let tmplRest = text; + let ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + tmplRest + ); + + while (ref) { + const [, item, , key] = ref; + + if (!localRefs.has(service)) { + localRefs.set(service, { item, keys: [] }); + } + + if (!localRefs.get(service).keys.includes(key)) { + localRefs.get(service).keys.push(key); + } + + tmplRest = tmplRest.slice(ref.index + ref[0].length); + ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + tmplRest + ); + } + } + }); + }); + + // Deploy scripts and env.*.yaml references across the repo. + walkFiles({ depth: 4, dir: process.cwd() }).forEach((file) => { + const base = file.split('/').pop(); + + const text = /\.json$/.test(base) + ? safeReadJson({ file }) + : safeReadText({ file }); + + if (!text) { + return; + } + + let rest = text; + let match = + /([A-Z0-9_]+)=projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)\/versions\/latest/.exec( + rest + ); + + while (match) { + recordId({ id: match[2], key: match[1], source: file }); + consumerRefs.push({ + consumerKey: match[1], + file: relPath({ file }), + fileService: fileService({ file }), + id: match[2], + }); + + rest = rest.slice(match.index + match[0].length); + match = + /([A-Z0-9_]+)=projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)\/versions\/latest/.exec( + rest + ); + } + + if (/^env\.[^.]+\.ya?ml$/.test(base)) { + let yamlRest = text; + let yaml = + /^([A-Z0-9_]+):\s*projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)/m.exec( + yamlRest + ); + + while (yaml) { + recordId({ id: yaml[2], key: null, source: file }); + consumerRefs.push({ + consumerKey: yaml[1], + file: relPath({ file }), + fileService: fileService({ file }), + id: yaml[2], + }); + + yamlRest = yamlRest.slice(yaml.index + yaml[0].length); + yaml = + /^([A-Z0-9_]+):\s*projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)/m.exec( + yamlRest + ); + } + } + }); + + // Region fallback: the kubectl context carries the zone. + if (!region) { + const context = (environments[envs[0]] || {}).context || ''; + + if (context.startsWith('gke_')) { + const zone = context.split('_')[2] || ''; + region = zone.split('-').slice(0, 2).join('-'); + } + } + + if (ids.size === 0 && localRefs.size === 0) { + // Nothing to scan: a minimal, valid scaffold so `c secrets add` + // works immediately. + return { + blockText: [ + ' secrets: {', + ' services: {},', + ' },', + '', + ].join('\n'), + notes: minimalNotes({ + itemPrefix, + opScan, + }), + region, + }; + } + + localRefs.forEach((entry, service) => { + nameHints[service] = entry.item; + }); + + // Aggregate owned keys per service; service-less keys to shared. + const services = {}; + const shared = { examples: {}, keys: [], opItem: null }; + + [...ids.keys()].sort().forEach((id) => { + const entry = ids.get(id); + const parsed = parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }); + + if (!parsed) { + return; + } + + const key = entry.key || pathToKey({ path: parsed.key }); + + if (!parsed.service || parsed.ambiguous) { + if (!shared.keys.includes(key)) { + shared.keys.push(key); + } + + if (!shared.examples[key]) { + shared.examples[key] = id; + } + + return; + } + + if (!services[parsed.service]) { + services[parsed.service] = { keys: [], sharedKeys: [] }; + } + + if (!services[parsed.service].keys.includes(key)) { + services[parsed.service].keys.push(key); + } + }); + + // Local template refs add keys + the opItem for their services. + localRefs.forEach(({ item, keys }, service) => { + if (!services[service]) { + services[service] = { keys: [], sharedKeys: [] }; + } + + keys.forEach((key) => { + if (!services[service].keys.includes(key)) { + services[service].keys.push(key); + } + }); + + services[service].opItem = item; + }); + + // c.js docker build args reference the dev item's consumer sections. + const devConsumers = {}; + let devItemFromArgs = null; + + const locations = (config.docker && config.docker.locations) || {}; + + Object.keys(locations).forEach((location) => { + const buildArgs = locations[location].buildArgs || {}; + + Object.keys(buildArgs).forEach((arg) => { + const source = String(buildArgs[arg]); + + const ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + source + ); + + if (!ref) { + return; + } + + const [, item, section, key] = ref; + + devItemFromArgs = item; + + if (!devConsumers[section]) { + devConsumers[section] = []; + } + + if (!devConsumers[section].includes(key)) { + devConsumers[section].push(key); + } + }); + }); + + // Render the block. + const quote = (value) => `'${value}'`; + const notes = []; + + const lines = []; + lines.push(' secrets: {'); + + if (!devItemFromArgs) { + notes.push( + 'No dev consumers found in docker build args - add secrets.dev.consumers if build args need secrets.' + ); + } + + if (devItemFromArgs) { + lines.push(' dev: {'); + lines.push(` opItem: ${quote(devItemFromArgs)},`); + lines.push(' consumers: {'); + Object.keys(devConsumers) + .sort() + .forEach((section) => { + lines.push( + ` ${quoteIfNeeded({ name: section })}: {` + ); + lines.push(' keys: ['); + devConsumers[section].sort().forEach((key) => { + lines.push(` ${quote(key)},`); + }); + lines.push(' ],'); + lines.push(' },'); + }); + lines.push(' },'); + lines.push(' },'); + } + + // Value-verified legacy dedupe: a service-owned key that is also a + // shared key is usually the same credential seen through old + // (service-segmented) and new (service-less) ids. When the GSM values + // hash-match in every checkable environment, the duplicate is dropped + // from the service; on any conflict it is kept for review. + const deduped = []; + + await Promise.all( + Object.keys(services).map(async (serviceName) => { + const duplicates = services[serviceName].keys.filter((owned) => + shared.keys.includes(owned) + ); + + await Promise.all( + duplicates.map(async (key) => { + const kebab = key.toLowerCase().replace(/_/g, '-'); + const legacyExample = `${repoPrefix}${envs[0]}-${serviceName}-${kebab}`; + + const checks = await Promise.all( + envs.map(async (env) => { + const unifiedId = `${repoPrefix}${env}-${kebab}`; + const unified = await gsmDescribe({ + id: unifiedId, + project, + }) + .then((secret) => + secret + ? gsmRead({ id: unifiedId, project }) + : null + ) + .catch(() => null); + + if (!unified) { + return null; + } + + // The legacy id is derived, not discovered: + // the old service-segmented id may no longer be + // referenced by any file. + const legacyId = `${repoPrefix}${env}-${serviceName}-${kebab}`; + + const legacy = await gsmDescribe({ + id: legacyId, + project, + }) + .then((secret) => + secret + ? gsmRead({ id: legacyId, project }) + : null + ) + .catch(() => null); + + if (!legacy) { + return null; + } + + return ( + sha12({ value: unified }) === + sha12({ value: legacy }) + ); + }) + ); + + const checkable = checks.filter((check) => check !== null); + + if (checkable.length > 0 && checkable.every(Boolean)) { + services[serviceName].keys = services[ + serviceName + ].keys.filter((owned) => owned !== key); + + deduped.push({ + example: legacyExample, + key, + service: serviceName, + }); + } + }) + ); + }) + ); + + // Value-verified shared.opItem: find the 1Password item actually + // holding each shared key's value (hash-compare against the shared GSM + // secrets) and bind when exactly one item matches. Never guess. + let boundTo = null; + let boundEnvs = []; + + if (shared.keys.length > 0) { + const unified = {}; + + await Promise.all( + shared.keys.map(async (key) => { + unified[key] = {}; + const kebab = key.toLowerCase().replace(/_/g, '-'); + + await Promise.all( + envs.map(async (env) => { + const id = `${repoPrefix}${env}-${kebab}`; + const value = await gsmDescribe({ id, project }) + .then((secret) => + secret ? gsmRead({ id, project }) : null + ) + .catch(() => null); + + if (value) { + unified[key][env] = sha12({ value }); + } + }) + ); + }) + ); + + const candidates = {}; + + Object.keys(services).forEach((serviceName) => { + const item = services[serviceName].opItem || nameHints[serviceName]; + + if (item) { + candidates[item] = serviceName; + } + }); + + const matches = Object.keys(candidates).filter((item) => + shared.keys.every((key) => { + const envsToCheck = Object.keys(unified[key]); + + if (envsToCheck.length === 0) { + return false; + } + + return envsToCheck.every((env) => { + try { + const value = opRead({ + ref: `op://Team/${item}/${env}/${key}`, + }); + + return ( + sha12({ value: Buffer.from(value, 'utf8') }) === + unified[key][env] + ); + } catch (e) { + return false; + } + }); + }) + ); + + if (matches.length === 1) { + // Bind by reference: the literal id already appears on the + // owning service's entry, and a reference keeps them in step. + [shared.opItem] = [`services.${candidates[matches[0]]}`]; + boundTo = candidates[matches[0]]; + boundEnvs = [ + ...new Set( + shared.keys.flatMap((key) => Object.keys(unified[key])) + ), + ].sort(); + } + } + + // Author sharedKeys: references to shared (service-less) ids attribute + // to the consuming service as a borrower, with the key moved out of its + // owned keys (a key can't be both owned and borrowed). + const authored = []; + const unhandled = []; + + consumerRefs.forEach( + ({ consumerKey, file, fileService: consumerService, id }) => { + const parsed = parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }); + + if (!parsed || parsed.service) { + return; + } + + const ownerKey = + (ids.get(id) || {}).key || pathToKey({ path: parsed.key }); + + if (!shared.keys.includes(ownerKey) || !consumerService) { + return; + } + + const service = services[consumerService]; + + if (!service || consumerService === 'shared') { + unhandled.push({ file, key: ownerKey }); + + return; + } + + if (consumerKey !== ownerKey) { + // A renamed consumer key can't be expressed by sharedKeys - + // leave it for manual authoring. + unhandled.push({ + file, + key: `${ownerKey} (as ${consumerKey})`, + }); + + return; + } + + service.keys = service.keys.filter((owned) => owned !== ownerKey); + + // Bare sharedKeys require a unique owner - when another + // service still owns the key (a legacy duplicate that could + // not be verified away), emit the dotted form. + const stillOwnedByAService = Object.values(services).some((svc) => + svc.keys.includes(ownerKey) + ); + const ref = stillOwnedByAService ? `shared.${ownerKey}` : ownerKey; + + if (!service.sharedKeys.includes(ref)) { + service.sharedKeys.push(ref); + } + + if ( + !authored.some( + (entry) => + entry.key === ownerKey && + entry.service === consumerService + ) + ) { + authored.push({ key: ownerKey, service: consumerService }); + } + } + ); + + lines.push(' services: {'); + + const renderService = ({ + indent, + keys, + opItem, + serviceName, + sharedKeys = [], + }) => { + lines.push(`${indent}${quoteIfNeeded({ name: serviceName })}: {`); + + // opItem only when already known (1Password scan or local template + // refs) - unbound services resolve lazily on first use. + if (opItem) { + lines.push(`${indent} opItem: ${quote(opItem)},`); + } + + // Keys render as a single empty array or one-per-line, matching + // the init style. + const keysLines = + keys.length === 0 + ? [`${indent} keys: [],`] + : [`${indent} keys: [`] + .concat( + keys + .sort() + .map((key) => `${indent} ${quote(key)},`) + ) + .concat([`${indent} ],`]); + + lines.push(...keysLines); + + if (sharedKeys.length > 0) { + lines.push( + `${indent} sharedKeys: [${sharedKeys + .sort() + .map((key) => quote(key)) + .join(', ')}],` + ); + } + + lines.push(`${indent}},`); + }; + + Object.keys(services) + .sort() + .forEach((service) => { + renderService({ + indent: ' ', + keys: services[service].keys, + opItem: services[service].opItem || nameHints[service] || null, + serviceName: service, + sharedKeys: services[service].sharedKeys, + }); + }); + + lines.push(' },'); + + if (shared.keys.length > 0) { + lines.push(' shared: {'); + + if (shared.opItem) { + lines.push(` opItem: ${quote(shared.opItem)},`); + } + + lines.push(' keys: ['); + + shared.keys.sort().forEach((key) => { + lines.push(` ${quote(key)},`); + }); + + lines.push(' ],'); + lines.push(' },'); + + const keyList = shared.keys.join(', '); + const firstExample = shared.examples[shared.keys[0]]; + + const opItemNote = shared.opItem + ? `shared.opItem is set to the '${boundTo}' 1Password item, which already holds the value (matched by comparing value hashes across ${boundEnvs.join( + ', ' + )}) - nothing needs to move in 1Password.` + : 'Set shared.opItem to the 1Password item that holds the value - it is never created automatically.'; + + notes.push( + `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). ${opItemNote} Every service that uses ${keyList} lists it under sharedKeys - not keys.` + ); + + deduped.forEach(({ example, key, service }) => { + notes.push( + `'${service}' listed ${key} because old secret ids still point at it (${example}). Those values are identical to the shared ones, so it was removed from '${service}'. The old secrets stay in Google Secret Manager until they're retired.` + ); + }); + + if (authored.length > 0) { + const byKey = {}; + + authored.forEach(({ key, service }) => { + if (!byKey[key]) { + byKey[key] = []; + } + + if (!byKey[key].includes(service)) { + byKey[key].push(service); + } + }); + + notes.push( + `Shared-key consumers drafted automatically (sharedKeys): ${Object.keys( + byKey + ) + .sort() + .map((key) => `${byKey[key].sort().join(', ')} -> ${key}`) + .join('; ')} - review them.` + ); + } + } + + // A service owning a key that 'shared' also owns is usually a legacy-id + // artifact - flag it for review. + Object.keys(services) + .sort() + .forEach((serviceName) => { + services[serviceName].keys + .filter((owned) => shared.keys.includes(owned)) + .sort() + .forEach((owned) => { + const example = [...ids.keys()] + .map((id) => ({ + id, + parsed: parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }), + })) + .find( + ({ parsed }) => + parsed && + parsed.service === serviceName && + pathToKey({ path: parsed.key }) === owned + ); + + notes.push( + `'${serviceName}' owns '${owned}'${ + example ? ` (from ids like ${example.id})` : '' + } but 'shared' also owns it - confirm the ownership (legacy ids often cause this).` + ); + }); + }); + + if (unhandled.length > 0) { + const byKey = {}; + + unhandled.forEach(({ file, key }) => { + if (!byKey[key]) { + byKey[key] = []; + } + + if (!byKey[key].includes(file)) { + byKey[key].push(file); + } + }); + + notes.push( + `Consumers that couldn't be drafted automatically: ${Object.keys( + byKey + ) + .sort() + .map((key) => `${key} -> ${byKey[key].sort().join(', ')}`) + .join('; ')} - declare sharedKeys on those services manually.` + ); + } + + lines.push(' },'); + lines.push(''); + + notes.push( + "The draft is a starting point: review each service's keys and opItems, then run `c secrets verify `." + ); + + if (!opScan.names) { + notes.push( + `1Password scan skipped (${opScan.error}) - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` + ); + } + + if (opScan.names && Object.keys(opScan.names).length === 0) { + notes.push( + `1Password scan found no '${itemPrefix}' items - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` + ); + } + + return { + blockText: lines.join('\n'), + notes, + region, + }; +}; + +module.exports = { draftContract }; diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js new file mode 100644 index 0000000..4b0b4f3 --- /dev/null +++ b/bin/lib/c-secrets-io.js @@ -0,0 +1,1695 @@ +'use strict'; + +const { execFile, execFileSync } = require('child_process'); +const { createHash, randomBytes } = require('crypto'); +const espree = require('espree'); +const https = require('https'); +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); +const { homedir } = require('os'); +const { promisify } = require('util'); + +const VAULT = 'Team'; +const OP_SESSION_FILE = join(homedir(), '.local/state/idearium/op-session'); + +// Ceiling for a single Google Secret Manager REST call: without it, a +// stalled connection hangs the calling command indefinitely. +const GSM_TIMEOUT_MS = 30000; + +// Environment variables whose presence indicates a non-interactive agent +// context - plaintext output is hard-blocked while any is set. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CODEX_SANDBOX', + 'GEMINI_CLI', + 'OPENCODE', +]; + +const sha12 = ({ value }) => + createHash('sha256').update(value).digest('hex').slice(0, 12); + +/** + * Resolve the op session token from the environment or `c op session`'s + * state file. Never print or log the return value. + * @returns {String} The session token. + */ +const opSession = () => { + if (process.env.OP_SESSION_idearium) { + return process.env.OP_SESSION_idearium; + } + + try { + return readFileSync(OP_SESSION_FILE, 'utf8').trim(); + } catch (e) { + throw new Error( + 'No 1Password session. Run `c op session` (or export OP_SESSION_idearium), then retry.' + ); + } +}; + +/** + * Run the op cli with the session wired in. Input/values pass via stdin or + * in-memory buffers, never via printed output. + * + * Stdin-bearing calls route through a shell pipe: Node's pipe stdio hands + * children AF_UNIX sockets, not pipe(2) FIFOs, and op only detects piped + * input on real FIFOs - piped straight in, op reports `expected data on + * stdin but none found` or reads nothing. cat reads the socket fine and + * writes into a genuine pipe op accepts. The command lives in the shell + * string and arguments pass verbatim through "$@", so no value is ever + * interpolated into a command line. + * @param {Object} options + * @param {Array} options.args The op arguments. + * @param {String} [options.input] Stdin to pipe into op. + * @returns {String} The command's stdout. + */ +const runOp = ({ args, input }) => { + const session = opSession(); + const opArgs = ['--session', session].concat(args); + + const invocation = + typeof input === 'string' + ? { + args: ['-c', 'cat | op "$@"', 'sh'].concat(opArgs), + command: 'sh', + } + : { args: opArgs, command: 'op' }; + + try { + return execFileSync(invocation.command, invocation.args, { + encoding: 'utf8', + input, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const stderr = String(e.stderr || ''); + + if ( + stderr.includes('not currently signed in') || + stderr.includes('no active session found') + ) { + throw new Error( + 'The 1Password session has expired. Run `c op session`, then retry.' + ); + } + + // Never rethrow the raw error: its message embeds the full + // command line, session token included. Build the message from + // safe parts only - the op arguments (references, not values), + // the exit status, and op's own stderr (scrubbed of the token, + // in case op ever echoes its command line back). + const detail = stderr.trim().split(session).join('***'); + const safeDetail = + detail || + (e.code === 'ENOENT' + ? 'the op cli is not installed' + : e.signal || 'unknown error'); + const status = Number.isInteger(e.status) ? ` (exit ${e.status})` : ''; + + throw new Error(`op ${args.join(' ')} failed${status}: ${safeDetail}`); + } +}; + +/** + * Read a 1Password reference's value (exact bytes, no trailing newline). + * The return value is a secret - never print it; hash it instead. + * @param {Object} options + * @param {String} options.ref The op:// reference. + * @returns {String} The value. + */ +const opRead = ({ ref }) => runOp({ args: ['read', '-n', ref] }); + +/** + * List a 1Password item's fields (labels and sections only). + * @param {Object} options + * @param {String} options.item The item id. + * @returns {Array} Fields with section labels. + */ +const opItemFields = ({ item }) => { + const raw = runOp({ args: ['item', 'get', item, '--format=json'] }); + + return JSON.parse(raw).fields.map((field) => ({ + field: field.label, + section: field.section ? field.section.label : null, + })); +}; + +/** + * Read a 1Password item's fields and values in one call (bulk reads - one + * op invocation per item instead of one per field). + * @param {Object} options + * @param {String} options.item The item id. + * @returns {Object} { fields: [{ field, section }], values } with values + * keyed 'section/field'. + */ +const opItemValues = ({ item }) => { + const raw = JSON.parse( + runOp({ args: ['item', 'get', item, '--format=json'] }) + ); + + const fields = []; + const values = {}; + + (raw.fields || []).forEach((field) => { + const section = field.section ? field.section.label : null; + + fields.push({ field: field.label, section }); + values[`${section}/${field.label}`] = field.value; + }); + + return { fields, values }; +}; + +/** + * Check whether a section/field exists on an item. + * @param {Object} options + * @param {String} options.field The field label. + * @param {String} options.item The item id. + * @param {String} options.section The section label. + * @returns {Boolean} + */ +const opFieldExists = ({ field, item, section }) => + opItemFields({ item }).some( + (candidate) => + candidate.section === section && candidate.field === field + ); + +/** + * Upsert a field on a 1Password item by piping a JSON item template via + * stdin (`op item edit` reads the template from piped input): the value + * never transits argv, where it would be visible to other processes on + * the machine. + * @param {Object} options + * @param {String} options.field The field label. + * @param {String} options.item The item id. + * @param {String} options.section The section label. + * @param {String} options.value The value. + * @returns {Void} + */ +const opUpsertField = ({ field, item, section, value }) => { + const raw = JSON.parse( + runOp({ args: ['item', 'get', item, '--format=json'] }) + ); + + const fields = raw.fields || []; + + const sectionLabel = (candidate) => + candidate.section ? candidate.section.label : null; + + const target = fields.find( + (candidate) => + candidate.label === field && sectionLabel(candidate) === section + ); + + if (target) { + target.value = value; + } + + if (!target) { + // A new field mirrors a sibling of its section so sections stay + // internally consistent; concealed is the safe default for + // secrets. + const sibling = fields.find( + (candidate) => sectionLabel(candidate) === section + ); + + fields.push({ + fieldType: (sibling || {}).fieldType || 'CONCEALED', + label: field, + section: { label: section }, + value, + }); + } + + runOp({ + args: ['item', 'edit', item, '--format=json'], + input: JSON.stringify(raw), + }); +}; + +/** + * Create a 1Password item (DATABASE category, no required built-ins) and + * return its id. + * @param {Object} options + * @param {String} options.title The item title (e.g. ras-rsc/service). + * @returns {String} The new item id. + */ +const opCreateItem = ({ title }) => { + const raw = runOp({ + args: [ + 'item', + 'create', + '--category=database', + `--title=${title}`, + `--vault=${VAULT}`, + '--format=json', + ], + }); + + return JSON.parse(raw).id; +}; + +/** + * Block plaintext output unless the user is at a terminal with no agent + * environment variables set. --force overrides, with a warning. + * @param {Object} options + * @param {Boolean} options.force Whether --force was passed. + * @returns {Void} + */ +const guardPlaintext = ({ force }) => { + if (force) { + process.stderr.write( + 'WARNING: plaintext output forced. Do not paste this into transcripts, files or tickets.\n' + ); + + return; + } + + if (!process.stdout.isTTY) { + throw new Error( + 'Plaintext output requires a terminal (stdout is not a TTY).' + ); + } + + const agent = AGENT_ENV_VARS.find((envVar) => process.env[envVar]); + + if (agent) { + throw new Error( + `Plaintext output is blocked in agent/CI contexts (${agent} is set). Re-run personally, or use --force against your better judgement.` + ); + } +}; + +/** + * Generate a random value for `set --generate`. + * @param {Object} options + * @param {Number} options.length The desired length (characters). + * @param {String} options.type hex, base64 or alphanumeric. + * @returns {String} The generated value. + */ +const generateValue = ({ length = 32, type = 'hex' }) => { + if (type === 'hex') { + return randomBytes(Math.ceil(length / 2)) + .toString('hex') + .slice(0, length); + } + + if (type === 'base64') { + return randomBytes(length).toString('base64').slice(0, length); + } + + if (type === 'alphanumeric') { + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = randomBytes(length); + + return Array.from(bytes) + .map((byte) => alphabet[byte % alphabet.length]) + .join(''); + } + + throw new Error(`Unknown --type '${type}' (hex, base64, alphanumeric)`); +}; + +/** + * Generate a random value from a parsed commander program's --generate + * flags, failing with a friendly error for malformed input (a bare -l + * parses to true then NaN; a bare -t parses to true). + * @param {Object} options + * @param {Object} options.program The parsed commander program. + * @returns {String} The generated value. + */ +const generateValueForProgram = ({ program }) => { + if (program.T === true) { + throw new Error( + '--type requires a value: hex, base64 or alphanumeric.' + ); + } + + // A bare -l parses to true; an absent one leaves L undefined, so the + // destructuring default supplies 32. + const { L = 32 } = program; + const length = parseInt(L, 10); + + if (!Number.isInteger(length) || length < 1) { + throw new Error('--length must be a positive integer.'); + } + + return generateValue({ length, type: program.T }); +}; + +/** + * Map gcloud authentication failures to a friendly, actionable error. + * @param {Object} options + * @param {String} options.stderr The failed command's stderr. + * @returns {Error|null} The friendly error, or null when not auth-related. + */ +const gcloudAuthError = ({ stderr }) => { + const phrases = [ + 'Reauthentication failed', + 'to obtain new credentials', + 'cannot prompt during non-interactive execution', + 'do not currently have an active account selected', + ]; + + return phrases.some((phrase) => stderr.includes(phrase)) + ? new Error( + 'gcloud authentication has expired. Run `gcloud auth login` (interactive), then retry.' + ) + : null; +}; + +/** + * Run gcloud and return its stdout. + * @param {Object} options + * @param {Array} options.args The gcloud arguments. + * @returns {String} The command's stdout. + */ +const runGcloud = ({ args }) => { + try { + return execFileSync('gcloud', args, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const friendly = gcloudAuthError({ stderr: String(e.stderr) }); + + throw friendly || e; + } +}; + +const execFileAsync = promisify(execFile); + +/** + * Run gcloud asynchronously and resolve with its stdout, mapping + * authentication failures to the same friendly error as runGcloud. + * @param {Object} options + * @param {Array} options.args The gcloud arguments. + * @returns {Promise} Resolves with the command's stdout. + */ +const runGcloudAsync = ({ args }) => + execFileAsync('gcloud', args, { encoding: 'utf8' }).then( + ({ stdout }) => stdout, + (e) => { + const friendly = gcloudAuthError({ stderr: String(e.stderr) }); + + throw friendly || e; + } + ); + +/** + * Run promise-returning work over items with bounded concurrency, + * resolving with each item's result in input order. + * @param {Object} options + * @param {Array} options.items The items to process. + * @param {Number} options.limit The concurrency limit. + * @param {Function} options.run Called with an item; returns a Promise. + * @returns {Promise} Resolves with the array of results. + */ +const pool = ({ items, limit, run }) => { + const results = new Array(items.length); + let index = 0; + + const worker = () => { + if (index >= items.length) { + return Promise.resolve(); + } + + const slot = index++; + const item = items[slot]; + + return Promise.resolve(run(item)).then((result) => { + results[slot] = result; + + return worker(); + }); + }; + + return Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker) + ).then(() => results); +}; + +/** + * Assert gcloud is authenticated (up front, before any work) for commands + * that touch Google Cloud. + * @returns {Void} + */ +const assertGcloudAuth = () => { + runGcloud({ args: ['auth', 'print-access-token'] }); +}; + +/** + * Make a Google Secret Manager REST call against the global endpoint. + * Payloads are built in-process and never logged. + * @param {Object} options + * @param {String} [options.body] A JSON request body. + * @param {String} options.method The HTTP method. + * @param {String} options.path The path after /v1. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the parsed JSON response. + */ +/** + * Parse a Google Secret Manager response body: friendly errors for the + * failure and unparseable-success cases, the parsed JSON otherwise. + * gsmRequest's response handler runs this inside a try/catch so a throw + * rejects the request promise instead of crashing the process inside an + * event handler. + * @param {Object} options + * @param {String} options.method The HTTP method (for error messages). + * @param {String} options.path The request path (for error messages). + * @param {String} options.raw The response body. + * @param {Number} options.statusCode The response status code. + * @returns {Object} The parsed JSON response ({} for an empty body). + */ +const parseGsmResponse = ({ method, path, raw, statusCode }) => { + if (statusCode >= 300) { + throw new Error( + `GSM ${method} ${path} failed (${statusCode}): ${raw.slice(0, 200)}` + ); + } + + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw); + } catch (e) { + throw new Error( + `GSM ${method} ${path} returned an unparseable response: ${raw.slice( + 0, + 200 + )}` + ); + } +}; + +const gsmRequest = ({ body, method, path, project }) => + new Promise((resolve, reject) => { + const token = runGcloud({ args: ['auth', 'print-access-token'] }); + + const request = https.request( + { + headers: { + 'authorization': `Bearer ${token.trim()}`, + 'content-type': 'application/json', + }, + host: 'secretmanager.googleapis.com', + method, + // Every dynamic segment is encoded at its call site: key + // characters are validated at contract load, but org, + // repo, env and service names are user-authored. + path: `/v1/projects/${encodeURIComponent(project)}${path}`, + timeout: GSM_TIMEOUT_MS, + }, + (res) => { + let raw = ''; + + res.on('data', (chunk) => { + raw += chunk; + }); + + res.on('end', () => { + try { + return resolve( + parseGsmResponse({ + method, + path, + raw, + statusCode: res.statusCode, + }) + ); + } catch (failure) { + return reject(failure); + } + }); + } + ); + + // Without a destroy, the timeout event alone leaves the socket + // (and the command) hanging. + request.on('timeout', () => { + request.destroy( + new Error( + `GSM ${method} ${path} timed out after ${ + GSM_TIMEOUT_MS / 1000 + }s - retry` + ) + ); + }); + + request.on('error', reject); + + if (body) { + request.write(JSON.stringify(body)); + } + + request.end(); + }); + +/** + * Check whether a GSM secret exists (and return its labels). + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the secret resource, or null. + */ +const gsmDescribe = ({ id, project }) => + gsmRequest({ + method: 'GET', + path: `/secrets/${encodeURIComponent(id)}`, + project, + }).catch((e) => { + if (e.message.includes('(404)')) { + return null; + } + + throw e; + }); + +/** + * Create a pinned-global GSM secret with the standard labels. + * @param {Object} options + * @param {String} options.env The environment label. + * @param {String} options.id The GSM secret id. + * @param {String} options.managedBy The managed-by label (org-repo). + * @param {String} options.project The GCP project id. + * @param {String} options.region The replication region. + * @param {String} options.service The service label. + * @returns {Promise} + */ +const gsmCreate = ({ env, id, managedBy, project, region, service }) => + gsmRequest({ + body: { + labels: { + 'environment': env, + 'managed-by': managedBy, + service, + }, + replication: { + userManaged: { + replicas: [{ location: region }], + }, + }, + }, + method: 'POST', + path: `/secrets?secretId=${encodeURIComponent(id)}`, + project, + }); + +/** + * Add a version to a GSM secret. The value is base64-encoded in-process. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @param {String} options.value The secret value. + * @returns {Promise} + */ +const gsmAddVersion = ({ id, project, value }) => + gsmRequest({ + body: { + payload: { data: Buffer.from(value, 'utf8').toString('base64') }, + }, + method: 'POST', + path: `/secrets/${encodeURIComponent(id)}:addVersion`, + project, + }); + +/** + * Read a GSM secret's latest version. The return value is a secret - never + * print it; hash it instead. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the value as a Buffer. + */ +const gsmRead = ({ id, project }) => + gsmRequest({ + method: 'GET', + path: `/secrets/${encodeURIComponent(id)}/versions/latest:access`, + project, + }).then((res) => Buffer.from(res.payload.data, 'base64')); + +/** + * Delete a GSM secret. Point of no return - callers must gate this on a + * consumers check. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} + */ +const gsmDelete = ({ id, project }) => + gsmRequest({ + method: 'DELETE', + path: `/secrets/${encodeURIComponent(id)}`, + project, + }); + +/** + * List every GSM secret id in the project. + * @param {Object} options + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with an array of secret ids. + */ +const gsmList = ({ project }) => { + const ids = []; + + const page = (pageToken) => + gsmRequest({ + method: 'GET', + path: `/secrets${ + pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : '' + }`, + project, + }).then((res) => { + (res.secrets || []).forEach((secret) => { + ids.push(secret.name.split('/').pop()); + }); + + if (res.nextPageToken) { + return page(res.nextPageToken); + } + + return ids; + }); + + return page(''); +}; + +/** + * List the project's Cloud Functions (scoped to the repo's name prefix) + * with the GSM secret ids each consumes. Function names do not encode the + * environment (e.g. beta carries an infix, production does not) - the env + * lives in the referenced secret ids. + * @param {Object} options + * @param {String} options.project The GCP project id. + * @param {String} options.region The region. + * @param {String} options.repoPrefix The function-name prefix (org-repo-). + * @returns {Promise} Resolves with [{ name, secrets: [secretId] }]. + */ +const gcfList = ({ project, region, repoPrefix }) => + runGcloudAsync({ + args: [ + 'functions', + 'list', + `--filter=name~functions/${repoPrefix}`, + `--format=value(name)`, + `--project=${project}`, + ], + }) + .then((raw) => + raw + .split('\n') + .map((name) => name.trim().split('/').pop()) + .filter((name) => name.length > 0) + ) + .then((names) => + pool({ + items: names, + limit: 6, + run: (name) => + runGcloudAsync({ + args: [ + 'functions', + 'describe', + name, + `--format=json`, + `--project=${project}`, + `--region=${region}`, + ], + }).then((raw) => { + const secrets = ( + (JSON.parse(raw).serviceConfig || {}) + .secretEnvironmentVariables || [] + ).map((entry) => entry.secret); + + return { name, secrets }; + }), + }) + ); + +/** + * Map kubectl failures to a friendly, actionable error. + * @param {Object} options + * @param {String} options.context The kubectl context (for messages). + * @param {String} options.stderr The failed command's stderr. + * @returns {Error|null} The friendly error, or null when unmapped. + */ +const kubectlError = ({ context, stderr }) => { + if ( + /context "[^"]+" does not exist/.test(stderr) || + stderr.includes('no context exists') || + stderr.includes('context was not found') + ) { + return new Error( + `kubectl has no context '${context}'. Run \`c kc context set\`, then retry.` + ); + } + + const unreachable = [ + 'Unable to connect to the server', + 'You must be logged in', + 'Unauthorized', + 'error: exec', + ]; + + return unreachable.some((phrase) => stderr.includes(phrase)) + ? new Error( + `kubectl cannot reach the cluster '${context}' (credentials expired or cluster unreachable). Run \`gcloud auth login\` if expired, and confirm the context with \`c kc context set\`, then retry.` + ) + : null; +}; + +/** + * Run kubectl and return its stdout, with friendly failure mapping. + * @param {Object} options + * @param {Array} options.args The kubectl arguments. + * @param {String} options.context The kubectl context. + * @returns {String} The command's stdout. + */ +const runKubectl = ({ args, context }) => { + try { + return execFileSync('kubectl', ['--context', context].concat(args), { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const friendly = kubectlError({ context, stderr: String(e.stderr) }); + + throw friendly || e; + } +}; + +/** + * Assert kubectl can reach the context's server with valid credentials + * (up front, before any work) for commands that read the cluster. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @returns {Void} + */ +const assertKubectl = ({ context }) => { + try { + execFileSync( + 'kubectl', + [ + '--context', + context, + '--request-timeout=10s', + 'get', + '--raw', + '/version', + ], + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + } catch (e) { + const friendly = kubectlError({ context, stderr: String(e.stderr) }); + + if (friendly) { + throw friendly; + } + + throw new Error( + `kubectl cannot reach the cluster '${context}'. Confirm the context with \`c kc context set\`, then retry. (${String( + e.stderr || e.message + ) + .trim() + .slice(0, 120)})` + ); + } +}; + +/** + * Read a Kubernetes secret in one call: its keys and their decoded values + * (bulk reads - one kubectl invocation per secret instead of one per key). + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.namespace The namespace. + * @param {String} options.secret The Kubernetes secret name. + * @returns {Object} { keys, values } with values keyed and decoded. + */ +const k8sReadSecret = ({ context, namespace, secret }) => { + const raw = runKubectl({ + args: ['-n', namespace, 'get', 'secret', secret, '-o', 'json'], + context, + }); + + const data = JSON.parse(raw).data || {}; + const values = {}; + + Object.keys(data).forEach((key) => { + values[key] = Buffer.from(data[key], 'base64'); + }); + + return { keys: Object.keys(values), values }; +}; + +/** + * List every SecretProviderClass in a namespace with the GSM ids their + * parameters reference. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.namespace The namespace. + * @returns {Promise} Resolves with [{ name, ids: [secretId] }]. + */ +const spcList = ({ context, namespace }) => + new Promise((resolve, reject) => { + let raw = ''; + + try { + raw = runKubectl({ + args: [ + '-n', + namespace, + 'get', + 'secretproviderclass', + '-o', + 'json', + ], + context, + }); + } catch (e) { + return reject(e); + } + + const items = JSON.parse(raw).items.map((item) => ({ + ids: + ((item.spec.parameters || {}).secrets || '').match( + /resourceName:\s*'projects\/[^/]+\/secrets\/([^']+)'/g + ) || [], + name: item.metadata.name, + })); + + return resolve( + items.map((item) => ({ + ids: item.ids.map((entry) => + entry + .replace(/.*secrets\//, '') + .replace(/'/g, '') + .replace(/\/versions\/latest$/, '') + ), + name: item.name, + })) + ); + }); + +/** + * Assert a warm 1Password session (up front, before any work) for commands + * that read or write op values. Throws the friendly session errors. + * @returns {Void} + */ +const assertOpSession = () => { + opSession(); + runOp({ args: ['whoami', '--format=json'] }); +}; + +/** + * List the Team vault's 1Password items in one call (bulk title lookups - + * one item list instead of one per title). + * @returns {Array} The raw items (id, title, ...). + */ +const opListItems = () => + JSON.parse( + runOp({ args: ['item', 'list', '--vault=Team', '--format=json'] }) + ); + +/** + * Find a 1Password item by exact title, returning its id (or null). + * @param {Object} options + * @param {String} options.title The item title. + * @returns {String|null} The item id. + */ +const opFindItemByTitle = ({ title }) => { + const item = opListItems().find((candidate) => candidate.title === title); + + return item ? item.id : null; +}; + +/** + * Resolve a 1Password item: a bound opItem wins; otherwise look the item + * up by title; when creation is not allowed, fail with a guidance error + * instead (the shared block, which never creates, gets its own message). + * @param {Object} options + * @param {Boolean} options.create Whether an item may be created. + * @param {String} [options.noCreateMessage] A specific error message when + * creation is not allowed (used for the shared block, which never creates). + * @param {String} [options.opItem] The bound opItem, when present. + * @param {String} options.title The item title ({org}-{repo}/{service}). + * @returns {Object} { created, id, persisted } - persisted true when the + * binding should be written back into c.js. + */ +const resolveOpItem = ({ create, noCreateMessage, opItem, title }) => { + if (opItem) { + return { created: false, id: opItem, persisted: false }; + } + + const found = opFindItemByTitle({ title }); + + if (found) { + return { created: false, id: found, persisted: true }; + } + + if (!create) { + throw new Error( + noCreateMessage || + `No 1Password item '${title}' - read commands never create items. Bind one first: c op set (or add) the service's first key.` + ); + } + + return { created: true, id: opCreateItem({ title }), persisted: true }; +}; + +/** + * Resolve an owning entry's 1Password item without ever creating one: a + * bound opItem wins; otherwise the item is looked up by title, failing + * with a guidance error when it does not exist. Every read path uses + * this - 1Password is never mutated. + * @param {Object} options + * @param {Object} options.owner The owning service or shared entry. + * @param {String} options.title The item title ({org}-{repo}/{service}). + * @returns {Object} { created, id, persisted } - persisted true when the + * binding should be written back into c.js. + */ +const resolveOwnerItem = ({ owner, title }) => { + if (owner && owner.name === 'shared') { + return resolveOpItem({ + create: false, + noCreateMessage: + "The 'shared' block stores its values in a 1Password item you choose - set shared.opItem in c.js (often the item of a service that uses the key).", + opItem: owner.opItem, + title, + }); + } + + return resolveOpItem({ + create: false, + opItem: owner && owner.opItem, + title, + }); +}; + +/** + * Resolve an owning entry's 1Password item on a write path: services + * resolve (or create) their item; the shared block only ever resolves + * (where a shared value lives is the author's decision, never the + * tool's). + * @param {Object} options + * @param {Object} options.owner The owning service or shared entry. + * @param {String} options.title The item title ({org}-{repo}/{service}). + * @returns {Object} { created, id, persisted }. + */ +const resolveOrCreateOwnerItem = ({ owner, title }) => { + if (owner && owner.name === 'shared') { + return resolveOwnerItem({ owner, title }); + } + + return resolveOpItem({ + create: true, + opItem: owner && owner.opItem, + title, + }); +}; + +/** + * The c.js file's path in the project directory. + * @returns {String} The absolute file path. + */ +const cjsPath = () => join(process.cwd(), 'c.js'); + +/** + * Read the c.js file's text from the project directory. + * @returns {String} The file contents. + */ +const readCjs = () => readFileSync(cjsPath(), 'utf8'); + +/** + * Write the c.js file's text back to the project directory. + * @param {Object} options + * @param {String} options.text The new file contents. + * @returns {Void} + */ +const writeCjs = ({ text }) => writeFileSync(cjsPath(), text); + +/** + * Quote a service/key name for c.js: single quotes only when required. + * @param {Object} options + * @param {String} options.name The name to quote. + * @returns {String} The (maybe quoted) name. + */ +const quoteIfNeeded = ({ name }) => + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`; + +/** + * Parse c.js text into an AST, returning the module.exports object. + * Positions come from the parser, not regexes, so formatting (quoted + * names, comments, single vs multi-line layouts) cannot mislocate an + * edit. + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} The module.exports ObjectExpression node. + */ +const parseCjs = ({ text }) => { + const ast = espree.parse(text, { ecmaVersion: 2020, range: true }); + + const assignment = ast.body.find( + (statement) => + statement.type === 'ExpressionStatement' && + statement.expression.type === 'AssignmentExpression' && + statement.expression.left.type === 'MemberExpression' && + statement.expression.left.object.name === 'module' && + statement.expression.left.property.name === 'exports' && + statement.expression.right.type === 'ObjectExpression' + ); + + if (!assignment) { + throw new Error( + 'Could not find a module.exports object in c.js - add the contract manually.' + ); + } + + return assignment.expression.right; +}; + +/** + * Find a property of an ObjectExpression by name (quoted or unquoted). + * @param {Object} options + * @param {String} options.name The property name. + * @param {Object} options.object The ObjectExpression node. + * @returns {Object|undefined} The Property node, when present. + */ +const findProperty = ({ name, object }) => + object.properties.find( + (property) => + property.type === 'Property' && + (property.key.name === name || property.key.value === name) + ); + +/** + * The index of the start of the line containing the given index. + * @param {Object} options + * @param {Number} options.index A position within the text. + * @param {String} options.text The full text. + * @returns {Number} The line's start index. + */ +const lineStartIndex = ({ index, text }) => text.lastIndexOf('\n', index) + 1; + +/** + * The whitespace between the start of the line and the given index (the + * indentation of a node that starts its own line). + * @param {Object} options + * @param {Number} options.index A position within the text. + * @param {String} options.text The full text. + * @returns {String} The leading whitespace. + */ +const indentBefore = ({ index, text }) => + text.slice(lineStartIndex({ index, text }), index); + +/** + * The file's dominant line ending, so insertions never mix endings. + * @param {Object} options + * @param {String} options.text The full text. + * @returns {String} '\r\n' or '\n'. + */ +const lineEndingOf = ({ text }) => (text.includes('\r\n') ? '\r\n' : '\n'); + +/** + * The secrets contract's ObjectExpression within c.js: only properties of + * this node belong to the contract, as c.js may contain identically-named + * properties elsewhere (docker locations etc). + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} The secrets ObjectExpression node. + */ +const secretsObject = ({ text }) => { + const root = parseCjs({ text }); + + const secrets = findProperty({ name: 'secrets', object: root }); + + if (!secrets || secrets.value.type !== 'ObjectExpression') { + throw new Error( + 'Could not find secrets in c.js - add the contract manually.' + ); + } + + return secrets.value; +}; + +/** + * A service entry's Property node inside secrets.services, or the shared + * block's, failing with actionable messages when absent. + * @param {Object} options + * @param {String} options.name The service name, or 'shared'. + * @param {Object} options.secrets The secrets ObjectExpression node. + * @returns {Object} The entry's Property node. + */ +const entryProperty = ({ name, secrets }) => { + if (name === 'shared') { + const shared = findProperty({ name: 'shared', object: secrets }); + + if (!shared) { + throw new Error( + 'Could not find secrets.shared in c.js - add the block manually.' + ); + } + + return shared; + } + + const services = findProperty({ name: 'services', object: secrets }); + + if (!services || services.value.type !== 'ObjectExpression') { + throw new Error( + 'Could not find secrets.services in c.js - add the service manually.' + ); + } + + const service = findProperty({ name, object: services.value }); + + if (!service) { + throw new Error( + `Could not find service '${name}' in secrets.services in c.js.` + ); + } + + return service; +}; + +/** + * Insert a rendered property (or multi-line block) into an object, right + * before its closing brace, matching the object's own layout: a closing + * brace on its own line gets the property on its own line at the + * properties' indentation (with the file's line ending), a one-line + * object stays one-line. A final property written without a trailing + * comma gets one so the sibling parses. + * @param {Object} options + * @param {String} options.line The rendered property (may be multi-line). + * @param {Object} options.object The ObjectExpression node to insert into. + * @param {String} options.text The c.js file contents. + * @returns {String} The edited text (not yet written). + */ +const insertPropertyIntoObject = ({ line, object: obj, text }) => { + const closeIndex = obj.range[1] - 1; + const closingIndent = indentBefore({ index: closeIndex, text }); + const braceOnOwnLine = + text.slice(obj.range[0], closeIndex).includes('\n') && + /^\s*$/.test(closingIndent); + + if (!braceOnOwnLine) { + const separator = obj.properties.length > 0 ? ', ' : ' '; + + return `${text.slice(0, closeIndex)}${separator}${line} ${text.slice( + closeIndex + )}`; + } + + const lineEnding = lineEndingOf({ text }); + const [firstProperty] = obj.properties; + // An object's own line ends in 'name: {' - its indent is the line's + // leading whitespace, not the text before the brace. + const [objectIndent] = /^\s*/.exec( + text.slice(lineStartIndex({ index: obj.range[0], text })) + ); + const propertyIndent = firstProperty + ? indentBefore({ index: firstProperty.range[0], text }) + : `${objectIndent} `; + + // An empty object (even written across lines) takes its first + // property between the braces. + if (obj.properties.length === 0) { + return ( + text.slice(0, obj.range[0] + 1) + + `${lineEnding}${propertyIndent}${line}${lineEnding}${closingIndent}` + + text.slice(closeIndex) + ); + } + + const lastProperty = obj.properties[obj.properties.length - 1]; + const commaFix = text.slice(lastProperty.range[1], closeIndex).includes(',') + ? '' + : ','; + + return ( + text.slice(0, lastProperty.range[1]) + + commaFix + + text.slice( + lastProperty.range[1], + lineStartIndex({ index: closeIndex, text }) + ) + + `${propertyIndent}${line}${lineEnding}${closingIndent}` + + text.slice(closeIndex) + ); +}; + +/** + * The freshly parsed service entry, failing loudly when an edit did not + * land in secrets.services. + * @param {Object} options + * @param {Object} options.config The freshly required c.js configuration. + * @param {String} options.name The service name. + * @returns {Object} The parsed service entry. + */ +const expectService = ({ config, name }) => { + const service = + config.secrets && + config.secrets.services && + config.secrets.services[name]; + + if (!service) { + throw new Error(`secrets.services.${name} is missing after the edit`); + } + + return service; +}; + +/** + * The freshly parsed service or shared entry ('shared' addresses the + * shared block, a sibling of services), failing loudly when an edit did + * not land in the secrets block. + * @param {Object} options + * @param {Object} options.config The freshly required c.js configuration. + * @param {String} options.name The service name, or 'shared'. + * @returns {Object} The parsed entry. + */ +const expectEntry = ({ config, name }) => { + if (name === 'shared') { + const shared = config.secrets && config.secrets.shared; + + if (!shared) { + throw new Error('secrets.shared is missing after the edit'); + } + + return shared; + } + + return expectService({ config, name }); +}; + +/** + * Re-parse c.js after an edit and verify the expectation against the + * parsed result, restoring the original text when it fails: an edit that + * misses its target block (or breaks the file) is never kept. + * @param {Object} options + * @param {String} options.backup The pre-edit file text. + * @param {Function} options.expect Receives the freshly parsed c.js + * configuration; throws when the edit did not land as intended. + * @returns {Void} + */ +const verifyCjsEdit = ({ backup, expect }) => { + delete require.cache[require.resolve(cjsPath())]; + + try { + expect(require(cjsPath())); + } catch (e) { + writeCjs({ text: backup }); + + delete require.cache[require.resolve(cjsPath())]; + + throw new Error( + `The c.js edit did not land as expected and was rolled back: ${e.message}` + ); + } +}; + +/** + * Register a new service in c.js's secrets contract (used by set/add when + * the service doesn't exist yet). Inserts alphabetically into + * secrets.services, then re-parses c.js to verify the edit landed. + * @param {Object} options + * @param {String} options.key The first key the service owns. + * @param {String} options.name The service name. + * @param {String} options.opItem The 1Password item id. + * @returns {Void} + */ +const registerServiceInCjs = ({ key, name, opItem }) => { + const text = readCjs(); + const secrets = secretsObject({ text }); + const services = findProperty({ name: 'services', object: secrets }); + + if (!services || services.value.type !== 'ObjectExpression') { + throw new Error( + 'Could not find secrets.services in c.js - add the service manually.' + ); + } + + const servicesObjectNode = services.value; + + const verify = () => + verifyCjsEdit({ + backup: text, + expect: (config) => { + const service = expectService({ config, name }); + + if (!service.keys.includes(key) || service.opItem !== opItem) { + throw new Error( + `secrets.services.${name} does not carry the registered entry` + ); + } + }, + }); + + const lineEnding = lineEndingOf({ text }); + const servicesIndent = indentBefore({ index: services.range[0], text }); + const propertyIndent = `${servicesIndent} `; + + const block = [ + `${propertyIndent}${quoteIfNeeded({ name })}: {`, + `${propertyIndent} keys: ['${key}'],`, + `${propertyIndent} opItem: '${opItem}',`, + `${propertyIndent}},`, + ].join(lineEnding); + + // The minimal scaffold's single-line empty map: expand it. + if (servicesObjectNode.properties.length === 0) { + const expanded = [`{`, `${block}`, `${servicesIndent}}`].join( + lineEnding + ); + + writeCjs({ + text: + text.slice(0, servicesObjectNode.range[0]) + + expanded + + text.slice(servicesObjectNode.range[1]), + }); + + return verify(); + } + + // Insert before the first service sorting after the new one. + const target = servicesObjectNode.properties.find( + (property) => + property.type === 'Property' && + (property.key.name || property.key.value) > name + ); + + if (target) { + const insertIndex = lineStartIndex({ index: target.range[0], text }); + + writeCjs({ + text: + text.slice(0, insertIndex) + + `${block}${lineEnding}` + + text.slice(insertIndex), + }); + + return verify(); + } + + // Alphabetically last: append before the services closing brace. + writeCjs({ + text: insertPropertyIntoObject({ + line: block.trim(), + object: servicesObjectNode, + text, + }), + }); + + return verify(); +}; + +/** + * Add a key to an entry's keys array in c.js (a service, or the shared + * block) with insertion-only edits: the new element is spliced in at its + * sorted position between existing elements, so the array's own layout, + * comments and trailing-comma style all survive. An entry without a keys + * array gets one. The edit is re-parsed to verify it landed. + * @param {Object} options + * @param {String} options.key The key to add. + * @param {String} options.name The service name, or 'shared'. + * @returns {Void} + */ +const addKeyToEntryInCjs = ({ key, name }) => { + const text = readCjs(); + const secrets = secretsObject({ text }); + const entry = entryProperty({ name, secrets }); + + if (entry.value.type !== 'ObjectExpression') { + throw new Error( + `Could not find a keys array for '${name}' in c.js - add '${key}' manually.` + ); + } + + const keysProperty = findProperty({ name: 'keys', object: entry.value }); + + const verify = () => + verifyCjsEdit({ + backup: text, + expect: (config) => { + if (!expectEntry({ config, name }).keys.includes(key)) { + throw new Error( + `secrets entry '${name}' does not include '${key}' in keys after the edit` + ); + } + }, + }); + + // No keys array yet: create one in the entry's own layout. + if (!keysProperty) { + writeCjs({ + text: insertPropertyIntoObject({ + line: `keys: ['${key}'],`, + object: entry.value, + text, + }), + }); + + return verify(); + } + + const array = keysProperty.value; + + const editable = + array.type === 'ArrayExpression' && + array.elements.every( + (element) => + element && + element.type === 'Literal' && + typeof element.value === 'string' + ); + + if (!editable) { + throw new Error( + `Could not find a plain keys array for '${name}' in c.js - add '${key}' manually.` + ); + } + + if (array.elements.some((element) => element.value === key)) { + return; + } + + // An empty array renders its first element, keeping the bracket + // layout it was written with. + if (array.elements.length === 0) { + const propertyIndent = indentBefore({ + index: keysProperty.range[0], + text, + }); + const multiline = text + .slice(array.range[0], array.range[1]) + .includes('\n'); + + const rendered = multiline + ? `[\n${propertyIndent} '${key}',\n${propertyIndent}]` + : `['${key}']`; + + writeCjs({ + text: + text.slice(0, array.range[0]) + + rendered + + text.slice(array.range[1]), + }); + + return verify(); + } + + const lineEnding = lineEndingOf({ text }); + + // Insert before the first element sorting after the key, matching + // the existing element layout. + const target = array.elements.find((element) => element.value > key); + + if (target) { + const targetIndent = indentBefore({ index: target.range[0], text }); + const onOwnLine = /^\s*$/.test(targetIndent); + const separator = onOwnLine ? `${lineEnding}${targetIndent}` : ' '; + + writeCjs({ + text: + text.slice(0, target.range[0]) + + `'${key}',${separator}` + + text.slice(target.range[0]), + }); + + return verify(); + } + + // Alphabetically last: attach after the last element, which hands + // the new element the old last element's trailing separator. + const last = array.elements[array.elements.length - 1]; + const gap = text.slice(last.range[1], array.range[1] - 1); + const separator = gap.includes('\n') + ? `${lineEnding}${indentBefore({ index: last.range[0], text })}` + : ' '; + + writeCjs({ + text: + text.slice(0, last.range[1]) + + `,${separator}'${key}'` + + text.slice(last.range[1]), + }); + + return verify(); +}; + +/** + * Bind an entry's opItem in c.js (inserting the property when the block + * has none) - used to persist lazily resolved item ids. The entry is a + * service or the shared block, and the edit is re-parsed to verify it + * landed. + * @param {Object} options + * @param {String} options.name The service name, or 'shared'. + * @param {String} options.opItem The 1Password item id. + * @returns {Void} + */ +const setOpItemInCjs = ({ name, opItem }) => { + const text = readCjs(); + const secrets = secretsObject({ text }); + const entry = entryProperty({ name, secrets }); + + if (entry.value.type !== 'ObjectExpression') { + throw new Error( + `Could not find the entry '${name}' in c.js - set its opItem manually.` + ); + } + + if (findProperty({ name: 'opItem', object: entry.value })) { + return; + } + + writeCjs({ + text: insertPropertyIntoObject({ + line: `opItem: '${opItem}',`, + object: entry.value, + text, + }), + }); + + verifyCjsEdit({ + backup: text, + expect: (config) => { + if (expectEntry({ config, name }).opItem !== opItem) { + throw new Error( + `secrets entry '${name}' does not carry the opItem binding after the edit` + ); + } + }, + }); +}; + +/** + * Push a value to a GSM secret and verify the readback by hash: create the + * secret (with labels, pinned-global replication) when missing, add a + * version otherwise. versions/latest is eventually consistent, so the + * readback retries briefly before declaring a mismatch. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment (labels). + * @param {String} options.id The GSM secret id. + * @param {String} options.owner The owning service name (labels). + * @param {String} options.value The secret value. + * @returns {Promise} Resolves with { created, sourceSha, readbackSha }. + */ +const gsmPushVerified = ({ contract, env, id, owner, value }) => { + const attempts = 5; + const delayMs = 3000; + + const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + const sourceSha = sha12({ value: Buffer.from(value, 'utf8') }); + + return gsmDescribe({ id, project: contract.project }).then((secret) => { + const created = !secret; + + const create = secret + ? Promise.resolve() + : gsmCreate({ + env, + id, + managedBy: `${contract.organisation}-${contract.name}`, + project: contract.project, + region: contract.region, + service: owner, + }); + + return create + .then(() => gsmAddVersion({ id, project: contract.project, value })) + .then(() => { + const attempt = (remaining) => + gsmRead({ id, project: contract.project }).then( + (readback) => { + const readbackSha = sha12({ value: readback }); + + if (readbackSha === sourceSha) { + return { created, readbackSha, sourceSha }; + } + + if (remaining > 0) { + return sleep(delayMs).then(() => + attempt(remaining - 1) + ); + } + + throw new Error( + `Readback MISMATCH for ${id} (source:${sourceSha} gsm:${readbackSha}) - versions/latest may still be propagating.` + ); + } + ); + + return attempt(attempts); + }); + }); +}; + +module.exports = { + OP_SESSION_FILE, + addKeyToEntryInCjs, + assertGcloudAuth, + assertKubectl, + assertOpSession, + cjsPath, + gcfList, + generateValue, + generateValueForProgram, + guardPlaintext, + gsmAddVersion, + gsmCreate, + gsmDelete, + gsmDescribe, + gsmList, + gsmPushVerified, + gsmRead, + k8sReadSecret, + opCreateItem, + opFieldExists, + opFindItemByTitle, + opItemFields, + opItemValues, + opListItems, + opRead, + opSession, + opUpsertField, + parseGsmResponse, + pool, + quoteIfNeeded, + registerServiceInCjs, + resolveOrCreateOwnerItem, + resolveOwnerItem, + runKubectl, + runOp, + setOpItemInCjs, + sha12, + spcList, +}; diff --git a/bin/lib/c-secrets-manifests.js b/bin/lib/c-secrets-manifests.js new file mode 100644 index 0000000..e9c8d50 --- /dev/null +++ b/bin/lib/c-secrets-manifests.js @@ -0,0 +1,368 @@ +'use strict'; + +const { resolve: resolvePath, join } = require('path'); +const { access, chmod, ensureDir, writeFile } = require('fs-extra'); + +const { formatProjectPrefix } = require('./c-project'); +const { getSecretsContract, getService, serviceKeys } = require('./c-secrets'); +const { encodeSecretStringData, injectSecretReferences } = require('./c-kc'); + +/** + * Render a SecretProviderClass for a contract service: one entry per key + * (owned and borrowed, alphabetical), global-format resource names. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content. + */ +const renderSecretProviderClass = ({ entries, namespace, service }) => + [ + 'apiVersion: secrets-store.csi.x-k8s.io/v1', + 'kind: SecretProviderClass', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secretproviderclass', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'spec:', + ' provider: gke', + ' parameters:', + ' secrets: |', + ] + .concat( + entries.map( + (resolved) => + ` - resourceName: '${resolved.resourceName}'\n path: '${resolved.path}'` + ) + ) + .concat(['']) + .join('\n'); + +/** + * Render a SecretSync for a contract service: sourcePath is the key + * lowercase-hyphenated, targetKey the canonical environment-variable name. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content. + */ +const renderSecretSync = ({ entries, namespace, service }) => + [ + 'apiVersion: secret-sync.gke.io/v1', + 'kind: SecretSync', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secretsync', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'spec:', + ' serviceAccountName: secret-sync', + ` secretProviderClassName: ${service}`, + ' secretObject:', + ' type: Opaque', + ' data:', + ] + .concat( + entries.map( + (resolved) => + ` - sourcePath: '${resolved.path}'\n targetKey: '${resolved.key}'` + ) + ) + .concat(['']) + .join('\n'); + +/** + * Render the secret-sync ServiceAccount for the namespace. + * @param {Object} options + * @param {String} options.namespace The target namespace. + * @returns {String} The manifest content. + */ +const renderSecretSyncServiceAccount = ({ namespace }) => + [ + 'apiVersion: v1', + 'kind: ServiceAccount', + 'metadata:', + ' labels:', + ' svc: secretsync', + ' type: serviceaccount', + ' name: secret-sync', + ` namespace: ${namespace}`, + '', + ].join('\n'); + +/** + * Render a local Secret manifest (pre-injection) for a contract service: + * stringData entries carry bare op:// references resolved from the + * contract, injected and base64-encoded by the same pipeline as committed + * templates. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content (with op:// references). + */ +const renderLocalSecret = ({ entries, namespace, service }) => { + // An unbound owner resolves no op:// reference: rendering would embed + // a literal null as the secret value, so fail the generation instead. + const unbound = entries.find((resolved) => !resolved.opRef); + + if (unbound) { + const remedy = + unbound.owner && unbound.owner.name === 'shared' + ? 'set shared.opItem in c.js (the item that stores the shared values)' + : `run \`c op set ${service} local ${unbound.key}\` (it binds the service's item)`; + + throw new Error( + `Cannot render the local Secret for service '${service}': key '${unbound.key}' has no op:// reference (its owner is unbound) - ${remedy}, then re-run.` + ); + } + + return [ + 'apiVersion: v1', + 'kind: Secret', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secret', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'type: Opaque', + 'stringData:', + ] + .concat( + entries.map((resolved) => ` ${resolved.key}: ${resolved.opRef}`) + ) + .concat(['']) + .join('\n'); +}; + +/** + * Generate every secret-shaped manifest declared by the contract for the + * environment's locations: SecretProviderClasses, SecretSyncs and the + * secret-sync ServiceAccount for deployed environments, and local Secrets + * (op references) for the local environment. Generated output is written + * straight to the compiled folder - a conflicting committed source file is + * a hard error (one true source). No contract, no generation: legacy file + * behaviour is untouched. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment being compiled. + * @param {String} [options.namespace] The target namespace (derived when + * omitted). + * @param {String} options.path The manifests path for the environment. + * @param {Array} options.services The environment's location services. + * @returns {Promise} Resolves with the array of generated file names. + */ +const generateSecretManifests = async ({ + config, + env, + namespace, + path = '', + services = [], +}) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + return []; + } + + const targetNamespace = + namespace || + formatProjectPrefix( + contract.organisation, + contract.name, + env, + true, + true + ); + + const sourceFolder = resolvePath(process.cwd(), path); + const destinationFolder = join(sourceFolder, '.compiled'); + const generated = []; + + await ensureDir(destinationFolder); + + const assertNoCommittedFile = async ({ servicePath }) => { + const base = join(sourceFolder, servicePath); + + const conflicts = await Promise.all( + [`${base}.yaml`, `${base}.yaml.tmpl`].map(async (candidate) => { + try { + await access(candidate); + + return candidate; + } catch (e) { + return null; + } + }) + ); + + const conflict = conflicts.find((candidate) => candidate); + + if (conflict) { + throw new Error( + `${conflict.replace( + `${sourceFolder}/`, + '' + )} conflicts with the secrets contract - the contract generates this file (delete the committed file; the contract is the one true source)` + ); + } + }; + + const writeCompiled = async ({ content, mode, servicePath }) => { + await writeFile( + join(destinationFolder, `${servicePath}.yaml`), + content, + { + encoding: 'utf8', + mode, + } + ); + + generated.push(`${servicePath}.yaml`); + }; + + // Group the location entries by shape and name: 'site' -> + // { secret: ..., secretproviderclass: ..., secretsync: ... }. + const shapes = { + secret: {}, + secretproviderclass: {}, + secretsync: {}, + serviceaccount: {}, + }; + + services.forEach((service) => { + const [name, shape] = service.path.split('.'); + + if (!shapes[shape]) { + shapes[shape] = {}; + } + + shapes[shape][name] = service; + }); + + const entriesFor = ({ name }) => { + // 'shared' is reserved for the secrets.shared block, and shared + // secrets are consumed through a service's sharedKeys - there is + // no shared-shaped manifest to generate. + if (name === 'shared') { + throw new Error( + `The '${name}' location is shared-shaped, but shared secrets are consumed through a service's sharedKeys - declare the location on the consuming service instead.` + ); + } + + const service = getService({ contract, name }); + + return serviceKeys({ contract, env, service }); + }; + + const shapesInOrder = [ + 'secretproviderclass', + 'secretsync', + 'serviceaccount', + ]; + + await Promise.all( + shapesInOrder.map(async (shape) => { + const names = Object.keys(shapes[shape]).sort(); + + await Promise.all( + names.map(async (name) => { + if (shape === 'serviceaccount' && name !== 'secretsync') { + return; + } + + const service = shapes[shape][name]; + + await assertNoCommittedFile({ + servicePath: service.path, + }); + + if (shape === 'serviceaccount') { + return writeCompiled({ + content: renderSecretSyncServiceAccount({ + namespace: targetNamespace, + }), + mode: 0o644, + servicePath: service.path, + }); + } + + const entries = entriesFor({ name }); + + if (shape === 'secretproviderclass') { + return writeCompiled({ + content: renderSecretProviderClass({ + entries, + namespace: targetNamespace, + service: name, + }), + mode: 0o644, + servicePath: service.path, + }); + } + + return writeCompiled({ + content: renderSecretSync({ + entries, + namespace: targetNamespace, + service: name, + }), + mode: 0o644, + servicePath: service.path, + }); + }) + ); + }) + ); + + // Local Secrets: generate with op references, then run the exact + // pipeline committed templates follow (inject, base64, 0600). + await Promise.all( + Object.keys(shapes.secret) + .sort() + .map(async (name) => { + const service = shapes.secret[name]; + const entries = entriesFor({ name }); + + await assertNoCommittedFile({ servicePath: service.path }); + + let content = renderLocalSecret({ + entries, + namespace: targetNamespace, + service: name, + }); + + content = await injectSecretReferences(content); + + const destinationFile = join( + destinationFolder, + `${service.path}.yaml` + ); + + await writeFile( + destinationFile, + encodeSecretStringData(content), + { encoding: 'utf8', mode: 0o600 } + ); + + await chmod(destinationFile, 0o600); + + generated.push(`${service.path}.yaml`); + }) + ); + + return generated; +}; + +module.exports = { + generateSecretManifests, + renderLocalSecret, + renderSecretProviderClass, + renderSecretSync, + renderSecretSyncServiceAccount, +}; diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js new file mode 100644 index 0000000..efe4ce2 --- /dev/null +++ b/bin/lib/c-secrets-verify.js @@ -0,0 +1,753 @@ +'use strict'; + +const getPropertyPath = require('get-value'); + +const { formatProjectPrefix } = require('./c-project'); +const { serviceKeys } = require('./c-secrets'); +const { + assertKubectl, + gcfList, + gsmList, + gsmRead, + k8sReadSecret, + opItemValues, + opListItems, + pool, + sha12, + spcList, +} = require('./c-secrets-io'); + +/** + * The Kubernetes environment configuration for an env (context, locations), + * or null when the project declares none. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Object|null} + */ +const kubernetesEnv = ({ config, env }) => + getPropertyPath(config, `kubernetes.environments.${env}`) || null; + +/** + * The namespace an environment's manifests deploy into: a configured + * kubernetes.environments..namespace override wins, otherwise the + * project prefix - the same resolution the kc commands use. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @returns {String} The namespace. + */ +const kubernetesNamespace = ({ config, contract, env }) => + getPropertyPath(config, `kubernetes.environments.${env}.namespace`) || + formatProjectPrefix(contract.organisation, contract.name, env, true, true); + +/** + * The services the env's Kubernetes locations synchronise (those with a + * secretsync location entry). + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Array} Service names with a secretsync location. + */ +const syncServiceNames = ({ config, env }) => { + const kenv = kubernetesEnv({ config, env }); + + if (!kenv) { + return []; + } + + const names = []; + + Object.values(kenv.locations || {}).forEach((entries) => { + (entries || []).forEach((entry) => { + if (entry.type === 'secretsync' && entry.path) { + names.push(entry.path.replace(/\.secretsync$/, '')); + } + }); + }); + + return [...new Set(names)].sort(); +}; + +/** + * The environment encoded in a repo resource name following the naming + * convention ({org}-{repo}-{env}-{name}), or null when the name carries + * no known env. + * @param {Object} options + * @param {Array} options.envs The known environment names. + * @param {String} options.name The resource name. + * @param {String} options.prefix The project prefix ({org}-{repo}-). + * @returns {String|null} The parsed environment. + */ +const parseNameEnv = ({ envs, name, prefix }) => + envs.find((candidate) => name.startsWith(`${prefix}${candidate}-`)) || null; + +/** + * Findings for one deployed function's repo-prefixed secret references, + * against the contract-derived ids per env and the ids that exist in + * GSM. Pure - verifyEnv only prints these. + * + * One finding per repo-prefixed reference, plus at most one advisory per + * function. MISBOUND outranks everything else: an id whose env disagrees + * with the function's name env is cross-env contamination regardless of + * whether the id exists. A function whose name parses to no known env + * (repos without the naming convention) keeps id-only validation behind + * an advisory. + * @param {Object} options + * @param {Object} options.derivedByEnv Contract-derived ids per env (Set). + * @param {Array} options.envs The known environment names. + * @param {Object} options.existingIds GSM ids that exist (Set). + * @param {Object} options.fn The deployed function ({ name, secrets }). + * @param {String} options.repoPrefix The project prefix ({org}-{repo}-). + * @returns {Array} Findings: { advisory, message, ok }. + */ +const functionReferenceFindings = ({ + derivedByEnv, + envs, + existingIds, + fn, + repoPrefix, +}) => { + const nameEnv = parseNameEnv({ + envs, + name: fn.name, + prefix: repoPrefix, + }); + + const findings = []; + + if (!nameEnv) { + findings.push({ + advisory: true, + message: `function ${fn.name}: no environment parsed from the name - validating references by secret id only`, + ok: true, + }); + } + + fn.secrets.forEach((id) => { + if (!id.startsWith(repoPrefix)) { + return; + } + + const refEnv = parseNameEnv({ + envs, + name: id, + prefix: repoPrefix, + }); + + if (nameEnv && refEnv && refEnv !== nameEnv) { + findings.push({ + advisory: false, + message: `MISBOUND function ${fn.name} -> ${id} (name env ${nameEnv}, id env ${refEnv})`, + ok: false, + }); + + return; + } + + const inContract = refEnv && derivedByEnv[refEnv].has(id); + + if (inContract && existingIds.has(id)) { + findings.push({ + advisory: false, + message: `function ${fn.name} -> ${id}`, + ok: true, + }); + + return; + } + + if (inContract) { + findings.push({ + advisory: false, + message: `MISSING function ${fn.name} -> ${id} (contract id, but the GSM secret does not exist)`, + ok: false, + }); + + return; + } + + findings.push({ + advisory: false, + message: `STALE function ${fn.name} -> ${id}${ + refEnv ? '' : ' (unknown env)' + } (not in contract${ + existingIds.has(id) + ? '' + : ', and the GSM secret no longer exists' + })`, + ok: false, + }); + }); + + return findings; +}; + +/** + * Verify one environment: contract-derived GSM secrets exist, their values + * hash-match 1Password, Kubernetes Secrets (for synced services) match + * structure and hashes, and deployed Cloud Functions reference only + * contract-derived secret ids that exist in GSM and belong to the + * function's own environment (where its name encodes one). Values are + * compared by hash only. Data is prefetched in bulk, then every check + * reports as it runs. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @returns {Promise} Resolves with { ok, problems }. + */ +const verifyEnv = ({ config, contract, env }) => { + const problems = []; + + // Report as checks run: the pass takes a while, so progress beats + // buffering everything for the end. + const report = ({ message, ok }) => { + process.stdout.write(`${ok ? 'OK ' : 'PROBLEM '} ${message}\n`); + + if (!ok) { + problems.push(message); + } + }; + + const section = ({ detail, title }) => { + process.stdout.write(`\n== ${title} ==\n${detail}\n\n`); + }; + + const entries = Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)); + + const repoPrefix = `${contract.organisation}-${contract.name}-`; + + // Derived ids per env, so function references (which may belong to any + // env - the env is encoded in the secret id, not the function name) + // can be routed to the right contract set. + const envs = Object.keys( + getPropertyPath(config, 'kubernetes.environments') || {} + ); + + const derivedByEnv = {}; + envs.forEach((candidate) => { + derivedByEnv[candidate] = new Set(); + + entries.forEach((service) => { + serviceKeys({ contract, env: candidate, service }).forEach( + (resolved) => { + derivedByEnv[candidate].add(resolved.gsmId); + } + ); + }); + }); + + // Reverse mapping for readable storage notes: which entry owns an item. + const itemOwners = {}; + + entries.forEach((entry) => { + if (entry.opItem) { + itemOwners[entry.opItem] = entry.name; + } + }); + + // Bulk prefetch, so the checks below run from local data: one item + // list resolves every owner's item id (reads never create), one item + // fetch per distinct item carries the values (phase 1) and labels + // (phase 3), one GSM list covers existence, and one secret fetch per + // synced service carries the cluster values (phase 2). GSM values + // have no batch-access API, so they read with bounded concurrency. + const itemByTitle = new Map( + opListItems().map((item) => [item.title, item.id]) + ); + + const opItemFor = (owner) => + owner.opItem || + itemByTitle.get( + `${contract.organisation}-${contract.name}/${owner.name}` + ) || + null; + + const itemIds = new Set(); + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (item) { + itemIds.add(item); + } + }); + + if (contract.dev) { + itemIds.add(contract.dev.opItem); + } + + const opData = {}; + + itemIds.forEach((item) => { + try { + opData[item] = opItemValues({ item }); + } catch (e) { + opData[item] = { error: e }; + } + }); + + const resolvedKeys = entries.map((service) => ({ + keys: serviceKeys({ contract, env, service }), + service, + })); + + // GSM value hashes per secret id, recorded in phase 1 and compared + // against the Kubernetes values in phase 2. + const gsmShas = {}; + const gsmValues = {}; + + return gsmList({ project: contract.project }) + .then((existing) => { + const existingIds = new Set(existing); + + const toRead = [ + ...new Set( + resolvedKeys + .flatMap(({ keys }) => keys.map((r) => r.gsmId)) + .filter((id) => existingIds.has(id)) + ), + ]; + + return pool({ + items: toRead, + limit: 6, + run: (id) => + gsmRead({ id, project: contract.project }).then((value) => { + gsmValues[id] = value; + }), + }).then(() => existingIds); + }) + .then((existingIds) => { + // 1. GSM existence + op/GSM value hashes. + section({ + detail: 'Every contract key exists in GSM and its value hash-matches 1Password.', + title: '1Password + GSM (contract secrets)', + }); + + resolvedKeys.forEach(({ service, keys }) => + keys.forEach((resolved) => { + if (!existingIds.has(resolved.gsmId)) { + report({ + message: `${service.name}/${resolved.key} gsm ${resolved.gsmId} MISSING`, + ok: false, + }); + + return; + } + + const gsmSha = sha12({ + value: gsmValues[resolved.gsmId], + }); + + gsmShas[resolved.gsmId] = gsmSha; + + const opItem = opItemFor(resolved.owner); + + if (!opItem) { + report({ + message: `${service.name}/${resolved.key} op:UNBOUND (no 1Password item '${contract.organisation}-${contract.name}/${resolved.owner.name}') gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const storedIn = itemOwners[opItem] + ? ` (stored in the ${itemOwners[opItem]} item)` + : ''; + + const data = opData[opItem]; + + if (!data || data.error) { + report({ + message: `${service.name}/${ + resolved.key + } op:READ-ERROR ${ + data && data.error + ? String(data.error.message).slice(0, 60) + : 'item not prefetched' + } gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const value = data.values[`${env}/${resolved.key}`]; + + if (typeof value === 'undefined') { + report({ + message: `${service.name}/${resolved.key} op:READ-ERROR field '${env}/${resolved.key}' not found in the '${resolved.owner.name}' item gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const opSha = sha12({ + value: Buffer.from(value, 'utf8'), + }); + + report({ + message: `${service.name}/${resolved.key} op:${opSha}${storedIn} gsm:${gsmSha}`, + ok: opSha === gsmSha, + }); + }) + ); + + // 2. Kubernetes structure + hashes for synced services. + const kenv = kubernetesEnv({ config, env }); + + if (kenv) { + section({ + detail: 'Synced cluster Secrets carry exactly the contract keys, with values hash-matching GSM.', + title: 'Kubernetes Secrets (synced services)', + }); + + const namespace = kubernetesNamespace({ + config, + contract, + env, + }); + + syncServiceNames({ config, env }).forEach((name) => { + const service = contract.services[name]; + + if (!service) { + report({ + message: + name === 'shared' + ? "k8s shared: shared secrets are consumed through a service's sharedKeys - declare the secretsync on the consuming service instead" + : `k8s ${name}: secretsync location but no contract service`, + ok: false, + }); + + return; + } + + const expected = serviceKeys({ + contract, + env, + service, + }); + + let secret; + + try { + secret = k8sReadSecret({ + context: kenv.context, + namespace, + secret: name, + }); + } catch (e) { + report({ + message: `k8s ${name}: READ-ERROR ${e.message.slice( + 0, + 80 + )}`, + ok: false, + }); + + return; + } + + const liveKeys = [...secret.keys].sort(); + const expectedKeys = expected + .map((resolved) => resolved.key) + .sort(); + + const structureOk = + JSON.stringify(liveKeys) === + JSON.stringify(expectedKeys); + + report({ + message: `k8s ${name}: ${liveKeys.length} keys${ + structureOk + ? '' + : ` (expected ${ + expectedKeys.length + }: ${expectedKeys.join(', ')})` + }`, + ok: structureOk, + }); + + if (!structureOk) { + return; + } + + expected.forEach((resolved) => { + const k8sSha = sha12({ + value: secret.values[resolved.key], + }); + const gsmSha = gsmShas[resolved.gsmId]; + + if (!gsmSha) { + report({ + message: `k8s ${name}/${resolved.key} ${k8sSha} (no GSM hash to compare - the GSM secret was missing above)`, + ok: false, + }); + + return; + } + + report({ + message: `k8s ${name}/${ + resolved.key + } ${k8sSha} gsm:${gsmSha}${ + k8sSha === gsmSha + ? '' + : ' (value drift, or the SecretSync has not caught up yet)' + }`, + ok: k8sSha === gsmSha, + }); + }); + }); + } + + // 3. Stale 1Password fields: fields present in known sections + // (environment sections on service items, consumer sections on + // the dev item) that the contract does not assign anywhere. + section({ + detail: 'Fields in known item sections (per environment on service items, per consumer on the dev item) that the contract assigns nowhere.', + title: '1Password stale fields', + }); + + const expected = new Set(); + const note = (item, sectionName, field) => + `${item}|${sectionName}|${field}`; + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (!item) { + return; + } + + entry.keys.forEach((key) => { + envs.forEach((entryEnv) => { + expected.add(note(item, entryEnv, key)); + }); + }); + }); + + const itemsToScan = new Set(); + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (item) { + itemsToScan.add(item); + } + }); + + const consumerSections = new Set( + contract.dev ? Object.keys(contract.dev.consumers) : [] + ); + + if (contract.dev) { + itemsToScan.add(contract.dev.opItem); + + consumerSections.forEach((consumerName) => { + contract.dev.consumers[consumerName].keys.forEach((key) => { + expected.add( + note(contract.dev.opItem, consumerName, key) + ); + }); + }); + } + + itemsToScan.forEach((item) => { + const data = opData[item]; + + if (!data || data.error) { + return; + } + + const ownerName = itemOwners[item] || 'dev'; + const isDevItem = contract.dev && contract.dev.opItem === item; + + data.fields.forEach(({ field, section: fieldSection }) => { + const knownSection = isDevItem + ? consumerSections.has(fieldSection) + : envs.includes(fieldSection); + + if ( + knownSection && + !expected.has(note(item, fieldSection, field)) + ) { + report({ + message: `op:EXTRA ${ownerName}/${fieldSection}/${field} (in 1Password but not the contract)`, + ok: false, + }); + } + }); + }); + + // 4. Deployed-functions drift: every repo-prefixed secret id a + // function references must be in the contract-derived set for + // the env encoded in the id and reference a GSM secret that + // exists. Where the naming convention carries the env in the + // function name ({org}-{repo}-{env}-{name}), the two envs must + // agree - a mismatch is cross-env contamination (MISBOUND). + // Names that parse to no known env (repos without the + // convention) validate by id only, behind an advisory. Output + // is grouped by function name so same-env functions read + // together. + return gcfList({ + project: contract.project, + region: contract.region, + repoPrefix, + }).then((functions) => { + section({ + detail: 'Secret bindings of each deployed function must match ids the c.js contract computes, reference GSM secrets that exist, and - where the function name encodes an environment - belong to that environment. STALE = an old id the contract no longer computes; MISSING = the referenced GSM secret is gone; MISBOUND = the env in the function name disagrees with the env in the id.', + title: 'Deployed functions (secret references)', + }); + + [...functions] + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((fn) => { + functionReferenceFindings({ + derivedByEnv, + envs, + existingIds, + fn, + repoPrefix, + }).forEach((finding) => { + if (finding.advisory) { + process.stdout.write( + `WARN ${finding.message}\n` + ); + + return; + } + + report(finding); + }); + }); + + return { + ok: problems.length === 0, + problems, + }; + }); + }); +}; + +/** + * Find everything referencing a GSM secret id: contract consumers, the + * environment's SecretProviderClasses and deployed Cloud Functions. + * Function consumers carry the env parsed from their name, or null when + * the name has none. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.secretId The GSM secret id. + * @returns {Promise} Resolves with { contract, env, functions, k8s }. + */ +const findConsumers = ({ config, contract, secretId }) => { + const prefix = `${contract.organisation}-${contract.name}-`; + + if (!secretId.startsWith(prefix)) { + throw new Error( + `'${secretId}' does not carry this project's prefix (${prefix})` + ); + } + + const envs = Object.keys( + getPropertyPath(config, 'kubernetes.environments') || {} + ); + + const env = envs.find((candidate) => + secretId.startsWith(`${prefix}${candidate}-`) + ); + + if (!env) { + throw new Error( + `Could not parse an environment out of '${secretId}' (known envs: ${ + envs.join(', ') || 'none' + })` + ); + } + + const result = { + contract: [], + env, + functions: [], + k8s: [], + }; + + // 1. Contract consumers (services and the shared block deriving this + // id for this env). + Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((service) => { + serviceKeys({ contract, env, service }).forEach((resolved) => { + if (resolved.gsmId === secretId) { + result.contract.push({ + borrowed: resolved.owner.name !== service.name, + key: resolved.key, + owner: resolved.owner.name, + service: service.name, + }); + } + }); + }); + + const kenv = kubernetesEnv({ config, env }); + const namespace = kubernetesNamespace({ config, contract, env }); + + // 2 + 3. Live consumers (k8s SecretProviderClasses, functions). + if (kenv && kenv.context) { + assertKubectl({ context: kenv.context }); + } + + const live = kenv + ? Promise.all([ + spcList({ context: kenv.context, namespace }), + gcfList({ + project: contract.project, + region: contract.region, + repoPrefix: `${contract.organisation}-${contract.name}-`, + }), + ]) + : Promise.resolve([[], []]); + + return live.then(([spcs, functions]) => { + spcs.forEach((spc) => { + if (spc.ids.includes(secretId)) { + result.k8s.push({ name: spc.name, namespace }); + } + }); + + functions.forEach((fn) => { + if (fn.secrets.includes(secretId)) { + result.functions.push({ + env: parseNameEnv({ + envs, + name: fn.name, + prefix, + }), + name: fn.name, + }); + } + }); + + return result; + }); +}; + +module.exports = { + findConsumers, + functionReferenceFindings, + kubernetesEnv, + kubernetesNamespace, + parseNameEnv, + syncServiceNames, + verifyEnv, +}; diff --git a/bin/lib/c-secrets.js b/bin/lib/c-secrets.js new file mode 100644 index 0000000..31aa7e0 --- /dev/null +++ b/bin/lib/c-secrets.js @@ -0,0 +1,753 @@ +'use strict'; + +const VAULT = 'Team'; + +const SERVICE_PROPERTIES = ['keys', 'opItem', 'sharedKeys']; +const CONSUMER_PROPERTIES = ['keys', 'sharedKeys']; + +const isPlainObject = ({ value }) => + Object.prototype.toString.call(value) === '[object Object]'; + +const isNonEmptyString = ({ value }) => + typeof value === 'string' && value.length > 0; + +const assertPlainObject = ({ message, value }) => { + if (!isPlainObject({ value })) { + throw new Error(message); + } + + return value; +}; + +/** + * Lowercase a key and convert underscores to hyphens, matching the GSM + * secret id and SecretProviderClass path conventions. + * @param {Object} options + * @param {String} options.key The environment-variable style key. + * @returns {String} The key, lowercase and hyphenated. + */ +const kebabCase = ({ key }) => key.toLowerCase().replace(/_/g, '-'); + +/** + * Assert an object declares no properties outside an allowed list. Catches + * typos like `key` instead of `keys` at load time. + * @param {Object} options + * @param {Array} options.allowed The allowed property names. + * @param {String} options.message The error prefix naming what is checked. + * @param {Object} options.object The object to check. + * @returns {Void} + */ +const assertKnownProperties = ({ allowed, message, object }) => { + Object.keys(object).forEach((property) => { + if (!allowed.includes(property)) { + throw new Error( + `${message}: unknown property '${property}' (allowed: ${allowed + .map((p) => `'${p}'`) + .join(', ')})` + ); + } + }); +}; + +/** + * Validate an array of key names. + * @param {Object} options + * @param {Boolean} options.allowDotted Whether dotted 'owner.KEY' entries + * are valid. + * @param {String} options.message The error prefix naming what is checked. + * @param {Array} options.value The value to validate. + * @returns {Array} The validated array of key names. + */ +const assertKeyArray = ({ allowDotted, message, value }) => { + if (!value) { + return []; + } + + if (!Array.isArray(value)) { + throw new Error(`${message}: must be an array`); + } + + value.forEach((entry) => { + if (!isNonEmptyString({ value: entry })) { + throw new Error(`${message}: entries must be non-empty strings`); + } + + // Keys become GSM secret ids (via kebabCase) that may only hold + // letters, numbers, underscores and hyphens - anything else would + // corrupt the derived id and its REST path. Dots exist only in + // the dotted 'owner.KEY' sharedKeys form (owners are kebab-case + // service names). + const valid = allowDotted + ? /^[A-Za-z0-9_.-]+$/.test(entry) + : /^[A-Za-z0-9_-]+$/.test(entry); + + if (!valid) { + const hint = entry.includes('.') + ? " - dots are only valid in the dotted 'owner.KEY' sharedKeys form" + : ''; + + throw new Error( + `${message}: entries must contain only letters, numbers, underscores and hyphens${hint}` + ); + } + + // The dotted form is exactly 'owner.KEY': a second dot would + // silently truncate on split (the tail is discarded), turning a + // typo like 'services.site.KEY' into something it never said. + if ( + allowDotted && + entry.includes('.') && + entry.split('.').length !== 2 + ) { + throw new Error( + `${message}: dotted entries must be the 'owner.KEY' form (exactly one dot)` + ); + } + }); + + if (new Set(value).size !== value.length) { + throw new Error(`${message}: duplicate entries`); + } + + return [...value]; +}; + +/** + * Validate the shape of a service or consumer entry (same shape: owned + * keys, borrowed sharedKeys). Consumers have no GSM presence and no opItem + * of their own - their owned keys live in the dev item. + * @param {Object} options + * @param {Object} options.entry The raw service/consumer configuration. + * @param {Boolean} options.hasGsm Whether the entry has a GSM presence. + * @param {String} options.name The service/consumer name. + * @returns {Object} The shape-validated entry. + */ +const validateEntry = ({ entry, hasGsm, name }) => { + assertPlainObject({ + message: `secrets entry '${name}' must be an object`, + value: entry, + }); + + assertKnownProperties({ + allowed: hasGsm ? SERVICE_PROPERTIES : CONSUMER_PROPERTIES, + message: `secrets.${name}`, + object: entry, + }); + + const keys = assertKeyArray({ + message: `secrets.${name}.keys`, + value: entry.keys, + }); + + // opItem is optional: an unbound service resolves (or creates) its + // 1Password item lazily on first use. + const validated = { + keys, + name, + sharedKeys: assertKeyArray({ + allowDotted: true, + message: `secrets.${name}.sharedKeys`, + value: entry.sharedKeys, + }), + }; + + if (hasGsm) { + if (isNonEmptyString({ value: entry.opItem })) { + validated.opItem = entry.opItem; + } + } + + return validated; +}; + +/** + * Validate the secrets.shared block (a sibling of services, not a service): + * keys whose GSM ids carry no service segment, stored in a 1Password item + * of the author's choosing (shared.opItem). + * @param {Object} options + * @param {Object} options.rawShared The raw secrets.shared configuration. + * @returns {Object} The normalised shared entry. + */ +const validateShared = ({ rawShared }) => { + assertPlainObject({ + message: "'secrets.shared' must be an object", + value: rawShared, + }); + + assertKnownProperties({ + allowed: ['keys', 'opItem'], + message: 'secrets.shared', + object: rawShared, + }); + + const shared = { + keys: assertKeyArray({ + message: 'secrets.shared.keys', + value: rawShared.keys, + }), + name: 'shared', + sharedKeys: [], + }; + + if (isNonEmptyString({ value: rawShared.opItem })) { + shared.opItem = rawShared.opItem; + } + + return shared; +}; + +/** + * Resolve a sharedKeys entry to its owning service and key name. A bare + * name must resolve to exactly one owning service; a dotted 'owner.KEY' + * names the owner explicitly for keys owned by more than one service. + * @param {Object} options + * @param {String} options.consumerName The name of the borrowing + * service/consumer, for error messages. + * @param {String} options.entry The sharedKeys entry (bare or dotted). + * @param {Object} options.services The normalised services map. + * @returns {Object} The owning service and the key name. + */ +const resolveSharedKeyEntry = ({ consumerName, entry, services }) => { + if (entry.includes('.')) { + const [dottedOwner, dottedKey] = entry.split('.'); + const owner = services[dottedOwner]; + + if (!owner) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': service '${dottedOwner}' does not exist` + ); + } + + if (!owner.keys.includes(dottedKey)) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': service '${dottedOwner}' does not own '${dottedKey}'` + ); + } + + return { key: dottedKey, owner }; + } + + const owners = Object.values(services).filter((service) => + service.keys.includes(entry) + ); + + if (owners.length === 0) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': no service owns it` + ); + } + + if (owners.length > 1) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': ambiguous (owned by ${owners + .map((owner) => owner.name) + .join(', ')}) - use the dotted 'owner.KEY' form` + ); + } + + return { key: entry, owner: owners[0] }; +}; + +/** + * Resolve the sharedKeys of an entry now that the full services map is + * known: each entry gains its owner, self-borrows and double-declarations + * are rejected. + * @param {Object} options + * @param {Object} options.entry The shape-validated service/consumer entry. + * @param {Object} options.services The normalised services map. + * @returns {Void} + */ +const resolveSharedKeys = ({ entry, services }) => { + entry.sharedKeys = entry.sharedKeys.map((shared) => + resolveSharedKeyEntry({ + consumerName: entry.name, + entry: shared, + services, + }) + ); + + entry.sharedKeys.forEach(({ key, owner }) => { + if (owner.name === entry.name) { + throw new Error( + `secrets.${entry.name}.sharedKeys: '${key}' is already owned by '${entry.name}'` + ); + } + + if (entry.keys.includes(key)) { + throw new Error( + `secrets.${entry.name}: '${key}' is both owned and borrowed` + ); + } + }); +}; + +const OP_ITEM_REFERENCE = /^(services\.[a-z0-9-]+|dev)$/; + +const isOpItemReference = ({ value }) => + typeof value === 'string' && OP_ITEM_REFERENCE.test(value); + +/** + * Resolve opItem references to literal 1Password item ids: a value of + * 'services.' or 'dev' points at another entry's opItem, so every + * item id appears exactly once in the contract. One hop only - a reference + * must point at an entry holding a literal id. + * @param {Object} options + * @param {Object|null} options.dev The normalised dev block. + * @param {Object} options.services The normalised services map. + * @param {Object|null} options.shared The normalised shared block. + * @returns {Void} + */ +const resolveOpItemReferences = ({ dev, services, shared }) => { + const entries = Object.values(services).concat(shared || []); + + if (dev) { + entries.push(dev); + } + + entries.forEach((entry) => { + if (!isOpItemReference({ value: entry.opItem })) { + return; + } + + const reference = entry.opItem; + const target = + reference === 'dev' + ? dev + : services[reference.slice('services.'.length)]; + + if (!target) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' does not exist` + ); + } + + if (target === entry) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' points at itself` + ); + } + + if (!target.opItem || isOpItemReference({ value: target.opItem })) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' cannot resolve: '${target.name}' has no literal opItem` + ); + } + + entry.opItem = target.opItem; + }); +}; + +/** + * Load and validate the secrets contract from a c.js configuration object. + * Returns null when no contract is present, so callers can fall back to + * legacy file behaviour. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @returns {Object|null} The normalised contract. + */ +const getSecretsContract = ({ config = {} }) => { + if (!config.secrets) { + return null; + } + + assertPlainObject({ + message: "'secrets' must be an object", + value: config.secrets, + }); + + assertKnownProperties({ + allowed: ['dev', 'services', 'shared'], + message: 'secrets', + object: config.secrets, + }); + + const project = assertPlainObject({ + message: "'project' configuration is required by the secrets contract", + value: config.project, + }); + + ['gcpProjectId', 'name', 'organisation'].forEach((property) => { + if (!isNonEmptyString({ value: project[property] })) { + throw new Error( + `'project.${property}' is required by the secrets contract` + ); + } + }); + + const gcloud = assertPlainObject({ + message: "'gcloud' configuration is required by the secrets contract", + value: config.gcloud, + }); + + if (!isNonEmptyString({ value: gcloud.region })) { + throw new Error("'gcloud.region' is required by the secrets contract"); + } + + const rawServices = assertPlainObject({ + message: "'secrets.services' must be an object", + value: config.secrets.services, + }); + + const shared = config.secrets.shared + ? validateShared({ rawShared: config.secrets.shared }) + : null; + + // Shape-validate every service first, so bare sharedKeys entries can + // find owners declared later in the map. Owners live in services or in + // the shared block. + const services = {}; + const owners = {}; + + if (shared) { + owners.shared = shared; + } + + Object.keys(rawServices).forEach((name) => { + services[name] = validateEntry({ + entry: rawServices[name], + hasGsm: true, + name, + }); + owners[name] = services[name]; + }); + + // A service may not call itself 'shared'. + if (services.shared) { + throw new Error( + "'shared' is reserved for the secrets.shared block - remove it from secrets.services" + ); + } + + Object.values(services).forEach((service) => { + resolveSharedKeys({ services: owners, entry: service }); + }); + + let dev = null; + + if (config.secrets.dev) { + assertPlainObject({ + message: "'secrets.dev' must be an object", + value: config.secrets.dev, + }); + + assertKnownProperties({ + allowed: ['consumers', 'opItem'], + message: 'secrets.dev', + object: config.secrets.dev, + }); + + if (!isNonEmptyString({ value: config.secrets.dev.opItem })) { + throw new Error("secrets.dev: 'opItem' must be a non-empty string"); + } + + const rawConsumers = assertPlainObject({ + message: "'secrets.dev.consumers' must be an object", + value: config.secrets.dev.consumers, + }); + + const consumers = {}; + Object.keys(rawConsumers).forEach((name) => { + consumers[name] = validateEntry({ + entry: rawConsumers[name], + hasGsm: false, + name, + }); + }); + Object.values(consumers).forEach((consumer) => { + resolveSharedKeys({ services: owners, entry: consumer }); + }); + + dev = { consumers, name: 'dev', opItem: config.secrets.dev.opItem }; + } + + resolveOpItemReferences({ dev, services, shared }); + + return { + dev, + name: project.name, + organisation: project.organisation, + project: project.gcpProjectId, + region: gcloud.region, + services, + shared, + vault: VAULT, + }; +}; + +/** + * Derive the GSM secret id for an owned key: service-segmented, except + * for the shared entry whose ids carry no segment ('shared' is a + * reserved service name, so the name is authoritative). + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment (local, beta, production...). + * @param {String} options.key The environment-variable style key. + * @param {Object} options.owner The owning service or shared entry. + * @returns {String} The GSM secret id. + */ +const gsmId = ({ contract, env, key, owner }) => + owner.name === 'shared' + ? `${contract.organisation}-${contract.name}-${env}-${kebabCase({ + key, + })}` + : `${contract.organisation}-${contract.name}-${env}-${ + owner.name + }-${kebabCase({ key })}`; + +/** + * Derive the global-format resource name for a GSM secret id. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.id The GSM secret id. + * @returns {String} The resource name. + */ +const gsmResourceName = ({ contract, id }) => + `projects/${contract.project}/secrets/${id}/versions/latest`; + +/** + * Derive a 1Password reference. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment section. + * @param {String} options.key The field label. + * @param {String} options.opItem The 1Password item id. + * @returns {String} The op:// reference. + */ +const opRef = ({ contract, env, key, opItem }) => + `op://${contract.vault}/${opItem}/${env}/${key}`; + +/** + * Resolve one key of one service to its full identity: owning service (for + * borrowed keys), GSM id, resource name, SecretProviderClass path and + * 1Password reference. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {String} options.key The key to resolve. + * @param {Object} options.service The consuming service. + * @returns {Object} The resolved key identity. + */ +const resolveServiceKey = ({ contract, env, key, service }) => { + const borrowed = service.sharedKeys.find((shared) => shared.key === key); + const owned = service.keys.includes(key); + + if (!owned && !borrowed) { + throw new Error( + `'${key}' is neither owned nor borrowed by service '${service.name}'` + ); + } + + const owner = owned ? service : borrowed.owner; + const id = gsmId({ contract, env, key, owner }); + + return { + gsmId: id, + key, + opItem: owner.opItem || null, + opRef: owner.opItem + ? opRef({ contract, env, key, opItem: owner.opItem }) + : null, + owner, + path: kebabCase({ key }), + resourceName: gsmResourceName({ contract, id }), + }; +}; + +/** + * Resolve one key of one dev consumer. Dev consumers answer only to env + * local and have no GSM presence: owned keys resolve into the dev item's + * consumer section, borrowed keys into the owning service's local section. + * @param {Object} options + * @param {Object} options.consumer The dev consumer. + * @param {Object} options.contract The normalised contract. + * @param {String} options.key The key to resolve. + * @returns {Object} The resolved key identity. + */ +const resolveConsumerKey = ({ consumer, contract, key }) => { + const borrowed = consumer.sharedKeys.find((shared) => shared.key === key); + + if (consumer.keys.includes(key)) { + return { + key, + opItem: contract.dev.opItem, + opRef: opRef({ + contract, + env: consumer.name, + key, + opItem: contract.dev.opItem, + }), + owner: null, + }; + } + + if (!borrowed) { + throw new Error( + `'${key}' is neither owned nor borrowed by consumer '${consumer.name}'` + ); + } + + return { + key, + opItem: borrowed.owner.opItem || null, + opRef: borrowed.owner.opItem + ? opRef({ + contract, + env: 'local', + key, + opItem: borrowed.owner.opItem, + }) + : null, + owner: borrowed.owner, + }; +}; + +/** + * Resolve every key of a service for an environment (owned and borrowed, + * sorted alphabetically) - the complete key list for manifest generation, + * ls and verify. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {Object} options.service The service. + * @returns {Array} Resolved key identities, sorted by key. + */ +const serviceKeys = ({ contract, env, service }) => + service.keys + .concat(service.sharedKeys.map((shared) => shared.key)) + .sort() + .map((key) => resolveServiceKey({ contract, env, key, service })); + +/** + * Look up a service by name, with a helpful error. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.name The service name. + * @returns {Object} The service. + */ +const getService = ({ contract, name }) => { + const service = contract.services[name]; + + if (!service) { + throw new Error( + `No service '${name}' in the secrets contract (services: ${Object.keys( + contract.services + ) + .sort() + .join(', ')})` + ); + } + + return service; +}; + +/** + * Look up a dev consumer by name, with a helpful error. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.name The consumer name. + * @returns {Object} The consumer. + */ +const getConsumer = ({ contract, name }) => { + if (!contract.dev) { + throw new Error('The secrets contract has no dev consumers'); + } + + const consumer = contract.dev.consumers[name]; + + if (!consumer) { + throw new Error( + `No dev consumer '${name}' in the secrets contract (consumers: ${Object.keys( + contract.dev.consumers + ) + .sort() + .join(', ')})` + ); + } + + return consumer; +}; + +/** + * Resolve a identity against the contract: a contract + * service (any env) or a dev consumer (local only, no GSM presence). + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {String} options.name The service or consumer name. + * @returns {Object} The identity: { kind, service } or { kind, consumer }. + */ +const resolveIdentity = ({ contract, env, name }) => { + const service = contract.services[name]; + + if (service) { + return { kind: 'service', service }; + } + + // The shared block behaves like a service for identity resolution (it + // owns GSM secrets), except its 1Password item is never auto-created. + if (name === 'shared' && contract.shared) { + return { kind: 'service', service: contract.shared }; + } + + const consumer = contract.dev && contract.dev.consumers[name]; + + if (consumer) { + if (env !== 'local') { + throw new Error( + `Dev consumers answer only to env local ('${name}' was given env '${env}')` + ); + } + + return { consumer, kind: 'consumer' }; + } + + throw new Error( + `No service or dev consumer '${name}' in the secrets contract (services: ${Object.keys( + contract.services + ) + .sort() + .join(', ')}${ + contract.dev + ? `; consumers: ${Object.keys(contract.dev.consumers) + .sort() + .join(', ')}` + : '' + })` + ); +}; + +/** + * Validate a positional env argument against the environments the project + * declares in its Kubernetes configuration (when it declares any). + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Void} + */ +const assertKnownEnv = ({ config, env }) => { + const environments = + config.kubernetes && config.kubernetes.environments + ? Object.keys(config.kubernetes.environments) + : []; + + if (environments.length > 0 && !environments.includes(env)) { + throw new Error( + `Unknown env '${env}' (known: ${environments.sort().join(', ')})` + ); + } +}; + +module.exports = { + assertKnownEnv, + getConsumer, + getSecretsContract, + getService, + gsmId, + gsmResourceName, + kebabCase, + opRef, + resolveConsumerKey, + resolveIdentity, + resolveServiceKey, + serviceKeys, +}; diff --git a/bin/lib/c.js b/bin/lib/c.js index 3d6fff9..e1a10bf 100644 --- a/bin/lib/c.js +++ b/bin/lib/c.js @@ -1,6 +1,7 @@ 'use strict'; const chalk = require('chalk'); +const { spawn } = require('child_process'); const { readFile, writeFile } = require('fs'); const { homedir } = require('os'); const { resolve: pathResolve, join, dirname } = require('path'); @@ -177,7 +178,9 @@ const missingCommand = (program) => { const [missing] = program.args; + /* eslint-disable no-console */ console.error(chalk.red(`\nThere is no '${missing}' command`)); + /* eslint-enable no-console */ program.help(); }; @@ -321,6 +324,33 @@ const reportError = (err, program, exit = false) => { } }; +/** + * Spawn a child process that inherits stdio, propagating its exit code: + * a signal death becomes a non-zero exit instead of reporting success, + * and a failure to spawn is reported (and fails) rather than crashing. + * @param {Object} options + * @param {Array} options.args The child arguments. + * @param {String} options.command The command to run. + * @returns {Object} The child process. + */ +const spawnWithExitCode = ({ args, command }) => { + const child = spawn(command, args, { stdio: 'inherit' }); + + child.on('error', (e) => { + reportError( + new Error(`Could not run '${command}': ${e.message}`), + false, + true + ); + }); + + child.on('close', (code) => { + process.exitCode = code === null ? 1 : code; + }); + + return child; +}; + /** * The path to the state file. * @param {String} path The parent devops path. @@ -392,6 +422,7 @@ module.exports = { proxyCommand, proxyCommands, reportError, + spawnWithExitCode, stateFilePath, storeState, throwErr, diff --git a/package-lock.json b/package-lock.json index fca8470..4ac4110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.10", "license": "MIT", "dependencies": { "chalk": "4.1.2", @@ -14,6 +14,7 @@ "commander-latest": "npm:commander@v7.2.0", "debug": "3.1.0", "dotenv": "4.0.0", + "espree": "7.3.1", "execa": "6.1.0", "fs-extra": "6.0.0", "get-value": "3.0.1", @@ -38,6 +39,7 @@ "husky": "8.0.1", "prettier": "2.7.1", "pretty-quick": "3.1.3", + "vitest": "5.0.3", "zx": "8.1.4" } }, @@ -498,9 +500,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -519,9 +518,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -540,9 +536,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -561,9 +554,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -582,9 +572,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -603,9 +590,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -624,9 +608,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -645,9 +626,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -666,9 +644,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -693,9 +668,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -720,9 +692,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -747,9 +716,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -774,9 +740,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -801,9 +764,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -828,9 +788,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -855,9 +812,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -958,6 +912,34 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@next/env": { "version": "16.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", @@ -1017,9 +999,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1038,9 +1017,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1059,9 +1035,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1080,9 +1053,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1167,6 +1137,295 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@rushstack/eslint-patch": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.4.tgz", @@ -1193,6 +1452,31 @@ "license": "0BSD", "peer": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", @@ -1231,14 +1515,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~8.9.0" } }, "node_modules/@typescript-eslint/parser": { @@ -1407,11 +1691,48 @@ "node": ">=10" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1424,7 +1745,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -1602,6 +1922,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -1724,6 +2054,16 @@ "license": "CC-BY-4.0", "peer": true }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1884,7 +2224,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "peer": true, "engines": { "node": ">=8" @@ -1993,6 +2332,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-shim-unscopables": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", @@ -2758,7 +3104,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=4" @@ -2875,7 +3220,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^7.4.0", @@ -2936,6 +3280,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", @@ -2968,6 +3322,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", @@ -3127,6 +3491,22 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -4052,28 +4432,301 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "node_modules/language-tags": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", + "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "~0.3.2" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "~0.3.2" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -4133,6 +4786,16 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4264,9 +4927,9 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4490,6 +5153,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5173,6 +5850,41 @@ "node": "*" } }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, "node_modules/run-async": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", @@ -5467,9 +6179,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "peer": true, @@ -5496,6 +6208,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -5786,6 +6505,74 @@ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -5918,9 +6705,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT", "optional": true @@ -5951,6 +6738,225 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5983,6 +6989,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index f0f1806..f1f6b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.10", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { @@ -11,7 +11,8 @@ "minor-release": "npm version minor && npm publish --access=public && git push --follow-tags", "patch-release": "npm version patch && npm publish --access=public && git push --follow-tags", "prerelease-release": "npm version prerelease && npm publish --access=public && git push --follow-tags", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", + "test:watch": "vitest", "prepare": "husky install" }, "repository": { @@ -33,6 +34,7 @@ "commander-latest": "npm:commander@v7.2.0", "debug": "3.1.0", "dotenv": "4.0.0", + "espree": "7.3.1", "execa": "6.1.0", "fs-extra": "6.0.0", "get-value": "3.0.1", @@ -54,6 +56,7 @@ "husky": "8.0.1", "prettier": "2.7.1", "pretty-quick": "3.1.3", + "vitest": "5.0.3", "zx": "8.1.4" } } diff --git a/test/c-kc-cmd.test.js b/test/c-kc-cmd.test.js new file mode 100644 index 0000000..c79548f --- /dev/null +++ b/test/c-kc-cmd.test.js @@ -0,0 +1,136 @@ +'use strict'; + +// Integration tests for c-kc-cmd: the kubectl invocation must put the +// --context/--namespace flags immediately after kubectl (never after the +// user's --), pass the argv as an array rather than a shell string, and +// propagate the child's exit code. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl for offline tests of c-kc-cmd: logs its argv verbatim. +echo "$@" >> "$KUBECTL_FAKE_LOG" + +if [ -n "$KUBECTL_FAKE_EXIT" ]; then + exit "$KUBECTL_FAKE_EXIT" +fi + +echo 'kubectl-fake-ok' +`; + +const FIXTURE_CJS = `module.exports = { + project: { + organisation: 'idearium', + name: 'demo', + }, + kubernetes: { + environments: { + beta: { + context: 'gke_fake_beta', + namespace: 'custom-ns', + locations: {}, + }, + }, + }, +}; +`; + +// Build a fixture directory holding the fake kubectl, a c.js with a +// kubernetes environment (namespace override included) and the state +// file selecting it. +const makeFixture = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-kc-cmd-')); + + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'c.js'), FIXTURE_CJS); + fs.mkdirSync(path.join(dir, 'devops')); + fs.writeFileSync(path.join(dir, 'devops', 'state.json'), '{"env":"beta"}'); + + return dir; +}; + +const runCommand = ({ cwd, env, extraEnv }) => + new Promise((resolve, reject) => { + const child = spawn( + 'node', + [ + path.join(REPO_ROOT, 'bin', 'c-kc-cmd.js'), + 'exec', + 'deploy/site', + '--', + 'env', + ], + { + cwd, + env: Object.assign({}, process.env, env, extraEnv), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + resolve({ code, stderr, stdout }); + }); + }); + +it('places --context/--namespace before the user command, keeping -- verbatim', async () => { + const dir = makeFixture(); + const log = path.join(dir, 'kubectl.log'); + + try { + const result = await runCommand({ + cwd: dir, + env: { + KUBECTL_FAKE_LOG: log, + PATH: `${dir}:${process.env.PATH}`, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('kubectl-fake-ok'); + expect(fs.readFileSync(log, 'utf8').trim()).toBe( + '--context gke_fake_beta --namespace custom-ns exec deploy/site -- env' + ); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +it('propagates the kubectl exit code', async () => { + const dir = makeFixture(); + const log = path.join(dir, 'kubectl.log'); + + try { + const result = await runCommand({ + cwd: dir, + env: { + KUBECTL_FAKE_LOG: log, + PATH: `${dir}:${process.env.PATH}`, + }, + extraEnv: { KUBECTL_FAKE_EXIT: '3' }, + }); + + expect(result.code).toBe(3); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/test/c-secrets-consumers.test.js b/test/c-secrets-consumers.test.js new file mode 100644 index 0000000..acd761e --- /dev/null +++ b/test/c-secrets-consumers.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Integration tests for c secrets consumers: spawned in a fixture +// project (c.js contract + fake `gcloud`/`kubectl` on PATH), the +// command must print each consuming function with the env parsed from +// its name - or (no env in name) when the name carries none. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of c secrets consumers. +case "$*" in + *"print-access-token"*) + echo 'fake-token' + ;; + *"functions list"*) + echo 'projects/demo-proj/locations/australia-southeast1/functions/idearium-demo-beta-api' + echo 'projects/demo-proj/locations/australia-southeast1/functions/idearium-demo-site' + ;; + *"functions describe"*) + case "$*" in + *beta-api*|*site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl CLI for offline tests of c secrets consumers. +case "$*" in + *"get --raw /version"*) + echo '{"major":"1"}' + ;; + *"secretproviderclass"*) + echo '{"items":[]}' + ;; + *) + echo "fake kubectl: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FIXTURE_CJS = `module.exports = { + gcloud: { + region: 'australia-southeast1', + }, + kubernetes: { + environments: { + beta: { + context: 'fake-context', + namespace: 'demo-beta-ns', + locations: {}, + }, + }, + }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + secrets: { + services: { + api: { keys: ['OTHER_KEY'], sharedKeys: [] }, + }, + shared: { keys: ['API_KEY'] }, + }, +}; +`; + +const run = ({ args }) => + new Promise((resolve) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-consumers-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { + mode: 0o755, + }); + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { + mode: 0o755, + }); + fs.writeFileSync(path.join(dir, 'c.js'), FIXTURE_CJS); + + const child = spawn( + process.execPath, + [path.join(REPO_ROOT, 'bin', 'c-secrets-consumers.js')].concat( + args + ), + { + cwd: dir, + env: Object.assign({}, process.env, { + PATH: `${dir}:${process.env.PATH}`, + }), + } + ); + + let stdout = ''; + + child.stdout.on('data', (data) => { + stdout += data; + }); + + child.on('close', (code) => { + fs.rmSync(dir, { force: true, recursive: true }); + + resolve({ code, stdout }); + }); + }); + +it('prints each consuming function with its name-parsed env', async () => { + const { code, stdout } = await run({ + args: ['idearium-demo-beta-api-key'], + }); + + expect(code).toBe(0); + + expect(stdout).toContain( + ' idearium-demo-beta-api (name env beta)\n' + + ' idearium-demo-site (no env in name)' + ); + + expect(stdout).toContain('REFERENCED by 3 consumer(s) - do not delete.'); +}); diff --git a/test/c-secrets-contract.test.js b/test/c-secrets-contract.test.js new file mode 100644 index 0000000..cc634de --- /dev/null +++ b/test/c-secrets-contract.test.js @@ -0,0 +1,133 @@ +'use strict'; + +// Validation tests for the secrets contract itself (getSecretsContract): +// in particular the key charset - keys become GSM secret ids that may +// only hold letters, numbers, underscores and hyphens, so anything else +// must fail at load with a friendly message. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); + +const base = () => ({ + gcloud: { region: 'australia-southeast1' }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, +}); + +const contractWith = (secrets) => + getSecretsContract({ + config: Object.assign(base(), { secrets }), + }); + +const messageOf = (fn) => { + try { + fn(); + } catch ({ message }) { + return message; + } + + return 'did not throw'; +}; + +it('accepts letters, numbers and underscores in keys', () => { + const contract = contractWith({ + services: { + site: { keys: ['CACHE_URL', 'RAS_API_KEY_2'], sharedKeys: [] }, + }, + shared: { keys: ['IMGIX_TOKEN'] }, + }); + + expect(contract.services.site.keys).toStrictEqual([ + 'CACHE_URL', + 'RAS_API_KEY_2', + ]); +}); + +it('rejects keys with characters outside the GSM id alphabet', () => { + expect( + messageOf(() => + contractWith({ services: { site: { keys: ['FOO#1'] } } }) + ) + ).toContain('letters, numbers, underscores and hyphens'); + + expect( + messageOf(() => + contractWith({ services: { site: { keys: ['FOO$BAR'] } } }) + ) + ).toContain('letters, numbers, underscores and hyphens'); + + expect( + messageOf(() => + contractWith({ + services: { site: { keys: [] } }, + shared: { keys: ['A]B'] }, + }) + ) + ).toContain('letters, numbers, underscores and hyphens'); +}); + +it('rejects dots outside the dotted sharedKeys form', () => { + expect( + messageOf(() => contractWith({ services: { site: { keys: ['A.B'] } } })) + ).toContain('dots are only valid'); + + expect( + messageOf(() => + contractWith({ + services: { + site: { keys: [], sharedKeys: ['owner.FOO#1'] }, + }, + }) + ) + ).toContain('dots are only valid'); +}); + +it('rejects dotted sharedKeys with more than one dot', () => { + // A second dot would silently truncate on split - 'a.b.c' would mean + // owner 'a', key 'b'. + expect( + messageOf(() => + contractWith({ + services: { site: { keys: [], sharedKeys: ['a.b.c'] } }, + }) + ) + ).toContain('exactly one dot'); + + // The likely real-world typo: mixing up the sharedKeys form with the + // opItem reference form. + expect( + messageOf(() => + contractWith({ + services: { + site: { keys: [], sharedKeys: ['services.site.KEY'] }, + }, + }) + ) + ).toContain('exactly one dot'); +}); + +it('accepts the dotted sharedKeys form for valid keys', () => { + const contract = contractWith({ + services: { + 'other-service': { keys: ['IMGIX_TOKEN'], sharedKeys: [] }, + 'site': { keys: [], sharedKeys: ['other-service.IMGIX_TOKEN'] }, + }, + }); + + expect(contract.services.site.sharedKeys[0].key).toBe('IMGIX_TOKEN'); +}); + +it('accepts hyphenated keys', () => { + const contract = contractWith({ + services: { site: { keys: ['FOO-BAR'], sharedKeys: [] } }, + }); + + expect(contract.services.site.keys).toStrictEqual(['FOO-BAR']); +}); diff --git a/test/c-secrets-findconsumers.test.js b/test/c-secrets-findconsumers.test.js new file mode 100644 index 0000000..1b9eeba --- /dev/null +++ b/test/c-secrets-findconsumers.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Integration tests for findConsumers against fake `gcloud` and +// `kubectl` binaries injected via PATH: consuming functions must carry +// the env parsed from their name (or null when the name has none), and +// the secret's env, contract consumers and SecretProviderClasses +// resolve as before. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); +const { findConsumers } = require('../bin/lib/c-secrets-verify.js'); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of findConsumers. +FUNCTIONS="idearium-demo-beta-api idearium-demo-site" + +case "$*" in + *"functions list"*) + prefix=$(printf '%s\\n' "$@" | sed -n 's/.*name~functions\\///p') + for fn in $FUNCTIONS; do + case "$fn" in + "$prefix"*) echo "projects/demo-proj/locations/australia-southeast1/functions/$fn" ;; + esac + done + ;; + *"functions describe"*) + case "$*" in + *beta-api*|*site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl CLI for offline tests of findConsumers: answers the +# context reachability probe and the SecretProviderClass listing. +case "$*" in + *"get --raw /version"*) + echo '{"major":"1"}' + ;; + *"secretproviderclass"*) + echo '{"items":[]}' + ;; + *) + echo "fake kubectl: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let previousPath; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-findconsumers-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { + mode: 0o755, + }); + + previousPath = process.env.PATH; + process.env.PATH = `${dir}:${process.env.PATH}`; +}); + +afterAll(() => { + process.env.PATH = previousPath; + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +it('annotates consuming functions with the env parsed from their name', async () => { + const config = { + kubernetes: { + environments: { + beta: { + context: 'fake-context', + namespace: 'demo-beta-ns', + locations: {}, + }, + }, + }, + }; + + const contract = getSecretsContract({ + config: { + gcloud: { region: 'australia-southeast1' }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + secrets: { + services: { + api: { keys: ['OTHER_KEY'], sharedKeys: [] }, + }, + shared: { keys: ['API_KEY'] }, + }, + }, + }); + + const result = await findConsumers({ + config, + contract, + secretId: 'idearium-demo-beta-api-key', + }); + + expect(result.env).toBe('beta'); + + expect(result.contract).toStrictEqual([ + { + borrowed: false, + key: 'API_KEY', + owner: 'shared', + service: 'shared', + }, + ]); + + expect(result.k8s).toStrictEqual([]); + + expect(result.functions).toStrictEqual([ + { env: 'beta', name: 'idearium-demo-beta-api' }, + { env: null, name: 'idearium-demo-site' }, + ]); +}); diff --git a/test/c-secrets-io-gcflist.test.js b/test/c-secrets-io-gcflist.test.js new file mode 100644 index 0000000..a3597ce --- /dev/null +++ b/test/c-secrets-io-gcflist.test.js @@ -0,0 +1,97 @@ +'use strict'; + +// Tests for gcfList against a fake `gcloud` binary injected via PATH: +// the async, bounded-concurrency implementation must list the project's +// functions and parse each one's secret bindings, resolving in list +// order. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { gcfList } = require('../bin/lib/c-secrets-io.js'); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of gcfList. Honors the +# name~functions/ filter so empty matches can be exercised. +FUNCTIONS="idearium-demo-beta-api idearium-demo-site" + +case "$*" in + *"functions list"*) + prefix=$(printf '%s\\n' "$@" | sed -n 's/.*name~functions\\///p') + for fn in $FUNCTIONS; do + case "$fn" in + "$prefix"*) echo "projects/demo-proj/locations/australia-southeast1/functions/$fn" ;; + esac + done + ;; + *"functions describe"*) + case "$*" in + *api*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"},{"secret":"idearium-demo-beta-shared-imgix"}]}}' + ;; + *site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-site-cache-url"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let previousPath; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-gcloud-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { mode: 0o755 }); + + previousPath = process.env.PATH; + process.env.PATH = `${dir}:${process.env.PATH}`; +}); + +afterAll(() => { + process.env.PATH = previousPath; + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +it('lists functions with their secret bindings, in list order', async () => { + const functions = await gcfList({ + project: 'demo-proj', + region: 'australia-southeast1', + repoPrefix: 'idearium-demo-', + }); + + expect(functions).toStrictEqual([ + { + name: 'idearium-demo-beta-api', + secrets: [ + 'idearium-demo-beta-api-key', + 'idearium-demo-beta-shared-imgix', + ], + }, + { + name: 'idearium-demo-site', + secrets: ['idearium-demo-beta-site-cache-url'], + }, + ]); +}); + +it('resolves with an empty list when no functions match', async () => { + const functions = await gcfList({ + project: 'demo-proj', + region: 'australia-southeast1', + repoPrefix: 'idearium-other-', + }); + + expect(functions).toStrictEqual([]); +}); diff --git a/test/c-secrets-io-parse.test.js b/test/c-secrets-io-parse.test.js new file mode 100644 index 0000000..5bee1f1 --- /dev/null +++ b/test/c-secrets-io-parse.test.js @@ -0,0 +1,70 @@ +'use strict'; + +// Unit tests for parseGsmResponse: the parsed body for a success, an +// empty object for an empty body, and friendly errors for the failure +// and unparseable-success cases - gsmRequest's response handler relies +// on it to reject the promise instead of crashing inside an event +// handler. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { parseGsmResponse } = require('../bin/lib/c-secrets-io.js'); + +it('returns the parsed body for a successful response', () => { + expect( + parseGsmResponse({ + method: 'GET', + path: '/secrets', + raw: '{"secrets":[{"name":"projects/p/secrets/a"}]}', + statusCode: 200, + }) + ).toStrictEqual({ + secrets: [{ name: 'projects/p/secrets/a' }], + }); +}); + +it('returns an empty object for an empty body', () => { + expect( + parseGsmResponse({ + method: 'DELETE', + path: '/secrets/x', + raw: '', + statusCode: 200, + }) + ).toStrictEqual({}); +}); + +it('throws the friendly failure error for non-2xx responses', () => { + expect(() => + parseGsmResponse({ + method: 'GET', + path: '/secrets/x', + raw: 'not found', + statusCode: 404, + }) + ).toThrow('GSM GET /secrets/x failed (404)'); +}); + +it('throws a friendly error for an unparseable 2xx body', () => { + let message = 'did not throw'; + + try { + parseGsmResponse({ + method: 'GET', + path: '/secrets', + raw: '{"trunc', + statusCode: 200, + }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).toContain('unparseable response'); + expect(message).toContain('{"trunc'); + // A raw SyntaxError never escapes - that is the crash-class bug this + // guards against. + expect(message).not.toContain('Unexpected token'); +}); diff --git a/test/c-secrets-io-resolve.test.js b/test/c-secrets-io-resolve.test.js new file mode 100644 index 0000000..8c9118a --- /dev/null +++ b/test/c-secrets-io-resolve.test.js @@ -0,0 +1,274 @@ +'use strict'; + +// Behavioral tests for the 1Password item resolvers of c-secrets-io: +// resolveOwnerItem must never create items (read paths), while +// resolveOrCreateOwnerItem creates service items (write paths). The +// shared block never creates on either path. +// +// Runs against a fake `op` binary injected via PATH, so no real 1Password +// session is needed; every invocation is logged for create/no-create +// assertions. +// +// Run with: npm test + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const io = require('../bin/lib/c-secrets-io.js'); + +const FAKE_OP = `#!/bin/sh +# Fake 1Password CLI for offline behavioral tests of c-secrets-io. +LOG="$OP_FAKE_LOG" +echo "$@" >> "$LOG" + +case "$*" in + *"whoami"*) + echo '{}' + ;; + *"item list"*) + echo '[{"id":"siteitemid","title":"idearium-demo/site","vault":{"id":"Team"}}]' + ;; + *"item create"*) + echo '{"id":"created-item-id"}' + ;; + *"boom"*) + echo 'boom said no' >&2 + exit 1 + ;; + *"expired"*) + echo 'not currently signed in' >&2 + exit 1 + ;; + *) + echo "fake op: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let hadSession; +let log; +let previousFakeLog; +let previousPath; +let previousSession; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-op-')); + log = path.join(dir, 'op.log'); + + fs.writeFileSync(path.join(dir, 'op'), FAKE_OP, { mode: 0o755 }); + + previousPath = process.env.PATH; + hadSession = 'OP_SESSION_idearium' in process.env; + previousSession = process.env.OP_SESSION_idearium; + previousFakeLog = process.env.OP_FAKE_LOG; + + process.env.PATH = `${dir}:${process.env.PATH}`; + process.env.OP_SESSION_idearium = 'faketoken'; + process.env.OP_FAKE_LOG = log; +}); + +afterAll(() => { + process.env.PATH = previousPath; + process.env.OP_FAKE_LOG = previousFakeLog; + + if (hadSession) { + process.env.OP_SESSION_idearium = previousSession; + } else { + delete process.env.OP_SESSION_idearium; + } + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +const freshLog = () => fs.writeFileSync(log, ''); +const logText = () => fs.readFileSync(log, 'utf8'); +const createdItems = () => logText().includes('item create'); + +const owners = { + bound: { keys: [], name: 'site', opItem: 'bound123', sharedKeys: [] }, + shared: { keys: [], name: 'shared', sharedKeys: [] }, + unboundApi: { keys: [], name: 'api', sharedKeys: [] }, + unboundSite: { keys: [], name: 'site', sharedKeys: [] }, +}; + +const title = (owner) => `idearium-demo/${owner.name}`; + +const expectThrowsWith = (fn, substring) => { + try { + fn(); + } catch (e) { + return expect(e.message).toContain(substring); + } + + throw new Error('expected the call to throw, but it did not'); +}; + +it('returns a bound opItem without invoking op, on either path', () => { + freshLog(); + + expect( + io.resolveOwnerItem({ owner: owners.bound, title: title(owners.bound) }) + ).toStrictEqual({ created: false, id: 'bound123', persisted: false }); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.bound, + title: title(owners.bound), + }) + ).toStrictEqual({ created: false, id: 'bound123', persisted: false }); + + expect(logText(), 'no op calls for a bound entry').toBe(''); +}); + +it('resolves an unbound entry by title without creating, on either path', () => { + freshLog(); + + expect( + io.resolveOwnerItem({ + owner: owners.unboundSite, + title: title(owners.unboundSite), + }) + ).toStrictEqual({ created: false, id: 'siteitemid', persisted: true }); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.unboundSite, + title: title(owners.unboundSite), + }) + ).toStrictEqual({ created: false, id: 'siteitemid', persisted: true }); + + expect(createdItems(), 'an existing item is bound, never duplicated').toBe( + false + ); +}); + +it('never creates items on read paths (unbound and missing)', () => { + freshLog(); + + expectThrowsWith( + () => + io.resolveOwnerItem({ + owner: owners.unboundApi, + title: title(owners.unboundApi), + }), + 'read commands never create' + ); + + expect(createdItems()).toBe(false); +}); + +it('never creates the shared block\u2019s item, on either path', () => { + freshLog(); + + expectThrowsWith( + () => + io.resolveOwnerItem({ + owner: owners.shared, + title: title(owners.shared), + }), + 'set shared.opItem' + ); + + expectThrowsWith( + () => + io.resolveOrCreateOwnerItem({ + owner: owners.shared, + title: title(owners.shared), + }), + 'set shared.opItem' + ); + + expect(createdItems()).toBe(false); +}); + +it('creates a missing service item on write paths, with the right title', () => { + freshLog(); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.unboundApi, + title: title(owners.unboundApi), + }) + ).toStrictEqual({ created: true, id: 'created-item-id', persisted: true }); + + expect(logText()).toContain('item create'); + expect(logText()).toContain('--title=idearium-demo/api'); +}); + +it('creates an item for a brand-new service (null owner)', () => { + freshLog(); + + expect( + io.resolveOrCreateOwnerItem({ + owner: null, + title: 'idearium-demo/newsvc', + }) + ).toStrictEqual({ created: true, id: 'created-item-id', persisted: true }); + + expect(createdItems()).toBe(true); +}); + +it('sanitizes op failures so the session token never leaks', () => { + let message = 'did not throw'; + + try { + io.runOp({ args: ['boom'] }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).toContain('boom'); + expect(message).toContain('boom said no'); + expect(message).toContain('exit 1'); + expect(message).not.toContain('faketoken'); + expect(message).not.toContain('--session'); +}); + +it('scrubs the session token from op stderr before failing', () => { + // The fake's catch-all echoes its full invocation (token included) + // to stderr - the built error must scrub it. + let message = 'did not throw'; + + try { + io.runOp({ args: ['definitely-not-a-command'] }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).not.toContain('faketoken'); + expect(message).toContain('***'); +}); + +it('maps session-expiry failures to the friendly message', () => { + expectThrowsWith( + () => io.runOp({ args: ['expired'] }), + 'session has expired' + ); +}); + +it('reports a missing op cli instead of failing with undefined', () => { + // ENOENT leaves e.stderr undefined; that must not become the literal + // string 'undefined' (a truthy detail that would keep the + // missing-cli hint from ever firing). PATH points somewhere empty + // rather than being cleared: an empty PATH makes execvp fall back to + // the working directory, which on CI contains an unzipped op/ dir + // (EACCES, not ENOENT). + const pathWas = process.env.PATH; + process.env.PATH = '/nonexistent-bin-for-op-cli-tests'; + + try { + expectThrowsWith( + () => io.runOp({ args: ['whoami'] }), + 'the op cli is not installed' + ); + } finally { + process.env.PATH = pathWas; + } +}); diff --git a/test/c-secrets-io-stdin.test.js b/test/c-secrets-io-stdin.test.js new file mode 100644 index 0000000..8978298 --- /dev/null +++ b/test/c-secrets-io-stdin.test.js @@ -0,0 +1,148 @@ +'use strict'; + +// Tests for the op stdin bridge: Node's pipe stdio hands children AF_UNIX +// sockets, and op only detects piped input on real pipe(2) FIFOs - so +// stdin-bearing op calls (runOp with input, and `c op cmd` when its stdin +// is piped) must route through a shell pipe (cat | op). These tests use a +// fake op that records whether its stdin was a FIFO and what it received. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { fileURLToPath } = require('node:url'); + +const io = require('../bin/lib/c-secrets-io.js'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_OP = `#!/bin/sh +# Fake 1Password CLI for stdin-bridge tests. Records whether stdin was a +# real FIFO (the property op's own detection depends on) and its contents. +LOG="$OP_FAKE_LOG" +echo "$@" >> "$LOG" + +case "$*" in + *"whoami"*) + echo '{}' + ;; + *"stdincheck"*) + if [ -p /dev/stdin ]; then + echo "STDIN-FIFO" >> "$LOG" + else + echo "STDIN-NOTFIFO" >> "$LOG" + fi + cat >> "$LOG" + echo "" >> "$LOG" + echo "stdin-ok" + ;; + *) + echo "fake op: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let log; + +const ENV_KEYS = ['OP_FAKE_LOG', 'OP_SESSION_idearium', 'PATH']; + +let savedEnv; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-op-stdin-')); + log = path.join(dir, 'op.log'); + + fs.writeFileSync(path.join(dir, 'op'), FAKE_OP, { mode: 0o755 }); + + savedEnv = {}; + + ENV_KEYS.forEach((key) => { + savedEnv[key] = { + present: key in process.env, + value: process.env[key], + }; + }); + + process.env.PATH = `${dir}:${process.env.PATH}`; + process.env.OP_SESSION_idearium = 'faketoken'; + process.env.OP_FAKE_LOG = log; +}); + +afterAll(() => { + ENV_KEYS.forEach((key) => { + if (savedEnv[key].present) { + process.env[key] = savedEnv[key].value; + } else { + delete process.env[key]; + } + }); + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +const freshLog = () => fs.writeFileSync(log, ''); +const logText = () => fs.readFileSync(log, 'utf8'); + +// Spawn a command, write input to its stdin, and resolve with its exit +// code and captured output. +const spawnCaptured = ({ args, command, input }) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + resolve({ code, stderr, stdout }); + }); + + child.stdin.end(input); + }); + +it('runOp delivers piped input to op over a real FIFO', () => { + freshLog(); + + const output = io.runOp({ args: ['stdincheck'], input: 'payload-data' }); + + expect(output).toContain('stdin-ok'); + + const text = logText(); + + expect(text).toContain('stdincheck'); + // The regression property: op's stdin must be a pipe(2) FIFO, not a + // Node socketpair - op cannot detect input on the latter. + expect(text).toContain('STDIN-FIFO'); + expect(text).not.toContain('STDIN-NOTFIFO'); + expect(text).toContain('payload-data'); +}); + +it('c op cmd bridges piped stdin into op over a real FIFO', async () => { + freshLog(); + + const result = await spawnCaptured({ + args: [path.join(REPO_ROOT, 'bin', 'c-op-cmd.js'), 'stdincheck'], + command: 'node', + input: 'bridge-payload', + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('stdin-ok'); + + const text = logText(); + + expect(text).toContain('STDIN-FIFO'); + expect(text).toContain('bridge-payload'); +}); diff --git a/test/c-secrets-manifests.test.js b/test/c-secrets-manifests.test.js new file mode 100644 index 0000000..ec6595b --- /dev/null +++ b/test/c-secrets-manifests.test.js @@ -0,0 +1,119 @@ +'use strict'; + +// Tests for generateSecretManifests: deployed-environment shapes render +// contract-derived SecretProviderClasses and SecretSyncs into the +// compiled folder, and shared-shaped locations are rejected with +// guidance (shared secrets are consumed through a service's sharedKeys). + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + generateSecretManifests, +} = require('../bin/lib/c-secrets-manifests.js'); + +const config = () => ({ + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + gcloud: { + region: 'australia-southeast1', + }, + secrets: { + services: { + site: { + opItem: 'siteitem', + keys: ['CACHE_URL'], + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + opItem: 'services.site', + keys: ['IMGIX_TOKEN'], + }, + }, +}); + +// Run an async body inside a temp directory (the compiled folder is +// created relative to the current working directory); the directory +// lives until the body settles. +const inTempDir = async (body) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-secrets-manifests-')); + const previousCwd = process.cwd(); + + process.chdir(dir); + + try { + return await body(path.join(dir, '.compiled')); + } finally { + process.chdir(previousCwd); + fs.rmSync(dir, { force: true, recursive: true }); + } +}; + +it('generates SecretProviderClass and SecretSync manifests for a service', () => + inTempDir(async (compiled) => { + const generated = await generateSecretManifests({ + config: config(), + env: 'beta', + namespace: 'idearium-demo-beta', + path: '', + services: [ + { + path: 'site.secretproviderclass', + type: 'secretproviderclass', + }, + { path: 'site.secretsync', type: 'secretsync' }, + ], + }); + + expect(generated.sort()).toStrictEqual([ + 'site.secretproviderclass.yaml', + 'site.secretsync.yaml', + ]); + + const spc = fs.readFileSync( + path.join(compiled, 'site.secretproviderclass.yaml'), + 'utf8' + ); + const sync = fs.readFileSync( + path.join(compiled, 'site.secretsync.yaml'), + 'utf8' + ); + + // Owned and borrowed keys both appear, in sorted order. + expect(spc).toContain( + "- resourceName: 'projects/demo-proj/secrets/idearium-demo-beta-site-cache-url/versions/latest'" + ); + expect(spc).toContain("path: 'cache-url'"); + expect(spc).toContain( + "- resourceName: 'projects/demo-proj/secrets/idearium-demo-beta-imgix-token/versions/latest'" + ); + expect(sync).toContain("targetKey: 'IMGIX_TOKEN'"); + expect(sync).toContain('secretProviderClassName: site'); + })); + +it('rejects shared-shaped locations with guidance', () => + inTempDir(async () => { + await expect( + generateSecretManifests({ + config: config(), + env: 'beta', + namespace: 'idearium-demo-beta', + path: '', + services: [ + { + path: 'shared.secretproviderclass', + type: 'secretproviderclass', + }, + ], + }) + ).rejects.toThrow(/sharedKeys/); + })); diff --git a/test/c-secrets-verify.test.js b/test/c-secrets-verify.test.js new file mode 100644 index 0000000..bcd057f --- /dev/null +++ b/test/c-secrets-verify.test.js @@ -0,0 +1,201 @@ +'use strict'; + +// Behavioral tests for the deployed-function secret-reference findings: +// where the naming convention encodes an env in a function name +// ({org}-{repo}-{env}-{name}), the env in every repo-prefixed secret id +// the function references must agree (MISBOUND otherwise); names without +// a parseable env keep id-only validation behind an advisory; and +// contract ids must exist in GSM (MISSING) or be reported stale (STALE). +// +// Run with: npm test + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { + functionReferenceFindings, + parseNameEnv, +} = require('../bin/lib/c-secrets-verify.js'); + +const ENVS = ['beta', 'local', 'production']; +const PREFIX = 'idearium-demo-'; + +const BETA_ID = 'idearium-demo-beta-site-cache-url'; +const PRODUCTION_ID = 'idearium-demo-production-site-cache-url'; + +const derivedByEnv = { + beta: new Set([BETA_ID]), + local: new Set(['idearium-demo-local-site-cache-url']), + production: new Set([PRODUCTION_ID]), +}; + +const findingsOf = ({ existing = [], fn }) => + functionReferenceFindings({ + derivedByEnv, + envs: ENVS, + existingIds: new Set(existing), + fn, + repoPrefix: PREFIX, + }); + +it('parses the env out of a convention name', () => { + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-beta-images', + prefix: PREFIX, + }) + ).toBe('beta'); + + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-production-prismic-published', + prefix: PREFIX, + }) + ).toBe('production'); +}); + +it('does not parse lookalike or legacy names', () => { + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-betamax-images', + prefix: PREFIX, + }) + ).toBe(null); + + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-prismic-published', + prefix: PREFIX, + }) + ).toBe(null); +}); + +it('reports a contract id of the function env that exists', () => { + const findings = findingsOf({ + existing: [BETA_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `function idearium-demo-beta-images -> ${BETA_ID}`, + ok: true, + }, + ]); +}); + +it('reports a contract id of the function env that is gone from GSM', () => { + const findings = findingsOf({ + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `MISSING function idearium-demo-beta-images -> ${BETA_ID} (contract id, but the GSM secret does not exist)`, + ok: false, + }, + ]); +}); + +it('reports a non-contract id as stale, noting an unparsed env', () => { + const findings = findingsOf({ + existing: ['idearium-demo-functions-imgix-token'], + fn: { + name: 'idearium-demo-beta-images', + secrets: ['idearium-demo-functions-imgix-token'], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: + 'STALE function idearium-demo-beta-images -> ' + + 'idearium-demo-functions-imgix-token (unknown env) ' + + '(not in contract)', + ok: false, + }, + ]); +}); + +it('reports a cross-env reference as misbound', () => { + const findings = findingsOf({ + existing: [PRODUCTION_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [PRODUCTION_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `MISBOUND function idearium-demo-beta-images -> ${PRODUCTION_ID} (name env beta, id env production)`, + ok: false, + }, + ]); +}); + +it('misbinding outranks existence checks', () => { + const findings = findingsOf({ + fn: { + name: 'idearium-demo-beta-images', + secrets: ['idearium-demo-production-gone-secret'], + }, + }); + + expect(findings.length).toBe(1); + expect(findings[0].ok).toBe(false); + expect(findings[0].message).toContain('MISBOUND'); +}); + +it('keeps id-only validation behind an advisory for names without an env', () => { + const findings = findingsOf({ + existing: [BETA_ID], + fn: { + name: 'idearium-demo-images', + secrets: [BETA_ID, 'an-unprefixed-id'], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: true, + message: + 'function idearium-demo-images: no environment parsed from ' + + 'the name - validating references by secret id only', + ok: true, + }, + { + advisory: false, + message: `function idearium-demo-images -> ${BETA_ID}`, + ok: true, + }, + ]); +}); + +it('reports no problems for a clean same-env function', () => { + const findings = findingsOf({ + existing: [BETA_ID, PRODUCTION_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings.filter(({ ok }) => !ok)).toStrictEqual([]); +}); diff --git a/test/cjs-editors.test.js b/test/cjs-editors.test.js new file mode 100644 index 0000000..149c7cd --- /dev/null +++ b/test/cjs-editors.test.js @@ -0,0 +1,602 @@ +'use strict'; + +// Behavior tests for the c.js contract editors (addKeyToEntryInCjs, +// setOpItemInCjs, registerServiceInCjs) over adversarial c.js fixtures. +// +// Every fixture is a valid c.js module; every edit must leave a file that +// still parses, still validates as a contract, preserves the user's +// comments, array style and line endings, and is idempotent. +// +// Run with: npm test + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const prettier = require('prettier'); + +const io = require('../bin/lib/c-secrets-io.js'); +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const PRETTIER_OPTIONS = Object.assign( + { endOfLine: 'lf', parser: 'babel' }, + JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, '.prettierrc.json'), 'utf8') + ) +); + +const isPrettierClean = (text) => + prettier.format(text, PRETTIER_OPTIONS) === text; + +const isCrlf = (text) => { + const lines = text.split('\n'); + + return lines.slice(0, -1).every((line) => line.endsWith('\r')); +}; + +// The c.js preamble every fixture shares (a docker decoy site, a valid +// project and gcloud block, and a secrets contract). +const preamble = () => `module.exports = { + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + gcloud: { + projectId: 'demo-proj', + region: 'australia-southeast1', + }, + docker: { + locations: { + site: { buildArgs: { TOKEN: 'nope' } }, + }, + }, + secrets: { +`; + +const closing = () => ` }, +}; +`; + +const loadCjs = () => { + const file = path.resolve(process.cwd(), 'c.js'); + + delete require.cache[require.resolve(file)]; + + return require(file); +}; + +const readCjs = () => + fs.readFileSync(path.resolve(process.cwd(), 'c.js'), 'utf8'); + +const writeCjs = (text) => + fs.writeFileSync(path.resolve(process.cwd(), 'c.js'), text); + +// Run a test body inside a temp directory holding the fixture c.js. +const withFixture = (name, cjsText, body) => + it(name, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cjs-editors-')); + const previousCwd = process.cwd(); + + process.chdir(dir); + writeCjs(cjsText); + + try { + body(); + } finally { + process.chdir(previousCwd); + fs.rmSync(dir, { force: true, recursive: true }); + } + }); + +// The key-array styles addKeyToEntryInCjs must survive. Each style pins +// the exact block the keys property must render as after adding one key, +// so formatting (indentation, separators, trailing commas) is asserted, +// not just parseability. +const KEY_STYLES = [ + { + // The style `c secrets init` drafts (and the style that shipped + // broken): multi-line with trailing commas. + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + 'IMGIX_TOKEN', + ],`, + expectedSiteBlock: ` keys: [ + 'CACHE_URL', + 'RAS_API_KEY', + ],`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'multi-line keys with trailing commas (init style)', + shared: ` shared: { + keys: [ + 'IMGIX_TOKEN', + ], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + 'CACHE_URL', + ], + }, +`, + }, + { + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + 'IMGIX_TOKEN' + ]`, + expectedSiteBlock: ` keys: [ + 'CACHE_URL', + 'RAS_API_KEY' + ]`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'multi-line keys without trailing commas', + shared: ` shared: { + keys: [ + 'IMGIX_TOKEN' + ] + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + 'CACHE_URL' + ] + }, +`, + }, + { + canonical: true, + expectedSharedBlock: ` keys: ['API_TOKEN', 'IMGIX_TOKEN'],`, + expectedSiteBlock: ` keys: ['CACHE_URL', 'RAS_API_KEY'],`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'single-line keys', + shared: ` shared: { + keys: ['IMGIX_TOKEN'], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, +`, + }, + { + canonical: true, + expectedSharedBlock: ` keys: ['API_TOKEN'],`, + expectedSiteBlock: ` keys: ['RAS_API_KEY'],`, + existingSharedKeys: [], + existingSiteKeys: [], + label: 'empty single-line keys', + shared: ` shared: { + keys: [], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [], + }, +`, + }, + { + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + ],`, + expectedSiteBlock: ` keys: [ + 'RAS_API_KEY', + ],`, + existingSharedKeys: [], + existingSiteKeys: [], + label: 'empty multi-line keys', + shared: ` shared: { + keys: [ + ], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + ], + }, +`, + }, +]; + +const fixtureFor = (style) => + `${preamble()} services: { +${style.site} }, +${style.shared}${closing()}`; + +KEY_STYLES.forEach((style) => { + const fixture = fixtureFor(style); + + withFixture( + `addKeyToEntryInCjs adds and sorts a service key: ${style.label}`, + fixture, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + + expect(config.secrets.services.site.keys).toStrictEqual( + style.existingSiteKeys.concat('RAS_API_KEY') + ); + expect(config.docker.locations.site.buildArgs.TOKEN).toBe('nope'); + + const text = readCjs(); + + expect( + text, + 'the keys block must render in the entry\u2019s own style' + ).toContain(style.expectedSiteBlock); + + if (style.canonical) { + expect(isPrettierClean(text)).toBe(true); + } + + // Idempotent: a second identical run changes nothing. + const afterFirst = readCjs(); + + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + expect(readCjs()).toBe(afterFirst); + } + ); + + withFixture( + `addKeyToEntryInCjs adds a shared key: ${style.label}`, + fixture, + () => { + io.addKeyToEntryInCjs({ key: 'API_TOKEN', name: 'shared' }); + + const config = loadCjs(); + + expect(config.secrets.shared.keys).toStrictEqual( + style.existingSharedKeys.concat('API_TOKEN').sort() + ); + + expect( + readCjs(), + 'the shared keys block must render in the entry\u2019s own style' + ).toContain(style.expectedSharedBlock); + } + ); +}); + +withFixture( + 'addKeyToEntryInCjs preserves comments inside the contract', + `${preamble()} services: { + site: { + // the site's owned keys + keys: [ + // cache connection details + 'CACHE_URL', + ], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + const text = readCjs(); + + expect(config.secrets.services.site.keys).toStrictEqual([ + 'CACHE_URL', + 'RAS_API_KEY', + ]); + expect(text).toMatch(/\/\/ the site's owned keys/); + expect(text).toMatch(/\/\/ cache connection details/); + } +); + +withFixture( + 'addKeyToEntryInCjs creates a missing keys array', + `${preamble()} services: { + 'prismic-published-workflow': { + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ + key: 'BASIC_AUTH', + name: 'prismic-published-workflow', + }); + + const config = loadCjs(); + + expect( + config.secrets.services['prismic-published-workflow'].keys + ).toStrictEqual(['BASIC_AUTH']); + expect( + config.secrets.services['prismic-published-workflow'].sharedKeys + ).toStrictEqual(['IMGIX_TOKEN']); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'addKeyToEntryInCjs preserves CRLF line endings', + fixtureFor(KEY_STYLES[2]).replace(/\n/g, '\r\n'), + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + + expect(config.secrets.services.site.keys).toStrictEqual([ + 'CACHE_URL', + 'RAS_API_KEY', + ]); + expect(isCrlf(readCjs()), 'no mixed line endings').toBe(true); + } +); + +withFixture( + 'addKeyToEntryInCjs result still validates as a contract', + fixtureFor(KEY_STYLES[0]), + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const { site } = getSecretsContract({ config: loadCjs() }).services; + + expect(site.name).toBe('site'); + expect(site.keys).toStrictEqual(['CACHE_URL', 'RAS_API_KEY']); + expect(site.opItem).toBe('aaaa'); + } +); + +withFixture( + 'addKeyToEntryInCjs handles a multi-line empty service block', + `${preamble()} services: { + site: { + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + expect(loadCjs().secrets.services.site.keys).toStrictEqual([ + 'RAS_API_KEY', + ]); + expect(readCjs()).toContain(` site: { + keys: ['RAS_API_KEY'], + },`); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'setOpItemInCjs handles a multi-line empty service block', + `${preamble()} services: { + site: { + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(readCjs()).toContain(` site: { + opItem: 'bbbb', + },`); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'setOpItemInCjs binds an absent opItem', + `${preamble()} services: { + site: { + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'setOpItemInCjs binds an absent shared opItem', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'shared', opItem: 'services.site' }); + + const config = loadCjs(); + + expect(config.secrets.shared.opItem).toBe('services.site'); + expect(config.secrets.services.site.opItem).toBe('aaaa'); + } +); + +withFixture( + 'setOpItemInCjs is a no-op when already bound', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + const before = readCjs(); + + io.setOpItemInCjs({ name: 'site', opItem: 'zzzz' }); + + expect(readCjs()).toBe(before); + expect(loadCjs().secrets.services.site.opItem).toBe('aaaa'); + } +); + +withFixture( + 'setOpItemInCjs preserves CRLF line endings', + `${preamble()} services: { + site: { + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`.replace(/\n/g, '\r\n'), + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(isCrlf(readCjs()), 'no mixed line endings').toBe(true); + } +); + +withFixture( + 'registerServiceInCjs inserts alphabetically before existing services', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + expect(Object.keys(config.secrets.services)).toStrictEqual([ + 'api', + 'site', + ]); + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'registerServiceInCjs appends alphabetically last services', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'www', opItem: 'dddd' }); + + const config = loadCjs(); + + expect(Object.keys(config.secrets.services)).toStrictEqual([ + 'site', + 'www', + ]); + expect(config.secrets.services.www.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.www.opItem).toBe('dddd'); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'registerServiceInCjs inserts alongside quoted service names', + `${preamble()} services: { + 'prismic-published-workflow': { + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + expect(Object.keys(config.secrets.services)).toStrictEqual([ + 'api', + 'prismic-published-workflow', + ]); + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); + } +); + +withFixture( + 'registerServiceInCjs expands an empty services scaffold', + `${preamble()} services: {}, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'registerServiceInCjs result still validates as a contract', + `${preamble()} services: {}, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const contract = getSecretsContract({ config: loadCjs() }); + + expect(contract.services.api.keys).toStrictEqual(['TOKEN']); + expect(contract.services.api.opItem).toBe('cccc'); + } +); diff --git a/test/op-stdin-detection.test.js b/test/op-stdin-detection.test.js new file mode 100644 index 0000000..4f785e3 --- /dev/null +++ b/test/op-stdin-detection.test.js @@ -0,0 +1,81 @@ +'use strict'; + +// Detection tests against the real `op` binary, with no 1Password +// account: op validates piped stdin before authentication, so the two +// failure modes are distinguishable without a session. These tests pin +// the very behaviour the stdin bridge exists for - if op ever accepts +// socket stdin, they fail and the bridge can be retired. +// +// Skipped automatically when `op` is not installed. GitHub CI installs a +// pinned op; see .github/workflows/test.yaml. + +import { beforeAll, afterAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hasOp = (() => { + try { + execFileSync('op', ['--version'], { stdio: 'ignore' }); + + return true; + } catch (e) { + return false; + } +})(); + +let home; + +beforeAll(() => { + // A sandboxed HOME guarantees no configured account or session can + // interfere: op stops at the (unauthenticated) desktop-app error. + home = fs.mkdtempSync(path.join(os.tmpdir(), 'op-detection-')); +}); + +afterAll(() => { + fs.rmSync(home, { force: true, recursive: true }); +}); + +// Run op with the given command shape and 'payload' on stdin, capturing +// stderr (op exits non-zero in both shapes - on its auth error). +const attempt = ({ args, command }) => { + try { + execFileSync(command, args, { + encoding: 'utf8', + env: { HOME: home, PATH: process.env.PATH }, + input: 'not-a-real-template', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return ''; + } catch (e) { + return String(e.stderr); + } +}; + +it.skipIf(!hasOp)( + 'real op cannot detect Node socket stdin (the bug the bridge fixes)', + () => { + const stderr = attempt({ args: ['inject'], command: 'op' }); + + // Piped straight from Node, op's stdin is an AF_UNIX socket: + // op sees no piped data at all. + expect(stderr).toContain('expected data on stdin'); + } +); + +it.skipIf(!hasOp)('real op detects stdin bridged through a shell pipe', () => { + const stderr = attempt({ + args: ['-c', 'cat | op "$@"', 'sh', 'inject'], + command: 'sh', + }); + + // Through cat | op the stdin check passes; without an account op + // fails later on sign-in instead - never on stdin detection. + expect(stderr).not.toContain('expected data on stdin'); + expect(stderr.length).toBeGreaterThan(0); +}); diff --git a/test/spawn-exit.test.js b/test/spawn-exit.test.js new file mode 100644 index 0000000..6ab5573 --- /dev/null +++ b/test/spawn-exit.test.js @@ -0,0 +1,70 @@ +'use strict'; + +// Integration tests for spawnWithExitCode: exit codes propagate, a +// signal death fails instead of reporting success, and a command that +// cannot spawn is reported rather than crashing with an unhandled +// 'error' event. + +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, it } from 'vitest'; + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const run = promisify(execFile); + +const driver = (body) => + run('node', [ + '-e', + `const { spawnWithExitCode } = require(${JSON.stringify( + `${REPO_ROOT}bin/lib/c.js` + )});\n${body}`, + ]).then( + ({ stderr }) => ({ code: 0, stderr: stderr || '' }), + (e) => ({ code: e.code, stderr: String(e.stderr || '') }) + ); + +it('propagates a zero exit code', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.exit(0)'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(0); +}); + +it('propagates a non-zero exit code', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.exit(3)'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(3); +}); + +it('reports a signal death as a failure (not success)', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.kill(process.pid, 'SIGKILL')'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(1); +}); + +it('reports a command that cannot spawn instead of crashing', async () => { + const { code, stderr } = await driver( + "spawnWithExitCode({ args: [], command: 'c-not-a-real-command-xyz' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(1); + expect(stderr).toContain("Could not run 'c-not-a-real-command-xyz'"); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..2c5f796 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.js'], + }, +});