@underpostnet/cyberia 3.3.77 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/.github/workflows/coverall.cyberia.ci.yml +12 -3
- package/.github/workflows/cyberia-client.cd.yml +2 -1
- package/.github/workflows/cyberia-server.cd.yml +2 -1
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/engine-cyberia.ci.yml +6 -0
- package/.github/workflows/ghpkg.ci.yml +19 -2
- package/.github/workflows/gitlab.ci.yml +8 -6
- package/.github/workflows/hardhat.ci.yml +4 -1
- package/.github/workflows/npmpkg.ci.yml +9 -5
- package/.github/workflows/publish.ci.yml +21 -12
- package/.github/workflows/publish.cyberia.ci.yml +25 -14
- package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -2
- package/CHANGELOG.md +75 -140
- package/CLI-HELP.md +28 -1
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +1 -1
- package/README.md +1 -1
- package/bin/build.js +2 -4
- package/bin/cyberia.js +4 -4
- package/bin/index.js +4 -4
- package/compose.env +1 -0
- package/conf.js +1 -1042
- package/deploy/cyberia-client/deploy.sh +13 -11
- package/deploy/cyberia-server/deploy.sh +13 -11
- package/deploy/dd-cyberia/sync-deploy.sh +7 -28
- package/deploy/lib/config.sh +1 -1
- package/deploy/lib/github-actions-logging.sh +3 -2
- package/deploy/pwa-microservices-template/deploy.sh +7 -1
- package/deployment.yaml +1 -1
- package/docker-compose.yml +7 -7
- package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1 -1
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +1 -1
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +1 -1
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +1 -1
- package/docs/coverage/cyberia/api/object-layer/index.html +1 -1
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1 -1
- package/docs/coverage/cyberia/index.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/index.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +1 -1
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +1 -1
- package/hardhat/package-lock.json +6 -6
- package/hardhat/package.json +2 -2
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +19 -17
- package/scripts/test-monitor.sh +1 -1
- package/src/api/file/file.router.js +7 -1
- package/src/cli/baremetal.js +1 -1
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/index.js +36 -0
- package/src/cli/kubectl.js +4 -3
- package/src/cli/release.js +2 -0
- package/src/cli/repository.js +7 -2
- package/src/cli/run.js +1 -1
- package/src/cli/secrets.js +393 -200
- package/src/cli/socketsecurity.js +260 -0
- package/src/client/components/core/CssCore.js +3 -0
- package/src/client/components/core/Modal.js +160 -36
- package/src/client/components/core/SearchBox.js +0 -1
- package/src/client/components/underpost/AppShellUnderpost.js +13 -1
- package/src/client/components/underpost/CssUnderpost.js +15 -2
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +8 -0
- package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
- package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +54 -56
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +30 -29
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
- package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +54 -25
- package/src/client/ssr/head/Css.js +12 -10
- package/src/client/ssr/views/Cyberia404.js +6 -10
- package/src/client/ssr/views/NotFound.js +75 -0
- package/src/client-builder/client-bundle.js +2 -2
- package/src/index.js +13 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
- package/src/runtime/engine-cyberia/compose.env +1 -0
- package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
- package/src/server/build/catalog.js +1 -0
- package/src/server/build/execution.js +19 -5
- package/src/server/build/package.js +166 -3
- package/src/server/build/testing.js +2 -1
- package/src/server/runtime/conf.js +21 -0
- package/src/server/runtime/config-scope.js +2 -0
- package/src/server/runtime/process.js +4 -3
- package/src/server/security/socketsecurity.js +1735 -0
- package/test/integration/infra/1-security/config-scope.test.js +3 -0
- package/test/integration/infra/1-security/socketsecurity.test.js +1041 -0
- package/test/integration/infra/1-security/sops-secret-store.test.js +201 -71
- package/test/unit/build-template.test.js +24 -0
- package/test/unit/deploy-log-table.test.js +38 -0
- package/test/unit/execution-profiles.test.js +1 -0
- package/test/unit/package.test.js +118 -0
package/src/cli/secrets.js
CHANGED
|
@@ -27,14 +27,23 @@ const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
|
|
|
27
27
|
const SOPS_VERSION = 'v3.10.2';
|
|
28
28
|
const AGE_VERSION = 'v1.2.1';
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
// stored manifest keeps this file's kebab-case
|
|
32
|
-
// spelling so a workload consumes it through
|
|
33
|
-
// MANAGED_SECRETS: its authoritative home is the
|
|
34
|
-
// manifest is an optional mirror rather than the origin.
|
|
30
|
+
// GitHub Actions secrets this domain distributes to a deploy's repositories. `GIT_AUTH_TOKEN` is
|
|
31
|
+
// the one with a mirror in the encrypted store: the stored manifest keeps this file's kebab-case
|
|
32
|
+
// naming, while the data key keeps the environment spelling so a workload consumes it through
|
|
33
|
+
// `envFrom` unchanged. Deliberately absent from MANAGED_SECRETS: its authoritative home is the
|
|
34
|
+
// GitHub Actions secret store, and the encrypted manifest is an optional mirror rather than the origin.
|
|
35
35
|
const GIT_AUTH_TOKEN_KEY = 'GIT_AUTH_TOKEN';
|
|
36
36
|
const GIT_AUTH_TOKEN_SECRET = 'git-auth-token';
|
|
37
|
-
const
|
|
37
|
+
const ACTIONS_SECRET_STAGE_DIR = '/dev/shm/underpost-actions-secret';
|
|
38
|
+
// Where the writes go: the account type, its owner, and the credential `gh` runs under.
|
|
39
|
+
const GITHUB_TARGET_ENV = Object.freeze({
|
|
40
|
+
type: 'GITHUB_TARGET_TYPE',
|
|
41
|
+
user: 'GITHUB_USERNAME',
|
|
42
|
+
org: 'GITHUB_ORG_NAME',
|
|
43
|
+
token: 'GITHUB_SECRET_TOKEN',
|
|
44
|
+
});
|
|
45
|
+
const GITHUB_TARGET_TYPES = ['user', 'org'];
|
|
46
|
+
const GITHUB_SECRET_LEVELS = ['repo', 'org'];
|
|
38
47
|
|
|
39
48
|
// The second meta id alongside `dd`. The template lineage is not a deploy, so it carries no conf
|
|
40
49
|
// id and resolves to its own repositories rather than through the `engine-<conf-id>` naming.
|
|
@@ -251,6 +260,22 @@ const stdinIsRedirected = () => {
|
|
|
251
260
|
|
|
252
261
|
const looksLikeGitHubToken = (token) => /^(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})$/.test(token);
|
|
253
262
|
|
|
263
|
+
// Per-key rules on top of the generic write. A token is checked for shape; any other value is
|
|
264
|
+
// opaque and taken as given.
|
|
265
|
+
const ACTIONS_SECRET_POLICIES = Object.freeze({
|
|
266
|
+
[GIT_AUTH_TOKEN_KEY]: {
|
|
267
|
+
validate(value) {
|
|
268
|
+
if (/\s/.test(value))
|
|
269
|
+
throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} contains whitespace; it is not a token`);
|
|
270
|
+
if (!looksLikeGitHubToken(value))
|
|
271
|
+
logger.warn(
|
|
272
|
+
`The replacement ${GIT_AUTH_TOKEN_KEY} does not match a known GitHub token shape ` +
|
|
273
|
+
`(ghp_…, gho_…, github_pat_…). Continuing — GitHub token formats have changed before.`,
|
|
274
|
+
);
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
254
279
|
/**
|
|
255
280
|
* @class UnderpostSecret
|
|
256
281
|
* @description Manages the secrets of the application.
|
|
@@ -368,45 +393,42 @@ class UnderpostSecret {
|
|
|
368
393
|
* Replaces the current projection: either the Age identity the store is sealed to, or the
|
|
369
394
|
* value of a credential itself.
|
|
370
395
|
*
|
|
371
|
-
* `--args secret
|
|
372
|
-
* every repository of a deploy
|
|
373
|
-
* Without it the recipient rotation runs, which re-keys
|
|
374
|
-
* recipient: secret values are unchanged there, so no
|
|
396
|
+
* `--args secret=<NAME|NAME>` selects the credential rotation — GitHub Actions secrets on
|
|
397
|
+
* every repository of a deploy, under a user account or an organization, with `GIT_AUTH_TOKEN`
|
|
398
|
+
* mirrored into the encrypted store. Without it the recipient rotation runs, which re-keys
|
|
399
|
+
* stored manifests onto a new Age recipient: secret values are unchanged there, so no
|
|
400
|
+
* workload restart is needed.
|
|
375
401
|
* @param {object} context - Normalized domain context. Requires either
|
|
376
|
-
* `--args secret
|
|
377
|
-
* `store=true`, `apply=true`) or `--args recipient=age1...`
|
|
378
|
-
* previous recipients and `keep=` to retain named ones).
|
|
402
|
+
* `--args secret=<NAME|NAME>` (with `value=`, `source=host`, `deploy-id=`, `target=`,
|
|
403
|
+
* `owner=`, `level=`, `repos=`, `store=true`, `apply=true`) or `--args recipient=age1...`
|
|
404
|
+
* (with `prune=true` to revoke previous recipients and `keep=` to retain named ones).
|
|
379
405
|
* @returns {*} The rotation result.
|
|
380
406
|
* @memberof UnderpostSecret
|
|
381
407
|
*/
|
|
382
408
|
rotate(context = {}) {
|
|
383
409
|
context = domainContextFactory(context);
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// credential and would replace the very name the operator needs to read back.
|
|
389
|
-
throw new Error(
|
|
390
|
-
`[secret] rotate does not know the credential '${secret}'; ${GIT_AUTH_TOKEN_KEY} is the only one ` +
|
|
391
|
-
`it rotates. Re-key the store's encryption identity with --args recipient=age1... instead.`,
|
|
392
|
-
);
|
|
393
|
-
return Underpost.secret.rotateGitAuthToken({
|
|
410
|
+
const keys = `${context.args.secret ?? ''}`.trim();
|
|
411
|
+
if (keys)
|
|
412
|
+
return Underpost.secret.rotateActionsSecrets({
|
|
413
|
+
keys,
|
|
394
414
|
namespace: context.namespace,
|
|
415
|
+
env: context.env,
|
|
395
416
|
dryRun: context.dryRun,
|
|
396
|
-
|
|
417
|
+
value: context.args.value,
|
|
418
|
+
source: context.args.source,
|
|
397
419
|
deployId: context.args['deploy-id'] ?? context.args.deployId,
|
|
420
|
+
target: context.args.target,
|
|
398
421
|
owner: context.args.owner,
|
|
422
|
+
level: context.args.level,
|
|
399
423
|
repos: context.args.repos,
|
|
400
424
|
store: context.args.store,
|
|
401
425
|
apply: context.args.apply,
|
|
402
426
|
});
|
|
403
|
-
}
|
|
404
427
|
const recipient = `${context.args.recipient ?? ''}`.trim();
|
|
405
428
|
if (!recipient)
|
|
406
429
|
throw new Error(
|
|
407
430
|
'[secret] rotate requires a target: --args recipient=<age-public-key> re-keys the store onto a new ' +
|
|
408
|
-
|
|
409
|
-
'one it supports).',
|
|
431
|
+
'Age identity, and --args secret=<NAME|NAME> writes GitHub Actions secrets.',
|
|
410
432
|
);
|
|
411
433
|
return Underpost.secret.rotateRecipient(recipient, {
|
|
412
434
|
namespace: context.namespace,
|
|
@@ -1641,10 +1663,69 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1641
1663
|
},
|
|
1642
1664
|
|
|
1643
1665
|
/**
|
|
1644
|
-
* @method
|
|
1645
|
-
* @description Resolves
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1666
|
+
* @method githubTargetFactory
|
|
1667
|
+
* @description Resolves where GitHub Actions secrets are written: the account type, the
|
|
1668
|
+
* owner every derived repository name resolves under, the level the writes land at, and the
|
|
1669
|
+
* credential `gh` runs under.
|
|
1670
|
+
*
|
|
1671
|
+
* `GITHUB_TARGET_TYPE` selects `user` or `org`, and the owner follows it: `GITHUB_USERNAME`
|
|
1672
|
+
* (after the owner of `ENGINE_SRC_REPO`, so a node keeps addressing the fleet it was
|
|
1673
|
+
* provisioned from) or `GITHUB_ORG_NAME`. An explicit `owner` overrides both.
|
|
1674
|
+
* `GITHUB_SECRET_TOKEN` is the PAT the writes authenticate with — `repo` scope, plus
|
|
1675
|
+
* `admin:org` for organization secrets; unset, `gh` uses the account `gh auth login` stored.
|
|
1676
|
+
*
|
|
1677
|
+
* `level` is where a value lands. `repo` writes each repository's own secret and works for
|
|
1678
|
+
* both account types. `org` writes one organization secret visible to the resolved
|
|
1679
|
+
* repositories; it needs an organization, and a free organization cannot share it with its
|
|
1680
|
+
* private repositories.
|
|
1681
|
+
* @param {object} [options={}] - Resolution options.
|
|
1682
|
+
* @param {string} [options.target] - `user` or `org`; `GITHUB_TARGET_TYPE` otherwise.
|
|
1683
|
+
* @param {string} [options.owner] - Explicit owner.
|
|
1684
|
+
* @param {string} [options.level] - `repo` or `org`; `repo` otherwise.
|
|
1685
|
+
* @returns {{type: string, owner: string, level: string, token: string}} The target.
|
|
1686
|
+
* @memberof UnderpostSecret
|
|
1687
|
+
*/
|
|
1688
|
+
githubTargetFactory(options = {}) {
|
|
1689
|
+
const read = (key) => `${process.env[key] ?? ''}`.trim();
|
|
1690
|
+
const type = (`${options.target ?? ''}`.trim() || read(GITHUB_TARGET_ENV.type) || 'user').toLowerCase();
|
|
1691
|
+
if (!GITHUB_TARGET_TYPES.includes(type))
|
|
1692
|
+
throw new Error(`[secret] unknown GitHub target type '${type}'; expected ${GITHUB_TARGET_TYPES.join(' or ')}`);
|
|
1693
|
+
const level = (`${options.level ?? ''}`.trim() || 'repo').toLowerCase();
|
|
1694
|
+
if (!GITHUB_SECRET_LEVELS.includes(level))
|
|
1695
|
+
throw new Error(`[secret] unknown secret level '${level}'; expected ${GITHUB_SECRET_LEVELS.join(' or ')}`);
|
|
1696
|
+
if (level === 'org' && type !== 'org')
|
|
1697
|
+
throw new Error(`[secret] an organization secret needs ${GITHUB_TARGET_ENV.type}=org or --args target=org`);
|
|
1698
|
+
const envSource = read('ENGINE_SRC_REPO');
|
|
1699
|
+
const owner =
|
|
1700
|
+
`${options.owner ?? ''}`.trim() ||
|
|
1701
|
+
(type === 'org'
|
|
1702
|
+
? read(GITHUB_TARGET_ENV.org)
|
|
1703
|
+
: (envSource.includes('/') ? envSource.split('/')[0] : '') || read(GITHUB_TARGET_ENV.user) || 'underpostnet');
|
|
1704
|
+
if (!owner)
|
|
1705
|
+
throw new Error(
|
|
1706
|
+
`[secret] ${GITHUB_TARGET_ENV.type}=org needs ${GITHUB_TARGET_ENV.org}, or --args owner=<organization>`,
|
|
1707
|
+
);
|
|
1708
|
+
return { type, owner, level, token: read(GITHUB_TARGET_ENV.token) };
|
|
1709
|
+
},
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* @method ghEnvFactory
|
|
1713
|
+
* @description The environment `gh` runs under. `GITHUB_SECRET_TOKEN` travels as `GH_TOKEN`,
|
|
1714
|
+
* so the writes authenticate as the credential chosen for them rather than as whatever token
|
|
1715
|
+
* the shell exports.
|
|
1716
|
+
* @param {{token?: string}} [target={}] - Resolved target.
|
|
1717
|
+
* @returns {NodeJS.ProcessEnv} Child environment.
|
|
1718
|
+
* @memberof UnderpostSecret
|
|
1719
|
+
*/
|
|
1720
|
+
ghEnvFactory(target = {}) {
|
|
1721
|
+
return target.token ? { ...process.env, GH_TOKEN: target.token } : process.env;
|
|
1722
|
+
},
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* @method actionsSecretTargets
|
|
1726
|
+
* @description Resolves every GitHub repository that carries a deploy's Actions secrets:
|
|
1727
|
+
* the private configuration repository its conf lives in, and the engine source
|
|
1728
|
+
* repositories it deploys from — production and test, which are one deploy under two names.
|
|
1648
1729
|
*
|
|
1649
1730
|
* Naming is delegated to {@link UnderpostRepository} rather than re-derived here, so a
|
|
1650
1731
|
* rotation targets exactly the repositories `run pull` and `deploy/lib/host.sh` resolve for
|
|
@@ -1655,32 +1736,34 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1655
1736
|
* Each deploy contributes its private conf repository, its production and test engine sources,
|
|
1656
1737
|
* its `engine-ghpkg-<conf-id>` package mirror, and every `metadata.repository` its
|
|
1657
1738
|
* `conf.instances.json` declares — an instance is a separate product with its own workflows
|
|
1658
|
-
* reading the same
|
|
1739
|
+
* reading the same secrets. Derived names that do not exist are dropped by the reachability
|
|
1659
1740
|
* probe, so no separate existence check is needed here.
|
|
1660
1741
|
*
|
|
1742
|
+
* Every name resolves under the owner {@link githubTargetFactory} chose. Under an
|
|
1743
|
+
* organization target a declared slug — an instance repository, `ENGINE_SRC_REPO` — is
|
|
1744
|
+
* re-owned too: the organization mirrors carry the same repository names, and a write that
|
|
1745
|
+
* followed the declared owner would land on the account being migrated away from.
|
|
1746
|
+
*
|
|
1661
1747
|
* `dd` fans out across `engine-private/deploy/dd.routes`, so one rotation covers the whole
|
|
1662
|
-
* fleet. `template` is the second meta id
|
|
1663
|
-
* resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and
|
|
1664
|
-
* deduplicated: deploys share repositories, and one listed twice would
|
|
1748
|
+
* fleet. `template` is the second meta id, usable alone or in a list: the template lineage
|
|
1749
|
+
* carries no conf id, so it resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and
|
|
1750
|
+
* `engine`. The union is deduplicated: deploys share repositories, and one listed twice would
|
|
1751
|
+
* be written twice.
|
|
1665
1752
|
* @param {object} [options={}] - Resolution options.
|
|
1666
1753
|
* @param {string} [options.deployId] - Deploy id, any repository reference naming it, a list
|
|
1667
1754
|
* separated by `|`, `;` or whitespace, the meta id `dd` for every deploy in
|
|
1668
1755
|
* `engine-private/deploy/dd.routes`, or `template` for the template lineage. Falls back to
|
|
1669
1756
|
* `ENGINE_SRC_REPO`, then the monorepo pair.
|
|
1670
|
-
* @param {string} [options.owner] - GitHub owner
|
|
1671
|
-
*
|
|
1757
|
+
* @param {string} [options.owner] - GitHub owner; see {@link githubTargetFactory}.
|
|
1758
|
+
* @param {string} [options.target] - Account type; see {@link githubTargetFactory}.
|
|
1672
1759
|
* @param {string} [options.repos] - Extra targets separated by `|`, `;` or whitespace —
|
|
1673
|
-
* `--args` itself splits on commas, so a list there cannot use one.
|
|
1760
|
+
* `--args` itself splits on commas, so a list there cannot use one. Taken as written.
|
|
1674
1761
|
* @returns {Array<string>} Deduplicated `owner/repo` slugs, private configuration first.
|
|
1675
1762
|
* @memberof UnderpostSecret
|
|
1676
1763
|
*/
|
|
1677
|
-
|
|
1764
|
+
actionsSecretTargets(options = {}) {
|
|
1765
|
+
const { type, owner } = Underpost.secret.githubTargetFactory(options);
|
|
1678
1766
|
const envSource = `${process.env.ENGINE_SRC_REPO ?? ''}`.trim();
|
|
1679
|
-
const owner =
|
|
1680
|
-
`${options.owner ?? ''}`.trim() ||
|
|
1681
|
-
(envSource.includes('/') ? envSource.split('/')[0] : '') ||
|
|
1682
|
-
process.env.GITHUB_USERNAME ||
|
|
1683
|
-
'underpostnet';
|
|
1684
1767
|
const requested = `${options.deployId ?? ''}`.trim() || envSource;
|
|
1685
1768
|
// `dd` is the meta id every runner reads as "all of dd.routes", resolved through the one
|
|
1686
1769
|
// reader the cluster deploys from — a rotation that parsed the route table itself could
|
|
@@ -1696,31 +1779,34 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1696
1779
|
`rather than the fleet. Check out engine-private, or name the deploys explicitly with ` +
|
|
1697
1780
|
`--args "deploy-id=dd-one|dd-two".`,
|
|
1698
1781
|
);
|
|
1782
|
+
const reowned = (slug) => (type === 'org' && slug ? `${owner}/${slug.split('/').pop()}` : slug);
|
|
1699
1783
|
const candidates = [];
|
|
1700
1784
|
// Unioned and deduplicated: deploys share repositories (every one of them pairs with the
|
|
1701
1785
|
// same engine-private when ENGINE_SRC_PRIVATE_REPO names it), and a repository listed twice
|
|
1702
|
-
// would be
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
|
|
1708
|
-
const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
|
|
1709
|
-
candidates.push(
|
|
1710
|
-
// Paired off the source rather than named on its own, so the conf repository and the
|
|
1711
|
-
// engine it configures can never be resolved apart.
|
|
1712
|
-
Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
|
|
1713
|
-
source,
|
|
1714
|
-
`${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
|
|
1715
|
-
// The ghpkg mirror and the instance repositories run their own workflows against the
|
|
1716
|
-
// same token, so a rotation that skipped them would leave half the deploy behind.
|
|
1717
|
-
...(ghpkg ? [`${owner}/${ghpkg}`] : []),
|
|
1718
|
-
...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`) : []),
|
|
1719
|
-
);
|
|
1786
|
+
// would be written twice.
|
|
1787
|
+
for (const reference of references.length > 0 ? references : ['']) {
|
|
1788
|
+
if (reference === TEMPLATE_ALIAS) {
|
|
1789
|
+
candidates.push(...TEMPLATE_REPOS.map((repo) => `${owner}/${repo}`));
|
|
1790
|
+
continue;
|
|
1720
1791
|
}
|
|
1792
|
+
const confId = Underpost.repo.confIdFactory(reference);
|
|
1793
|
+
const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
|
|
1794
|
+
const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
|
|
1795
|
+
candidates.push(
|
|
1796
|
+
// Paired off the source rather than named on its own, so the conf repository and the
|
|
1797
|
+
// engine it configures can never be resolved apart.
|
|
1798
|
+
Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
|
|
1799
|
+
source,
|
|
1800
|
+
`${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
|
|
1801
|
+
// The ghpkg mirror and the instance repositories run their own workflows against the
|
|
1802
|
+
// same secrets, so a rotation that skipped them would leave half the deploy behind.
|
|
1803
|
+
...(ghpkg ? [`${owner}/${ghpkg}`] : []),
|
|
1804
|
+
...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`).map(reowned) : []),
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1721
1807
|
candidates.push(
|
|
1722
|
-
`${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim(),
|
|
1723
|
-
envSource,
|
|
1808
|
+
reowned(`${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim()),
|
|
1809
|
+
reowned(envSource),
|
|
1724
1810
|
...`${options.repos ?? ''}`.split(/[,|;\s]+/),
|
|
1725
1811
|
);
|
|
1726
1812
|
const targets = [];
|
|
@@ -1739,34 +1825,47 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1739
1825
|
},
|
|
1740
1826
|
|
|
1741
1827
|
/**
|
|
1742
|
-
* @method
|
|
1743
|
-
* @description Names the source a
|
|
1744
|
-
*
|
|
1828
|
+
* @method plannedSecretSource
|
|
1829
|
+
* @description Names the source a key's value would come from, without reading, prompting
|
|
1830
|
+
* for or consuming anything. Pure, so `--dry-run` can report the plan truthfully.
|
|
1831
|
+
*
|
|
1832
|
+
* Sources, in order: `--args value=`, piped stdin, the key's own environment variable, the
|
|
1833
|
+
* host configuration (`engine-private/deploy/scopes/*.env.<env>`), then a no-echo terminal
|
|
1834
|
+
* prompt. `source=host` pins the host configuration, so automation cannot take a stale
|
|
1835
|
+
* export by mistake.
|
|
1836
|
+
* @param {string} key - Secret name.
|
|
1745
1837
|
* @param {object} [options={}] - Rotation options.
|
|
1746
|
-
* @param {string} [options.
|
|
1838
|
+
* @param {string} [options.value] - Value supplied through `--args value=`.
|
|
1839
|
+
* @param {string} [options.source] - `host` to read the host configuration only.
|
|
1840
|
+
* @param {Object<string, string>} [hostValues={}] - The host configuration.
|
|
1747
1841
|
* @returns {string} Human-readable source name.
|
|
1748
1842
|
* @memberof UnderpostSecret
|
|
1749
1843
|
*/
|
|
1750
|
-
|
|
1751
|
-
|
|
1844
|
+
plannedSecretSource(key, options = {}, hostValues = {}) {
|
|
1845
|
+
const hostValue = `${hostValues[key] ?? ''}`.trim();
|
|
1846
|
+
if (options.source === 'host')
|
|
1847
|
+
return hostValue ? 'host configuration' : '(unavailable: not in the host configuration)';
|
|
1848
|
+
if (`${options.value ?? ''}`.trim()) return '--args value';
|
|
1752
1849
|
if (stdinIsRedirected()) return 'piped stdin';
|
|
1753
|
-
if (`${process.env[
|
|
1754
|
-
|
|
1850
|
+
if (`${process.env[key] ?? ''}`.trim()) return `${key} environment`;
|
|
1851
|
+
if (hostValue) return 'host configuration';
|
|
1852
|
+
return process.stdin.isTTY ? 'interactive prompt' : '(unavailable: no value, nothing piped, no terminal)';
|
|
1755
1853
|
},
|
|
1756
1854
|
|
|
1757
1855
|
/**
|
|
1758
|
-
* @method
|
|
1759
|
-
* @description Splits resolved targets into those the
|
|
1760
|
-
*
|
|
1856
|
+
* @method probeActionsSecretTargets
|
|
1857
|
+
* @description Splits resolved targets into those the `gh` credential can actually reach
|
|
1858
|
+
* and those it cannot, without writing anything.
|
|
1761
1859
|
*
|
|
1762
1860
|
* A deploy does not necessarily own every repository its naming implies — a test source repo
|
|
1763
1861
|
* often does not exist — so this is what keeps a fleet fan-out from aborting on the first
|
|
1764
1862
|
* absent one, and what lets `--dry-run` report the real target set rather than the derived one.
|
|
1765
1863
|
* @param {Array<string>} targets - `owner/repo` slugs.
|
|
1864
|
+
* @param {{token?: string}} [target={}] - Resolved target, for the credential.
|
|
1766
1865
|
* @returns {{reachable: Array<string>, unreachable: Array<string>}} The split.
|
|
1767
1866
|
* @memberof UnderpostSecret
|
|
1768
1867
|
*/
|
|
1769
|
-
|
|
1868
|
+
probeActionsSecretTargets(targets = [], target = {}) {
|
|
1770
1869
|
const reachable = [];
|
|
1771
1870
|
const unreachable = [];
|
|
1772
1871
|
for (const repo of targets) {
|
|
@@ -1775,6 +1874,7 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1775
1874
|
silent: true,
|
|
1776
1875
|
silentOnError: true,
|
|
1777
1876
|
disableLog: true,
|
|
1877
|
+
env: Underpost.secret.ghEnvFactory(target),
|
|
1778
1878
|
});
|
|
1779
1879
|
if (`${view}`.trim()) reachable.push(repo);
|
|
1780
1880
|
else {
|
|
@@ -1786,53 +1886,65 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1786
1886
|
},
|
|
1787
1887
|
|
|
1788
1888
|
/**
|
|
1789
|
-
* @method
|
|
1790
|
-
* @description Materializes
|
|
1791
|
-
*
|
|
1889
|
+
* @method stageActionsSecret
|
|
1890
|
+
* @description Materializes one value onto tmpfs at mode 600 — the single source the GitHub
|
|
1891
|
+
* write and, for `GIT_AUTH_TOKEN`, the manifest write read from.
|
|
1792
1892
|
*
|
|
1793
1893
|
* The value never travels as a command argument: `gh secret set` takes it on stdin and the
|
|
1794
1894
|
* manifest is built by Node, so it reaches neither the process table nor the command log. An
|
|
1795
1895
|
* interactive prompt writes straight into the staged file for the same reason — captured
|
|
1796
1896
|
* stdout is logged, a file is not.
|
|
1797
1897
|
*
|
|
1798
|
-
* Sources
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
* @param {string} stagePath - tmpfs path to write the
|
|
1802
|
-
* @param {
|
|
1803
|
-
* @param {
|
|
1804
|
-
* @
|
|
1898
|
+
* Sources are the ones {@link plannedSecretSource} names, in that order. Piping is the one
|
|
1899
|
+
* that keeps a value out of both the process table and the shell history; the host
|
|
1900
|
+
* configuration is the one automation reads, because it is the durable record.
|
|
1901
|
+
* @param {string} stagePath - tmpfs path to write the value to.
|
|
1902
|
+
* @param {string} key - Secret name.
|
|
1903
|
+
* @param {object} [options={}] - Value sources; see {@link plannedSecretSource}.
|
|
1904
|
+
* @param {Object<string, string>} [hostValues={}] - The host configuration.
|
|
1905
|
+
* @returns {{value: string, source: string}} The staged value and where it came from.
|
|
1805
1906
|
* @memberof UnderpostSecret
|
|
1806
1907
|
*/
|
|
1807
|
-
|
|
1908
|
+
stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {
|
|
1808
1909
|
// `GITHUB_TOKEN` is deliberately not a source: it is the credential `gh` authenticates
|
|
1809
1910
|
// *with*, which during a rotation is the outgoing token. Reading it here would re-set the
|
|
1810
1911
|
// value being replaced and report a rotation that never happened.
|
|
1811
|
-
const provided = `${options.
|
|
1812
|
-
const inherited = `${process.env[
|
|
1912
|
+
const provided = `${options.value ?? ''}`.trim();
|
|
1913
|
+
const inherited = `${process.env[key] ?? ''}`.trim();
|
|
1914
|
+
const hostValue = `${hostValues[key] ?? ''}`.trim();
|
|
1813
1915
|
let source;
|
|
1814
|
-
if (
|
|
1916
|
+
if (options.source === 'host') {
|
|
1917
|
+
if (!hostValue)
|
|
1918
|
+
throw new Error(
|
|
1919
|
+
`[secret] ${key} is not in the host configuration (${Underpost.host.sourceLabel(options.env)})`,
|
|
1920
|
+
);
|
|
1921
|
+
writeStageFileSync(stagePath, hostValue);
|
|
1922
|
+
source = 'host configuration';
|
|
1923
|
+
} else if (provided) {
|
|
1815
1924
|
writeStageFileSync(stagePath, provided);
|
|
1816
|
-
source = '--args
|
|
1925
|
+
source = '--args value';
|
|
1817
1926
|
} else if (stdinIsRedirected()) {
|
|
1818
|
-
// Ahead of the environment: a pipe is what the operator chose for this run, while
|
|
1819
|
-
//
|
|
1927
|
+
// Ahead of the environment: a pipe is what the operator chose for this run, while the
|
|
1928
|
+
// environment may be an inherited export still holding the outgoing value.
|
|
1820
1929
|
writeStageFileSync(stagePath, fs.readFileSync(0, 'utf8'));
|
|
1821
1930
|
source = 'piped stdin';
|
|
1822
1931
|
} else if (inherited) {
|
|
1823
1932
|
writeStageFileSync(stagePath, inherited);
|
|
1824
|
-
source = `${
|
|
1933
|
+
source = `${key} environment`;
|
|
1934
|
+
} else if (hostValue) {
|
|
1935
|
+
writeStageFileSync(stagePath, hostValue);
|
|
1936
|
+
source = 'host configuration';
|
|
1825
1937
|
} else {
|
|
1826
1938
|
if (!process.stdin.isTTY)
|
|
1827
1939
|
throw new Error(
|
|
1828
|
-
`[secret] rotate needs
|
|
1829
|
-
`(printf %s "$
|
|
1830
|
-
|
|
1940
|
+
`[secret] rotate needs a value for ${key}: pipe it in ` +
|
|
1941
|
+
`(printf %s "$VALUE" | node bin secret rotate …), pass --args value=<value>, export ${key}, ` +
|
|
1942
|
+
`declare it in the host configuration with --args source=host, or run this from a terminal to be prompted.`,
|
|
1831
1943
|
);
|
|
1832
1944
|
// Created empty first so the file exists at mode 600 before anything is read into it.
|
|
1833
1945
|
writeStageFileSync(stagePath, '');
|
|
1834
1946
|
shellExec(
|
|
1835
|
-
`bash -c 'set -o pipefail; umask 077; read -rsp "New ${
|
|
1947
|
+
`bash -c 'set -o pipefail; umask 077; read -rsp "New ${key}: " value </dev/tty; ` +
|
|
1836
1948
|
`echo >/dev/tty; printf %s "$value" > "${stagePath}"'`,
|
|
1837
1949
|
{ disableLog: true },
|
|
1838
1950
|
);
|
|
@@ -1840,17 +1952,56 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1840
1952
|
}
|
|
1841
1953
|
|
|
1842
1954
|
const raw = fs.readFileSync(stagePath, 'utf8');
|
|
1843
|
-
const
|
|
1844
|
-
if (!
|
|
1845
|
-
if (
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1955
|
+
const value = raw.replace(/\r?\n$/, '');
|
|
1956
|
+
if (!value) throw new Error(`[secret] the value for ${key} is empty`);
|
|
1957
|
+
if (value !== raw) writeStageFileSync(stagePath, value);
|
|
1958
|
+
ACTIONS_SECRET_POLICIES[key]?.validate?.(value);
|
|
1959
|
+
return { value, source };
|
|
1960
|
+
},
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* @method writeActionsSecret
|
|
1964
|
+
* @description Writes one staged value to GitHub. At the `repo` level every repository gets
|
|
1965
|
+
* its own secret. At the `org` level the organization's repositories share one organization
|
|
1966
|
+
* secret whose visibility is exactly that set, and a repository under another owner still
|
|
1967
|
+
* gets its own — an instance declared outside the organization is not left behind.
|
|
1968
|
+
*
|
|
1969
|
+
* The value arrives on stdin from the staged file, never as an argument, and the command is
|
|
1970
|
+
* kept out of the log.
|
|
1971
|
+
* @param {object} params - Write parameters.
|
|
1972
|
+
* @param {string} params.key - Secret name.
|
|
1973
|
+
* @param {string} params.stagePath - tmpfs file holding the value.
|
|
1974
|
+
* @param {{owner: string, level: string, token?: string}} params.target - Resolved target.
|
|
1975
|
+
* @param {Array<string>} params.repos - Reachable `owner/repo` slugs.
|
|
1976
|
+
* @returns {{written: Array<string>, failed: Array<string>}} Repositories covered and not.
|
|
1977
|
+
* @memberof UnderpostSecret
|
|
1978
|
+
*/
|
|
1979
|
+
writeActionsSecret({ key, stagePath, target, repos }) {
|
|
1980
|
+
const env = Underpost.secret.ghEnvFactory(target);
|
|
1981
|
+
const written = [];
|
|
1982
|
+
const failed = [];
|
|
1983
|
+
const write = (scope, covered) => {
|
|
1984
|
+
try {
|
|
1985
|
+
shellExec(`bash -c 'set -o pipefail; gh secret set ${key} ${scope} < "${stagePath}"'`, {
|
|
1986
|
+
silent: true,
|
|
1987
|
+
disableLog: true,
|
|
1988
|
+
env,
|
|
1989
|
+
});
|
|
1990
|
+
written.push(...covered);
|
|
1991
|
+
logger.info(`${key} set on ${covered.join(', ')}`);
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
failed.push(...covered);
|
|
1994
|
+
logger.error(`${key} could not be set on ${covered.join(', ')}`, { error: error.message });
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
const shared = target.level === 'org' ? repos.filter((slug) => slug.split('/')[0] === target.owner) : [];
|
|
1998
|
+
if (shared.length > 0)
|
|
1999
|
+
write(
|
|
2000
|
+
`--org "${target.owner}" --visibility selected --repos "${shared.map((slug) => slug.split('/')[1]).join(',')}"`,
|
|
2001
|
+
shared,
|
|
1852
2002
|
);
|
|
1853
|
-
|
|
2003
|
+
for (const repo of repos.filter((slug) => !shared.includes(slug))) write(`--repo "${repo}"`, [repo]);
|
|
2004
|
+
return { written, failed };
|
|
1854
2005
|
},
|
|
1855
2006
|
|
|
1856
2007
|
/**
|
|
@@ -1869,7 +2020,7 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1869
2020
|
* @memberof UnderpostSecret
|
|
1870
2021
|
*/
|
|
1871
2022
|
writeGitAuthTokenManifest(token, namespace = 'default') {
|
|
1872
|
-
const stageDir = stageDirSync(
|
|
2023
|
+
const stageDir = stageDirSync(ACTIONS_SECRET_STAGE_DIR);
|
|
1873
2024
|
const plaintextPath = `${stageDir}/${GIT_AUTH_TOKEN_SECRET}.yaml`;
|
|
1874
2025
|
writeStageFileSync(
|
|
1875
2026
|
plaintextPath,
|
|
@@ -1893,14 +2044,15 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1893
2044
|
},
|
|
1894
2045
|
|
|
1895
2046
|
/**
|
|
1896
|
-
* @method
|
|
1897
|
-
* @description
|
|
1898
|
-
* authenticates with,
|
|
2047
|
+
* @method rotateActionsSecrets
|
|
2048
|
+
* @description Writes one or more GitHub Actions secrets to every repository a deploy
|
|
2049
|
+
* authenticates with, under a user account or an organization, and records `GIT_AUTH_TOKEN`
|
|
2050
|
+
* in the encrypted store when it is mirrored there.
|
|
1899
2051
|
*
|
|
1900
|
-
* GitHub is written first and the store second, because
|
|
1901
|
-
* holds it: a store that leads GitHub records a
|
|
2052
|
+
* GitHub is written first and the store second, because a credential is only real once
|
|
2053
|
+
* GitHub holds it: a store that leads GitHub records a value no workflow can use, while a
|
|
1902
2054
|
* GitHub that leads the store converges on the next run. Every write is idempotent, so a run
|
|
1903
|
-
* that failed part way is re-runnable with the same
|
|
2055
|
+
* that failed part way is re-runnable with the same values.
|
|
1904
2056
|
*
|
|
1905
2057
|
* A target that does not resolve is reported and skipped rather than aborting the rotation —
|
|
1906
2058
|
* a deploy does not necessarily own every repository its naming implies, and a missing test
|
|
@@ -1908,65 +2060,111 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1908
2060
|
* fails to write is collected and raised at the end, after the repositories that did succeed
|
|
1909
2061
|
* are on record.
|
|
1910
2062
|
*
|
|
1911
|
-
* The token never appears as a command argument: `gh secret set` reads it from a tmpfs file
|
|
1912
|
-
* on stdin, so it reaches neither the process table nor the command log.
|
|
1913
|
-
*
|
|
1914
2063
|
* Usage:
|
|
1915
|
-
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,
|
|
2064
|
+
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,value=<new>,deploy-id=dd-cyberia"
|
|
1916
2065
|
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,deploy-id=dd" --dry-run # whole fleet
|
|
1917
|
-
* node bin secret rotate --args "secret=
|
|
2066
|
+
* node bin secret rotate --args "secret=SOCKET_CLI_API_TOKEN|SOCKET_CLI_ORG_SLUG|NPM_TOKEN,source=host,deploy-id=dd"
|
|
2067
|
+
* node bin secret rotate --args "secret=NPM_TOKEN,source=host,target=org,level=org,deploy-id=template"
|
|
1918
2068
|
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,store=true,apply=true"
|
|
1919
2069
|
* @param {object} [options={}] - Rotation options.
|
|
1920
|
-
* @param {string} [options.
|
|
1921
|
-
*
|
|
2070
|
+
* @param {string} [options.keys] - Secret names, separated by `|`, `;` or whitespace.
|
|
2071
|
+
* @param {string} [options.value] - The value, for a single key. When omitted: piped stdin,
|
|
2072
|
+
* then the key's environment variable, then the host configuration, then a no-echo prompt.
|
|
2073
|
+
* @param {string} [options.source] - `host` reads the host configuration only.
|
|
2074
|
+
* @param {string} [options.env='production'] - Environment the host configuration is read for.
|
|
1922
2075
|
* @param {string} [options.deployId] - Deploy id, a list separated by `|`, `;` or whitespace,
|
|
1923
2076
|
* `dd` for every deploy in `engine-private/deploy/dd.routes`, or `template`.
|
|
2077
|
+
* @param {string} [options.target] - `user` or `org`; see {@link githubTargetFactory}.
|
|
1924
2078
|
* @param {string} [options.owner] - GitHub owner for the resolved repository names.
|
|
2079
|
+
* @param {string} [options.level] - `repo` or `org`; see {@link githubTargetFactory}.
|
|
1925
2080
|
* @param {string} [options.repos] - Extra `owner/repo` targets, separated by `|`, `;` or space.
|
|
1926
2081
|
* @param {string} [options.namespace='default'] - Store namespace for the mirrored manifest.
|
|
1927
|
-
* @param {boolean} [options.store=false] - Mirror into the encrypted store
|
|
1928
|
-
* manifest is stored yet. An existing manifest is always updated.
|
|
2082
|
+
* @param {boolean} [options.store=false] - Mirror `GIT_AUTH_TOKEN` into the encrypted store
|
|
2083
|
+
* even when no manifest is stored yet. An existing manifest is always updated.
|
|
1929
2084
|
* @param {boolean} [options.apply=false] - Project the updated manifest into the cluster.
|
|
1930
2085
|
* @param {boolean} [options.dryRun=false] - Report the plan without contacting GitHub,
|
|
1931
2086
|
* prompting, or writing anything.
|
|
1932
|
-
* @returns {{
|
|
1933
|
-
*
|
|
2087
|
+
* @returns {{target: object, keys: Array<string>, targets: Array<string>,
|
|
2088
|
+
* written: Object<string, Array<string>>, unreachable: Array<string>,
|
|
2089
|
+
* failed: Object<string, Array<string>>, sources: Object<string, string>, manifest: string,
|
|
2090
|
+
* store: boolean}} Outcome.
|
|
1934
2091
|
* @memberof UnderpostSecret
|
|
1935
2092
|
*/
|
|
1936
|
-
|
|
2093
|
+
rotateActionsSecrets(options = {}) {
|
|
2094
|
+
const keys = [
|
|
2095
|
+
...new Set(
|
|
2096
|
+
`${options.keys ?? ''}`
|
|
2097
|
+
.split(/[|;\s]+/)
|
|
2098
|
+
.map((key) => key.trim().toUpperCase())
|
|
2099
|
+
.filter(Boolean),
|
|
2100
|
+
),
|
|
2101
|
+
];
|
|
2102
|
+
if (keys.length === 0) throw new Error('[secret] rotate needs at least one secret name in --args secret=');
|
|
2103
|
+
const invalid = keys.filter((key) => !/^(?!GITHUB_)[A-Z_][A-Z0-9_]*$/.test(key));
|
|
2104
|
+
if (invalid.length > 0)
|
|
2105
|
+
throw new Error(
|
|
2106
|
+
`[secret] ${invalid.join(', ')} cannot name a GitHub Actions secret: letters, digits and underscores ` +
|
|
2107
|
+
`only, and the GITHUB_ prefix is reserved by GitHub.`,
|
|
2108
|
+
);
|
|
1937
2109
|
const namespace = options.namespace || 'default';
|
|
1938
|
-
const
|
|
2110
|
+
const target = Underpost.secret.githubTargetFactory(options);
|
|
2111
|
+
const targets = Underpost.secret.actionsSecretTargets(options);
|
|
1939
2112
|
if (targets.length === 0)
|
|
1940
2113
|
throw new Error(
|
|
1941
|
-
`[secret] no repository resolved for ${
|
|
2114
|
+
`[secret] no repository resolved for ${keys.join(', ')}. Name the deploy with ` +
|
|
1942
2115
|
`--args deploy-id=<id>, or the repositories with --args "repos=owner/repo|owner/other".`,
|
|
1943
2116
|
);
|
|
1944
|
-
|
|
1945
|
-
|
|
2117
|
+
if (keys.length > 1 && (`${options.value ?? ''}`.trim() || stdinIsRedirected()))
|
|
2118
|
+
throw new Error(
|
|
2119
|
+
`[secret] --args value= and piped stdin carry one value; with ${keys.length} keys read the ` +
|
|
2120
|
+
`environment or the host configuration (--args source=host).`,
|
|
2121
|
+
);
|
|
2122
|
+
const hostValues = Underpost.host.read(options.env || 'production');
|
|
2123
|
+
const mirrorsToken = keys.includes(GIT_AUTH_TOKEN_KEY);
|
|
2124
|
+
const stored = mirrorsToken && Underpost.secret.has(GIT_AUTH_TOKEN_SECRET, namespace);
|
|
2125
|
+
const store = mirrorsToken && (stored || options.store === true || `${options.store}` === 'true');
|
|
1946
2126
|
const ghReady = Underpost.secret.hasBinary('gh');
|
|
2127
|
+
const ghEnv = Underpost.secret.ghEnvFactory(target);
|
|
1947
2128
|
// Advisory, not a gate: `gh auth status` also exits non-zero for a logged-in account whose
|
|
1948
2129
|
// token merely lacks an optional scope. Reachability of the targets is the real
|
|
1949
2130
|
// precondition, so this is captured to explain a failure rather than to cause one.
|
|
1950
2131
|
const ghAuth = ghReady
|
|
1951
|
-
? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true })
|
|
2132
|
+
? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true, env: ghEnv })
|
|
1952
2133
|
: null;
|
|
1953
2134
|
const ghAuthenticated = ghAuth?.code === 0;
|
|
1954
2135
|
const ghAuthOutput = `${ghAuth?.stdout ?? ''}`.trim() || '(no output)';
|
|
1955
2136
|
// gh prefers GH_TOKEN/GITHUB_TOKEN over the account `gh auth login` stored, and this engine's
|
|
1956
2137
|
// own host store exports GITHUB_TOKEN — so a stale one silently shadows a working login and
|
|
1957
|
-
// every probe fails against a credential the operator never chose.
|
|
1958
|
-
|
|
2138
|
+
// every probe fails against a credential the operator never chose. An explicit
|
|
2139
|
+
// GITHUB_SECRET_TOKEN is that choice, so nothing shadows it.
|
|
2140
|
+
const shadowing = target.token
|
|
2141
|
+
? []
|
|
2142
|
+
: ['GH_TOKEN', 'GITHUB_TOKEN'].filter((key) => `${process.env[key] ?? ''}`.trim());
|
|
2143
|
+
const sources = Object.fromEntries(
|
|
2144
|
+
keys.map((key) => [key, Underpost.secret.plannedSecretSource(key, options, hostValues)]),
|
|
2145
|
+
);
|
|
2146
|
+
// Rendered as lines rather than keyed by secret name: the log redactor blanks any field
|
|
2147
|
+
// whose name carries "token", which would hide the very labels the operator reads.
|
|
2148
|
+
const lines = (byKey) => keys.map((key) => `${key} <- ${[].concat(byKey[key] ?? []).join(', ')}`);
|
|
2149
|
+
const where = {
|
|
2150
|
+
type: target.type,
|
|
2151
|
+
owner: target.owner,
|
|
2152
|
+
level: target.level,
|
|
2153
|
+
via: target.token ? GITHUB_TARGET_ENV.token : 'gh auth login',
|
|
2154
|
+
};
|
|
1959
2155
|
|
|
1960
2156
|
if (options.dryRun) {
|
|
1961
2157
|
// Probing is a read, so the plan reports the targets that actually exist rather than the
|
|
1962
|
-
// ones the naming derived. Nothing is
|
|
1963
|
-
const probed = ghReady ? Underpost.secret.
|
|
1964
|
-
logger.info(`[dry-run]
|
|
2158
|
+
// ones the naming derived. Nothing is prompted for or written.
|
|
2159
|
+
const probed = ghReady ? Underpost.secret.probeActionsSecretTargets(targets, target) : null;
|
|
2160
|
+
logger.info(`[dry-run] Actions secret rotation plan`, {
|
|
2161
|
+
keys,
|
|
2162
|
+
target: where,
|
|
1965
2163
|
targets,
|
|
1966
|
-
|
|
2164
|
+
wouldWrite: probed ? probed.reachable : '(not probed)',
|
|
1967
2165
|
unreachable: probed ? probed.unreachable : '(not probed)',
|
|
1968
2166
|
namespace,
|
|
1969
|
-
from:
|
|
2167
|
+
from: lines(sources),
|
|
1970
2168
|
gh: ghReady ? (ghAuthenticated ? 'authenticated' : 'not authenticated') : 'missing',
|
|
1971
2169
|
manifest: store ? Underpost.secret.manifestPath(GIT_AUTH_TOKEN_SECRET, namespace) : '(store untouched)',
|
|
1972
2170
|
storedManifest: stored,
|
|
@@ -1975,113 +2173,108 @@ UNDERPOST_SOPS_ENV_EOF`,
|
|
|
1975
2173
|
else if (!ghAuthenticated)
|
|
1976
2174
|
logger.warn('gh is not authenticated, so no target could be probed. Run `gh auth login`.');
|
|
1977
2175
|
return {
|
|
2176
|
+
target,
|
|
2177
|
+
keys,
|
|
1978
2178
|
targets,
|
|
1979
|
-
|
|
2179
|
+
written: {},
|
|
1980
2180
|
unreachable: probed ? probed.unreachable : [],
|
|
1981
|
-
failed:
|
|
2181
|
+
failed: {},
|
|
2182
|
+
sources,
|
|
1982
2183
|
manifest: '',
|
|
1983
2184
|
store,
|
|
1984
|
-
tokenSource: '',
|
|
1985
2185
|
};
|
|
1986
2186
|
}
|
|
1987
2187
|
|
|
1988
2188
|
if (!ghReady)
|
|
1989
2189
|
throw new Error(
|
|
1990
2190
|
`gh not found in PATH. Install the GitHub CLI (https://cli.github.com), then authenticate it with ` +
|
|
1991
|
-
`\`gh auth login\` before rotating ${
|
|
2191
|
+
`\`gh auth login\` or set ${GITHUB_TARGET_ENV.token} before rotating ${keys.join(', ')}.`,
|
|
1992
2192
|
);
|
|
1993
2193
|
if (!ghAuthenticated)
|
|
1994
2194
|
logger.warn(`\`gh auth status\` exited non-zero; continuing if the targets are reachable.`, {
|
|
1995
2195
|
status: ghAuthOutput,
|
|
1996
2196
|
});
|
|
1997
2197
|
|
|
1998
|
-
// Probed before
|
|
2198
|
+
// Probed before any value is staged: nothing can be written to an unreachable set, and
|
|
1999
2199
|
// prompting for a credential that has nowhere to go wastes the operator's paste.
|
|
2000
|
-
const probed = Underpost.secret.
|
|
2200
|
+
const probed = Underpost.secret.probeActionsSecretTargets(targets, target);
|
|
2001
2201
|
if (probed.reachable.length === 0)
|
|
2002
2202
|
throw new Error(
|
|
2003
2203
|
`None of the ${targets.length} target(s) is reachable with the current gh credential, so ` +
|
|
2004
|
-
`${
|
|
2005
|
-
`needs the \`repo\` scope and admin on each repository
|
|
2204
|
+
`${keys.join(', ')} was not rotated and nothing was written. Writing an Actions secret ` +
|
|
2205
|
+
`needs the \`repo\` scope and admin on each repository` +
|
|
2206
|
+
`${target.level === 'org' ? ', and \`admin:org\` for an organization secret' : ''}.\n` +
|
|
2006
2207
|
`Targets: ${targets.join(', ')}\n` +
|
|
2007
2208
|
(shadowing.length
|
|
2008
2209
|
? `${shadowing.join(' and ')} is set here, and gh uses it in preference to the account ` +
|
|
2009
2210
|
`\`gh auth login\` stored. If gh calls it invalid below, run \`unset ` +
|
|
2010
|
-
`${shadowing.join(' ')}
|
|
2211
|
+
`${shadowing.join(' ')}\`, or set ${GITHUB_TARGET_ENV.token}, and try again.\n`
|
|
2011
2212
|
: '') +
|
|
2012
2213
|
`\`gh auth status\` reports:\n${ghAuthOutput}`,
|
|
2013
2214
|
);
|
|
2014
2215
|
|
|
2015
|
-
stageDirSync(
|
|
2016
|
-
const
|
|
2017
|
-
const
|
|
2216
|
+
stageDirSync(ACTIONS_SECRET_STAGE_DIR);
|
|
2217
|
+
const written = {};
|
|
2218
|
+
const failed = {};
|
|
2018
2219
|
const unreachable = probed.unreachable;
|
|
2019
|
-
const failed = [];
|
|
2020
2220
|
let manifest = '';
|
|
2021
|
-
let tokenSource = '';
|
|
2022
2221
|
try {
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
);
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2222
|
+
for (const key of keys) {
|
|
2223
|
+
const stagePath = `${ACTIONS_SECRET_STAGE_DIR}/${key}`;
|
|
2224
|
+
const staged = Underpost.secret.stageActionsSecret(stagePath, key, options, hostValues);
|
|
2225
|
+
sources[key] = staged.source;
|
|
2226
|
+
logger.info(`Rotating ${key}`, { targets: probed.reachable, target: where, from: staged.source });
|
|
2227
|
+
const outcome = Underpost.secret.writeActionsSecret({ key, stagePath, target, repos: probed.reachable });
|
|
2228
|
+
written[key] = outcome.written;
|
|
2229
|
+
failed[key] = outcome.failed;
|
|
2230
|
+
|
|
2231
|
+
if (key === GIT_AUTH_TOKEN_KEY && outcome.written.length > 0) {
|
|
2232
|
+
if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.value, namespace);
|
|
2233
|
+
else
|
|
2234
|
+
logger.info(
|
|
2235
|
+
`No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
|
|
2236
|
+
`Pass --args store=true to mirror this token into it.`,
|
|
2237
|
+
);
|
|
2238
|
+
if (manifest && (options.apply === true || `${options.apply}` === 'true'))
|
|
2239
|
+
Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
|
|
2039
2240
|
}
|
|
2040
2241
|
}
|
|
2041
|
-
|
|
2042
|
-
if (rotated.length === 0)
|
|
2043
|
-
throw new Error(
|
|
2044
|
-
`${GIT_AUTH_TOKEN_KEY} could not be written to any of the ${probed.reachable.length} reachable ` +
|
|
2045
|
-
`target(s): ${probed.reachable.join(', ')}. The encrypted store was left untouched, so it still ` +
|
|
2046
|
-
`records the credential GitHub is running on.`,
|
|
2047
|
-
);
|
|
2048
|
-
|
|
2049
|
-
if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.token, namespace);
|
|
2050
|
-
else
|
|
2051
|
-
logger.info(
|
|
2052
|
-
`No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
|
|
2053
|
-
`Pass --args store=true to mirror this token into it.`,
|
|
2054
|
-
);
|
|
2055
|
-
|
|
2056
|
-
if (manifest && (options.apply === true || `${options.apply}` === 'true'))
|
|
2057
|
-
Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
|
|
2058
2242
|
} finally {
|
|
2059
|
-
shellExec(`shred -u "${
|
|
2243
|
+
shellExec(`shred -u "${ACTIONS_SECRET_STAGE_DIR}"/* 2>/dev/null || rm -f "${ACTIONS_SECRET_STAGE_DIR}"/*`, {
|
|
2060
2244
|
silentOnError: true,
|
|
2061
2245
|
silent: true,
|
|
2062
2246
|
disableLog: true,
|
|
2063
2247
|
});
|
|
2064
|
-
fs.removeSync(
|
|
2248
|
+
fs.removeSync(ACTIONS_SECRET_STAGE_DIR);
|
|
2065
2249
|
}
|
|
2066
2250
|
|
|
2067
|
-
const report = { targets,
|
|
2068
|
-
|
|
2251
|
+
const report = { target, keys, targets, written, unreachable, failed, sources, manifest, store };
|
|
2252
|
+
const unwritten = keys.filter((key) => written[key].length === 0);
|
|
2253
|
+
if (unwritten.length > 0)
|
|
2254
|
+
throw new Error(
|
|
2255
|
+
`${unwritten.join(', ')} could not be written to any of the ${probed.reachable.length} reachable ` +
|
|
2256
|
+
`target(s): ${probed.reachable.join(', ')}.` +
|
|
2257
|
+
(mirrorsToken
|
|
2258
|
+
? ` The encrypted store was left untouched, so it still records the credential GitHub is running on.`
|
|
2259
|
+
: ''),
|
|
2260
|
+
);
|
|
2261
|
+
const split = keys.filter((key) => failed[key].length > 0);
|
|
2262
|
+
if (split.length > 0)
|
|
2069
2263
|
throw new Error(
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
`
|
|
2264
|
+
split
|
|
2265
|
+
.map((key) => `${key} was rotated on ${written[key].join(', ')} but failed on ${failed[key].join(', ')}.`)
|
|
2266
|
+
.join(' ') +
|
|
2267
|
+
` Those repositories still hold the previous value, so the fleet is split across two credentials. ` +
|
|
2268
|
+
`Re-run once resolved: every target is written again, so the fleet converges on one value either way.`,
|
|
2074
2269
|
);
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
logger.info(`${GIT_AUTH_TOKEN_KEY} rotation complete`, {
|
|
2270
|
+
logger.info(`Actions secret rotation complete`, {
|
|
2271
|
+
target: where,
|
|
2078
2272
|
targets,
|
|
2079
|
-
|
|
2273
|
+
written: lines(written),
|
|
2080
2274
|
unreachable,
|
|
2081
|
-
failed,
|
|
2082
2275
|
manifest,
|
|
2083
2276
|
store,
|
|
2084
|
-
from:
|
|
2277
|
+
from: lines(sources),
|
|
2085
2278
|
});
|
|
2086
2279
|
return report;
|
|
2087
2280
|
},
|