@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
|
@@ -205,9 +205,24 @@ describe('sops encrypted secret store', () => {
|
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
describe('
|
|
208
|
+
describe('GitHub Actions secret rotation', () => {
|
|
209
209
|
const secretsSource = fs.readFileSync(new URL('../../../../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
210
|
+
const methodBody = (signature) => {
|
|
211
|
+
const start = secretsSource.indexOf(signature);
|
|
212
|
+
expect(start, `${signature} not found`).to.be.greaterThan(-1);
|
|
213
|
+
return secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
214
|
+
};
|
|
210
215
|
const saved = {};
|
|
216
|
+
const TARGET_KEYS = [
|
|
217
|
+
'ENGINE_SRC_REPO',
|
|
218
|
+
'ENGINE_SRC_PRIVATE_REPO',
|
|
219
|
+
'GITHUB_USERNAME',
|
|
220
|
+
'GITHUB_TARGET_TYPE',
|
|
221
|
+
'GITHUB_ORG_NAME',
|
|
222
|
+
'GITHUB_SECRET_TOKEN',
|
|
223
|
+
'GIT_AUTH_TOKEN',
|
|
224
|
+
'NPM_TOKEN',
|
|
225
|
+
];
|
|
211
226
|
|
|
212
227
|
// `instanceRepos` reads `./engine-private/conf/<deployId>/conf.instances.json`, and
|
|
213
228
|
// engine-private is a private repository absent from a CI checkout. A real deploy's
|
|
@@ -232,9 +247,8 @@ describe('sops encrypted secret store', () => {
|
|
|
232
247
|
});
|
|
233
248
|
|
|
234
249
|
beforeEach(() => {
|
|
235
|
-
for (const key of [
|
|
236
|
-
|
|
237
|
-
for (const key of Object.keys(saved)) delete process.env[key];
|
|
250
|
+
for (const key of TARGET_KEYS) saved[key] = process.env[key];
|
|
251
|
+
for (const key of TARGET_KEYS) delete process.env[key];
|
|
238
252
|
});
|
|
239
253
|
|
|
240
254
|
afterEach(() => {
|
|
@@ -243,11 +257,57 @@ describe('sops encrypted secret store', () => {
|
|
|
243
257
|
else process.env[key] = value;
|
|
244
258
|
});
|
|
245
259
|
|
|
260
|
+
describe('target resolution', () => {
|
|
261
|
+
it('defaults to the user account, at the repository level, under the gh login', () => {
|
|
262
|
+
expect(sops().githubTargetFactory({})).to.deep.equal({
|
|
263
|
+
type: 'user',
|
|
264
|
+
owner: 'underpostnet',
|
|
265
|
+
level: 'repo',
|
|
266
|
+
token: '',
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('takes the user owner from ENGINE_SRC_REPO, then GITHUB_USERNAME', () => {
|
|
271
|
+
process.env.GITHUB_USERNAME = 'someone';
|
|
272
|
+
expect(sops().githubTargetFactory({}).owner).to.equal('someone');
|
|
273
|
+
process.env.ENGINE_SRC_REPO = 'acme/engine-lampp';
|
|
274
|
+
expect(sops().githubTargetFactory({}).owner).to.equal('acme');
|
|
275
|
+
expect(sops().githubTargetFactory({ owner: 'explicit' }).owner).to.equal('explicit');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('addresses an organization through GITHUB_TARGET_TYPE and GITHUB_ORG_NAME', () => {
|
|
279
|
+
process.env.GITHUB_TARGET_TYPE = 'org';
|
|
280
|
+
process.env.GITHUB_ORG_NAME = 'acme-org';
|
|
281
|
+
process.env.GITHUB_SECRET_TOKEN = 'github_pat_organizationWriter0000000000';
|
|
282
|
+
expect(sops().githubTargetFactory({})).to.deep.equal({
|
|
283
|
+
type: 'org',
|
|
284
|
+
owner: 'acme-org',
|
|
285
|
+
level: 'repo',
|
|
286
|
+
token: 'github_pat_organizationWriter0000000000',
|
|
287
|
+
});
|
|
288
|
+
expect(sops().githubTargetFactory({ level: 'org' }).level).to.equal('org');
|
|
289
|
+
// --args wins over the environment, so one run can address the other account type.
|
|
290
|
+
expect(sops().githubTargetFactory({ target: 'user' }).owner).to.equal('underpostnet');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('refuses an organization target without an organization, and an organization level without an organization target', () => {
|
|
294
|
+
expect(() => sops().githubTargetFactory({ target: 'org' })).to.throw(/GITHUB_ORG_NAME/);
|
|
295
|
+
expect(() => sops().githubTargetFactory({ level: 'org' })).to.throw(/target=org/);
|
|
296
|
+
expect(() => sops().githubTargetFactory({ target: 'team' })).to.throw(/user or org/);
|
|
297
|
+
expect(() => sops().githubTargetFactory({ level: 'enterprise' })).to.throw(/repo or org/);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('hands GITHUB_SECRET_TOKEN to gh as GH_TOKEN and otherwise leaves the login alone', () => {
|
|
301
|
+
expect(sops().ghEnvFactory({ token: 'github_pat_x' })).to.include({ GH_TOKEN: 'github_pat_x' });
|
|
302
|
+
expect(sops().ghEnvFactory({ token: '' })).to.equal(process.env);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
246
306
|
it('targets the private conf repo, both engine sources and the package mirror of a deploy', () => {
|
|
247
307
|
// Same naming `deploy_id_from_repo` resolves in deploy/lib/host.sh, reached through the
|
|
248
308
|
// repository domain rather than re-derived here. dd-core declares no instances, so this is
|
|
249
309
|
// the naming-derived set on its own.
|
|
250
|
-
expect(sops().
|
|
310
|
+
expect(sops().actionsSecretTargets({ deployId: 'dd-core', owner: 'acme' })).to.deep.equal([
|
|
251
311
|
'acme/engine-core-private',
|
|
252
312
|
'acme/engine-core',
|
|
253
313
|
'acme/engine-test-core',
|
|
@@ -257,26 +317,45 @@ describe('sops encrypted secret store', () => {
|
|
|
257
317
|
|
|
258
318
|
it('keeps an instance repository under the owner it declares, not the derived one', () => {
|
|
259
319
|
// metadata.repository is an explicit slug, so it outranks the --args owner used for naming.
|
|
260
|
-
expect(sops().
|
|
320
|
+
expect(sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY, owner: 'acme' })).to.include(
|
|
261
321
|
FIXTURE_INSTANCE_REPOS[0],
|
|
262
322
|
);
|
|
263
323
|
});
|
|
264
324
|
|
|
325
|
+
it('re-owns every derived and declared repository under an organization target', () => {
|
|
326
|
+
process.env.ENGINE_SRC_REPO = 'underpostnet/engine-fixture-sops';
|
|
327
|
+
process.env.ENGINE_SRC_PRIVATE_REPO = 'underpostnet/engine-private';
|
|
328
|
+
const targets = sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY, target: 'org', owner: 'acme-org' });
|
|
329
|
+
expect(targets).to.deep.equal([
|
|
330
|
+
'acme-org/engine-fixture-sops-private',
|
|
331
|
+
'acme-org/engine-fixture-sops',
|
|
332
|
+
'acme-org/engine-test-fixture-sops',
|
|
333
|
+
'acme-org/engine-ghpkg-fixture-sops',
|
|
334
|
+
'acme-org/fixture-sops-server',
|
|
335
|
+
'acme-org/fixture-sops-client',
|
|
336
|
+
'acme-org/engine-private',
|
|
337
|
+
]);
|
|
338
|
+
// An explicit extra target is taken as written.
|
|
339
|
+
expect(sops().actionsSecretTargets({ target: 'org', owner: 'acme-org', repos: 'other/one' })).to.include(
|
|
340
|
+
'other/one',
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
265
344
|
it('includes the ghpkg package mirror the engine repository publishes into', () => {
|
|
266
345
|
// .github/workflows/ghpkg.ci.yml builds engine-ghpkg-<conf_id>, whose own workflows read
|
|
267
|
-
// the same
|
|
268
|
-
expect(sops().
|
|
346
|
+
// the same secrets; the monorepo has no ghpkg of its own.
|
|
347
|
+
expect(sops().actionsSecretTargets({ deployId: 'dd-cyberia', owner: 'acme' })).to.include(
|
|
269
348
|
'acme/engine-ghpkg-cyberia',
|
|
270
349
|
);
|
|
271
|
-
expect(sops().
|
|
350
|
+
expect(sops().actionsSecretTargets({ owner: 'acme' })).to.not.include('acme/engine-ghpkg');
|
|
272
351
|
});
|
|
273
352
|
|
|
274
353
|
it("includes every repository the deploy's instances are built from", () => {
|
|
275
354
|
// dd-cyberia builds from cyberia-server and cyberia-client; rotating only the engine repos
|
|
276
|
-
// would leave half the deploy on the previous
|
|
355
|
+
// would leave half the deploy on the previous value.
|
|
277
356
|
const declared = Underpost.repo.instanceRepos(FIXTURE_DEPLOY);
|
|
278
357
|
expect(declared, `${FIXTURE_DEPLOY} declares no instance repositories`).to.deep.equal(FIXTURE_INSTANCE_REPOS);
|
|
279
|
-
expect(sops().
|
|
358
|
+
expect(sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY })).to.include.members(declared);
|
|
280
359
|
});
|
|
281
360
|
|
|
282
361
|
it('contributes nothing for a deploy that declares no instances', () => {
|
|
@@ -285,11 +364,21 @@ describe('sops encrypted secret store', () => {
|
|
|
285
364
|
});
|
|
286
365
|
|
|
287
366
|
it('resolves the `template` meta id to the template lineage, not through conf-id naming', () => {
|
|
288
|
-
expect(sops().
|
|
367
|
+
expect(sops().actionsSecretTargets({ deployId: 'template', owner: 'acme' })).to.deep.equal([
|
|
289
368
|
'acme/pwa-microservices-template',
|
|
290
369
|
'acme/pwa-microservices-template-ghpkg',
|
|
291
370
|
'acme/engine',
|
|
292
371
|
]);
|
|
372
|
+
// In a list too, so one rotation covers a product and the template it was built from.
|
|
373
|
+
expect(sops().actionsSecretTargets({ deployId: 'template|dd-core', owner: 'acme' })).to.deep.equal([
|
|
374
|
+
'acme/pwa-microservices-template',
|
|
375
|
+
'acme/pwa-microservices-template-ghpkg',
|
|
376
|
+
'acme/engine',
|
|
377
|
+
'acme/engine-core-private',
|
|
378
|
+
'acme/engine-core',
|
|
379
|
+
'acme/engine-test-core',
|
|
380
|
+
'acme/engine-ghpkg-core',
|
|
381
|
+
]);
|
|
293
382
|
});
|
|
294
383
|
|
|
295
384
|
it('resolves the same targets from every reference that names the deploy', () => {
|
|
@@ -300,17 +389,17 @@ describe('sops encrypted secret store', () => {
|
|
|
300
389
|
'acme/engine-ghpkg-lampp',
|
|
301
390
|
];
|
|
302
391
|
for (const reference of ['dd-lampp', 'engine-lampp', 'engine-test-lampp', 'engine-lampp-private'])
|
|
303
|
-
expect(sops().
|
|
392
|
+
expect(sops().actionsSecretTargets({ deployId: reference, owner: 'acme' }), reference).to.deep.equal(expected);
|
|
304
393
|
});
|
|
305
394
|
|
|
306
395
|
it('falls back to the monorepo pair, deduplicated, when no deploy is named', () => {
|
|
307
|
-
expect(sops().
|
|
396
|
+
expect(sops().actionsSecretTargets({ owner: 'acme' })).to.deep.equal(['acme/engine-private', 'acme/engine']);
|
|
308
397
|
});
|
|
309
398
|
|
|
310
399
|
it('takes the deploy and owner from the deploy environment when none is passed', () => {
|
|
311
400
|
process.env.ENGINE_SRC_REPO = 'acme/engine-test-fixture-sops';
|
|
312
401
|
process.env.ENGINE_SRC_PRIVATE_REPO = 'acme/engine-private';
|
|
313
|
-
expect(sops().
|
|
402
|
+
expect(sops().actionsSecretTargets({})).to.deep.equal([
|
|
314
403
|
'acme/engine-fixture-sops-private',
|
|
315
404
|
'acme/engine-fixture-sops',
|
|
316
405
|
'acme/engine-test-fixture-sops',
|
|
@@ -324,7 +413,7 @@ describe('sops encrypted secret store', () => {
|
|
|
324
413
|
// `dd` is the meta id every runner reads as "all of dd.routes"; the rotation must cover the
|
|
325
414
|
// same fleet the cluster deploys, resolved through the one reader rather than a second parse.
|
|
326
415
|
const routed = resolveDeployList('dd');
|
|
327
|
-
const targets = sops().
|
|
416
|
+
const targets = sops().actionsSecretTargets({ deployId: 'dd', owner: 'acme' });
|
|
328
417
|
expect(routed.length, 'no deploy routes to fan out over').to.be.greaterThan(0);
|
|
329
418
|
expect(targets).to.have.lengthOf(new Set(targets).size);
|
|
330
419
|
for (const deployId of routed) {
|
|
@@ -338,7 +427,7 @@ describe('sops encrypted secret store', () => {
|
|
|
338
427
|
});
|
|
339
428
|
|
|
340
429
|
it('accepts an explicit multi-deploy list, in route-table order', () => {
|
|
341
|
-
expect(sops().
|
|
430
|
+
expect(sops().actionsSecretTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' })).to.deep.equal([
|
|
342
431
|
'acme/engine-fixture-sops-private',
|
|
343
432
|
'acme/engine-fixture-sops',
|
|
344
433
|
'acme/engine-test-fixture-sops',
|
|
@@ -353,61 +442,79 @@ describe('sops encrypted secret store', () => {
|
|
|
353
442
|
|
|
354
443
|
it('unions repositories two deploys share instead of rotating them twice', () => {
|
|
355
444
|
process.env.ENGINE_SRC_PRIVATE_REPO = 'acme/engine-private';
|
|
356
|
-
const targets = sops().
|
|
445
|
+
const targets = sops().actionsSecretTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' });
|
|
357
446
|
expect(targets.filter((slug) => slug === 'acme/engine-private')).to.have.lengthOf(1);
|
|
358
447
|
});
|
|
359
448
|
|
|
360
449
|
it('separates extra targets on characters --args does not split on', () => {
|
|
361
|
-
const targets = sops().
|
|
450
|
+
const targets = sops().actionsSecretTargets({ owner: 'acme', repos: 'other/one|other/two;other/three' });
|
|
362
451
|
expect(targets).to.include.members(['other/one', 'other/two', 'other/three']);
|
|
363
452
|
});
|
|
364
453
|
|
|
365
454
|
it('drops an unresolvable extra target instead of failing the whole rotation', () => {
|
|
366
|
-
const targets = sops().
|
|
455
|
+
const targets = sops().actionsSecretTargets({ owner: 'acme', repos: 'not-a-slug|other/one' });
|
|
367
456
|
expect(targets).to.include('other/one');
|
|
368
457
|
expect(targets).to.not.include('not-a-slug');
|
|
369
458
|
});
|
|
370
459
|
|
|
371
460
|
it('reports the plan without contacting GitHub or writing anything on a dry run', () => {
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
461
|
+
// The reported fields do not depend on gh reachability, so probing is stubbed to keep this
|
|
462
|
+
// test from reaching the real GitHub API through whatever `gh` session happens to be
|
|
463
|
+
// authenticated on this machine.
|
|
375
464
|
const hasBinary = vi.spyOn(sops(), 'hasBinary').mockReturnValue(false);
|
|
465
|
+
const read = vi.spyOn(Underpost.host, 'read').mockReturnValue({ NPM_TOKEN: 'npm_fixture' });
|
|
376
466
|
try {
|
|
377
|
-
const report = sops().
|
|
467
|
+
const report = sops().rotateActionsSecrets({
|
|
468
|
+
keys: 'GIT_AUTH_TOKEN|NPM_TOKEN',
|
|
469
|
+
deployId: FIXTURE_DEPLOY,
|
|
470
|
+
owner: 'acme',
|
|
471
|
+
dryRun: true,
|
|
472
|
+
});
|
|
473
|
+
expect(report.keys).to.deep.equal(['GIT_AUTH_TOKEN', 'NPM_TOKEN']);
|
|
378
474
|
expect(report.targets).to.have.lengthOf(4 + FIXTURE_INSTANCE_REPOS.length);
|
|
379
|
-
expect(report.
|
|
475
|
+
expect(report.written).to.deep.equal({});
|
|
380
476
|
expect(report.manifest).to.equal('');
|
|
381
|
-
expect(report.
|
|
477
|
+
expect(report.sources.NPM_TOKEN).to.equal('host configuration');
|
|
478
|
+
expect(report.target).to.include({ type: 'user', owner: 'acme', level: 'repo' });
|
|
382
479
|
} finally {
|
|
383
480
|
hasBinary.mockRestore();
|
|
481
|
+
read.mockRestore();
|
|
384
482
|
}
|
|
385
483
|
});
|
|
386
484
|
|
|
387
|
-
it('
|
|
388
|
-
|
|
389
|
-
|
|
485
|
+
it('rejects names GitHub cannot hold, and one value spread over several keys', () => {
|
|
486
|
+
expect(() => sops().rotateActionsSecrets({ keys: 'GITHUB_MINE', owner: 'acme' })).to.throw(/GITHUB_ prefix/);
|
|
487
|
+
expect(() => sops().rotateActionsSecrets({ keys: 'bad-name', owner: 'acme' })).to.throw(/letters, digits/);
|
|
488
|
+
expect(() => sops().rotateActionsSecrets({ keys: '', owner: 'acme' })).to.throw(/at least one secret name/);
|
|
489
|
+
expect(() =>
|
|
490
|
+
sops().rotateActionsSecrets({ keys: 'NPM_TOKEN|NPM_USER', value: 'one', owner: 'acme', dryRun: true }),
|
|
491
|
+
).to.throw(/one value/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('takes a piped value, ahead of an inherited environment holding the outgoing one', () => {
|
|
495
|
+
const body = methodBody(' stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {');
|
|
390
496
|
expect(body).to.include('stdinIsRedirected()');
|
|
391
497
|
expect(body).to.include("fs.readFileSync(0, 'utf8')");
|
|
392
498
|
expect(body.indexOf("source = 'piped stdin'")).to.be.lessThan(body.indexOf('} else if (inherited) {'));
|
|
499
|
+
// The durable host configuration is the last implicit source before a prompt.
|
|
500
|
+
expect(body.indexOf('} else if (inherited) {')).to.be.lessThan(body.indexOf('} else if (hostValue) {'));
|
|
393
501
|
});
|
|
394
502
|
|
|
395
503
|
it('reads stdin only when it is piped or redirected, never a terminal or /dev/null', () => {
|
|
396
504
|
// fstat rather than isTTY: `< /dev/null` is not a TTY either, and reading it would strand
|
|
397
|
-
// the rotation on an empty
|
|
505
|
+
// the rotation on an empty value instead of falling through to the next source.
|
|
398
506
|
expect(secretsSource).to.include('stat.isFIFO() || stat.isFile()');
|
|
399
507
|
});
|
|
400
508
|
|
|
401
509
|
it('names piped stdin as the planned source without consuming it', () => {
|
|
402
|
-
expect(sops().
|
|
403
|
-
expect(sops().
|
|
510
|
+
expect(sops().plannedSecretSource('NPM_TOKEN', { value: 'npm_explicit' })).to.equal('--args value');
|
|
511
|
+
expect(sops().plannedSecretSource('NPM_TOKEN', {})).to.be.a('string').and.not.equal('');
|
|
404
512
|
});
|
|
405
513
|
|
|
406
514
|
it('treats `gh auth status` as advisory, gating on target reachability instead', () => {
|
|
407
515
|
// A logged-in account whose token merely lacks an optional scope still exits non-zero here,
|
|
408
516
|
// so making it the gate blocks a rotation that would have worked.
|
|
409
|
-
const
|
|
410
|
-
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
517
|
+
const body = methodBody(' rotateActionsSecrets(options = {}) {');
|
|
411
518
|
expect(body).to.include('if (!ghAuthenticated)');
|
|
412
519
|
expect(body).to.not.match(/if \(!ghAuthenticated\)\s*\n?\s*throw/);
|
|
413
520
|
expect(body).to.include('probed.reachable.length === 0');
|
|
@@ -421,46 +528,58 @@ describe('sops encrypted secret store', () => {
|
|
|
421
528
|
it('names a shadowing GH_TOKEN/GITHUB_TOKEN as the likely cause of a dead credential', () => {
|
|
422
529
|
// gh prefers these over the stored login, and `host load` exports GITHUB_TOKEN on every
|
|
423
530
|
// engine node — so a stale one turns a working `gh auth login` into 15 unreachable targets.
|
|
531
|
+
// An explicit GITHUB_SECRET_TOKEN is the chosen credential, so nothing shadows it.
|
|
424
532
|
expect(secretsSource).to.include("['GH_TOKEN', 'GITHUB_TOKEN'].filter(");
|
|
425
533
|
expect(secretsSource).to.include('is set here, and gh uses it in preference to the account');
|
|
534
|
+
expect(methodBody(' rotateActionsSecrets(options = {}) {')).to.include('target.token\n ? []');
|
|
426
535
|
});
|
|
427
536
|
|
|
428
|
-
it('probes before staging
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
body.indexOf('stageGitAuthToken(stagePath, options)'),
|
|
537
|
+
it('probes before staging any value, so an unreachable set never prompts for one', () => {
|
|
538
|
+
const body = methodBody(' rotateActionsSecrets(options = {}) {');
|
|
539
|
+
expect(body.indexOf('probeActionsSecretTargets(targets, target)')).to.be.lessThan(
|
|
540
|
+
body.indexOf('stageActionsSecret(stagePath, key, options, hostValues)'),
|
|
433
541
|
);
|
|
434
542
|
});
|
|
435
543
|
|
|
436
|
-
it('hands the
|
|
437
|
-
// A
|
|
438
|
-
expect(secretsSource).to.include('gh secret set ${
|
|
439
|
-
expect(secretsSource).to.not.include('gh secret set ${
|
|
544
|
+
it('hands the value to gh on stdin, never as a command argument', () => {
|
|
545
|
+
// A value in the command string is a value in the process table and in the command log.
|
|
546
|
+
expect(secretsSource).to.include('gh secret set ${key} ${scope} < "${stagePath}"');
|
|
547
|
+
expect(secretsSource).to.not.include('gh secret set ${key} --body');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('writes one organization secret for the organization repositories and repository secrets elsewhere', () => {
|
|
551
|
+
const body = methodBody(' writeActionsSecret({ key, stagePath, target, repos }) {');
|
|
552
|
+
expect(body).to.include('--org "${target.owner}" --visibility selected --repos');
|
|
553
|
+
expect(body).to.include("repos.filter((slug) => slug.split('/')[0] === target.owner)");
|
|
554
|
+
expect(body).to.include('--repo "${repo}"');
|
|
440
555
|
});
|
|
441
556
|
|
|
442
557
|
it('runs the gh write under pipefail with the command kept out of the log', () => {
|
|
443
|
-
const
|
|
444
|
-
expect(start, 'rotateGitAuthToken() not found').to.be.greaterThan(-1);
|
|
445
|
-
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
558
|
+
const body = methodBody(' writeActionsSecret({ key, stagePath, target, repos }) {');
|
|
446
559
|
expect(body).to.include("bash -c 'set -o pipefail; gh secret set");
|
|
447
560
|
expect(body).to.include('disableLog: true');
|
|
448
|
-
expect(
|
|
561
|
+
expect(methodBody(' rotateActionsSecrets(options = {}) {')).to.include(
|
|
562
|
+
'shred -u "${ACTIONS_SECRET_STAGE_DIR}"/*',
|
|
563
|
+
);
|
|
449
564
|
});
|
|
450
565
|
|
|
451
|
-
it('stages
|
|
452
|
-
expect(secretsSource).to.include("const
|
|
453
|
-
const
|
|
454
|
-
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
566
|
+
it('stages every value on tmpfs at mode 600 rather than through a shell heredoc', () => {
|
|
567
|
+
expect(secretsSource).to.include("const ACTIONS_SECRET_STAGE_DIR = '/dev/shm/underpost-actions-secret';");
|
|
568
|
+
const body = methodBody(' stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {');
|
|
455
569
|
expect(body).to.include('writeStageFileSync(stagePath');
|
|
456
570
|
// GITHUB_TOKEN is what gh authenticates with — on a rotation that is the outgoing token.
|
|
457
571
|
expect(body).to.not.include('process.env.GITHUB_TOKEN');
|
|
458
572
|
});
|
|
459
573
|
|
|
574
|
+
it('validates the token shape for GIT_AUTH_TOKEN only', () => {
|
|
575
|
+
expect(secretsSource).to.include('ACTIONS_SECRET_POLICIES[key]?.validate?.(value)');
|
|
576
|
+
expect(secretsSource).to.match(
|
|
577
|
+
/const ACTIONS_SECRET_POLICIES = Object\.freeze\(\{\n \[GIT_AUTH_TOKEN_KEY\]: \{/,
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
|
|
460
581
|
it('replaces the stored manifest through the validating atomic encrypt path', () => {
|
|
461
|
-
const
|
|
462
|
-
expect(start, 'writeGitAuthTokenManifest() not found').to.be.greaterThan(-1);
|
|
463
|
-
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
582
|
+
const body = methodBody(" writeGitAuthTokenManifest(token, namespace = 'default') {");
|
|
464
583
|
expect(body).to.include('Underpost.secret.encrypt(plaintextPath, namespace, { force: true })');
|
|
465
584
|
});
|
|
466
585
|
|
|
@@ -469,15 +588,12 @@ describe('sops encrypted secret store', () => {
|
|
|
469
588
|
expect(EXECUTION_PROFILES.HERMETIC_BUILD.permits).to.not.include('net');
|
|
470
589
|
});
|
|
471
590
|
|
|
472
|
-
it('rejects a
|
|
473
|
-
expect(() => sops().rotate({ args: { secret: 'AWS_SECRET_ACCESS_KEY' } })).to.throw(
|
|
474
|
-
/GIT_AUTH_TOKEN is the only one it rotates/,
|
|
475
|
-
);
|
|
591
|
+
it('rejects a rotation with no target', () => {
|
|
476
592
|
expect(() => sops().rotate({ args: {} })).to.throw(/rotate requires a target/);
|
|
477
593
|
});
|
|
478
594
|
});
|
|
479
595
|
|
|
480
|
-
describe('
|
|
596
|
+
describe('Actions secret value sources', () => {
|
|
481
597
|
const saved = {};
|
|
482
598
|
|
|
483
599
|
beforeEach(() => {
|
|
@@ -490,31 +606,45 @@ describe('sops encrypted secret store', () => {
|
|
|
490
606
|
else process.env.GIT_AUTH_TOKEN = saved.env;
|
|
491
607
|
});
|
|
492
608
|
|
|
493
|
-
it('prefers an explicit
|
|
609
|
+
it('prefers an explicit value over every implicit source', () => {
|
|
494
610
|
process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
|
|
495
|
-
expect(sops().
|
|
611
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { value: 'ghp_explicit' })).to.equal('--args value');
|
|
496
612
|
});
|
|
497
613
|
|
|
498
|
-
it('falls back through the environment to a prompt', () => {
|
|
614
|
+
it('falls back through the environment and the host configuration to a prompt', () => {
|
|
499
615
|
process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
|
|
500
|
-
expect(sops().
|
|
616
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {})).to.equal('GIT_AUTH_TOKEN environment');
|
|
501
617
|
delete process.env.GIT_AUTH_TOKEN;
|
|
502
|
-
expect(sops().
|
|
618
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {}, { GIT_AUTH_TOKEN: 'ghp_host' })).to.equal(
|
|
619
|
+
'host configuration',
|
|
620
|
+
);
|
|
621
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {})).to.match(/interactive prompt|unavailable/);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('pins the host configuration with source=host, even when the environment holds a value', () => {
|
|
625
|
+
process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
|
|
626
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { source: 'host' }, { GIT_AUTH_TOKEN: 'x' })).to.equal(
|
|
627
|
+
'host configuration',
|
|
628
|
+
);
|
|
629
|
+
expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { source: 'host' }, {})).to.match(/not in the host/);
|
|
503
630
|
});
|
|
504
631
|
|
|
505
632
|
it('does not prompt or write on a dry run', () => {
|
|
506
|
-
// Report fields under dryRun are independent of gh reachability (see rotateGitAuthToken's
|
|
507
|
-
// early return), so probing is stubbed out here rather than left to whatever `gh` happens to
|
|
508
|
-
// be installed and authenticated as on the machine running the suite — otherwise this reaches
|
|
509
|
-
// the real GitHub API for a probe of the fictitious 'acme/dd-cyberia' target.
|
|
510
633
|
const hasBinary = vi.spyOn(sops(), 'hasBinary').mockReturnValue(false);
|
|
634
|
+
const read = vi.spyOn(Underpost.host, 'read').mockReturnValue({});
|
|
511
635
|
try {
|
|
512
|
-
const report = sops().
|
|
513
|
-
|
|
514
|
-
|
|
636
|
+
const report = sops().rotateActionsSecrets({
|
|
637
|
+
keys: 'GIT_AUTH_TOKEN',
|
|
638
|
+
deployId: 'dd-cyberia',
|
|
639
|
+
owner: 'acme',
|
|
640
|
+
dryRun: true,
|
|
641
|
+
});
|
|
642
|
+
expect(report.written).to.deep.equal({});
|
|
515
643
|
expect(report.manifest).to.equal('');
|
|
644
|
+
expect(report.sources.GIT_AUTH_TOKEN).to.be.a('string').and.not.equal('');
|
|
516
645
|
} finally {
|
|
517
646
|
hasBinary.mockRestore();
|
|
647
|
+
read.mockRestore();
|
|
518
648
|
}
|
|
519
649
|
});
|
|
520
650
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ensureTemplateCheckout,
|
|
12
12
|
gitOriginRepositoryName,
|
|
13
13
|
pruneTemplateWorkTree,
|
|
14
|
+
templateGitignoreFactory,
|
|
14
15
|
validateTemplatePath,
|
|
15
16
|
} from '../../src/server/runtime/conf.js';
|
|
16
17
|
import { TEMPLATE_PRESERVED_ENTRIES } from '../../src/projects/underpost/catalog-underpost.js';
|
|
@@ -55,6 +56,29 @@ describe('template checkout guard', () => {
|
|
|
55
56
|
});
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
describe('template .gitignore', () => {
|
|
60
|
+
it('carries the engine rules and stops before the prototype rules', () => {
|
|
61
|
+
const content = templateGitignoreFactory();
|
|
62
|
+
expect(content).to.match(/^\/node_modules$/m);
|
|
63
|
+
expect(content).to.not.include('# Ignore ERP / CRM custom prototypes src');
|
|
64
|
+
expect(content).to.not.include('*healthcare*');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps an install out of the next commit', () => {
|
|
68
|
+
const path = gitCheckout();
|
|
69
|
+
try {
|
|
70
|
+
fs.writeFileSync(`${path}/.gitignore`, templateGitignoreFactory());
|
|
71
|
+
fs.outputFileSync(`${path}/node_modules/probe/index.js`, 'module.exports = 1;\n');
|
|
72
|
+
fs.outputFileSync(`${path}/package.json`, '{}\n');
|
|
73
|
+
execSync('git add .', { cwd: path });
|
|
74
|
+
const staged = execSync('git diff --cached --name-only', { cwd: path, encoding: 'utf8' }).trim().split('\n');
|
|
75
|
+
expect(staged).to.have.members(['.gitignore', 'package.json']);
|
|
76
|
+
} finally {
|
|
77
|
+
fs.removeSync(path);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
58
82
|
describe('template work tree prune', () => {
|
|
59
83
|
it('keeps only the entries a rebuild cannot reconstruct', () => {
|
|
60
84
|
const path = fs.mkdtempSync(`${os.tmpdir()}/underpost-prune-`);
|
|
@@ -516,3 +516,41 @@ function render(output, columns, lines) {
|
|
|
516
516
|
|
|
517
517
|
return screen.filter((line) => line.trim().length > 0);
|
|
518
518
|
}
|
|
519
|
+
|
|
520
|
+
describe('run_quiet stream wiring', () => {
|
|
521
|
+
let wiringDir;
|
|
522
|
+
|
|
523
|
+
beforeEach(() => {
|
|
524
|
+
wiringDir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-quiet-wiring-'));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
afterEach(() => fs.removeSync(wiringDir));
|
|
528
|
+
|
|
529
|
+
// A command that finishes before tee holds the merged writer lets awk read EOF, and tee then
|
|
530
|
+
// blocks on an open with no reader until the 10 s drain kills it. A delay between tee's two
|
|
531
|
+
// opens, in the order the helper declares them, turns that scheduling race into a certainty.
|
|
532
|
+
it('finishes a short command even when tee opens its streams late', () => {
|
|
533
|
+
const wiring = /^(\s*)tee -a "\$error_log" (\S+) (\S+) &$/m;
|
|
534
|
+
const source = fs.readFileSync(loggingLib, 'utf8');
|
|
535
|
+
const [line, indent, first, second] = source.match(wiring) ?? [];
|
|
536
|
+
expect(line, 'tee wiring line').to.be.a('string');
|
|
537
|
+
const delayedLib = path.join(wiringDir, 'logging.sh');
|
|
538
|
+
fs.writeFileSync(
|
|
539
|
+
delayedLib,
|
|
540
|
+
source.replace(line, `${indent}{ sleep 0.3; exec tee -a "$error_log" ${second}; } ${first} &`),
|
|
541
|
+
);
|
|
542
|
+
const env = { ...process.env, TERM: 'xterm' };
|
|
543
|
+
for (const key of ['GITHUB_ACTIONS', 'RUN_QUIET_CI', 'RUN_QUIET_DEBUG']) delete env[key];
|
|
544
|
+
|
|
545
|
+
const started = Date.now();
|
|
546
|
+
execFileSync(
|
|
547
|
+
'bash',
|
|
548
|
+
[
|
|
549
|
+
'-c',
|
|
550
|
+
`source ${JSON.stringify(delayedLib)}; run_quiet "Sync probe" "Target pod:" 14 bash -c 'echo out; echo err >&2'`,
|
|
551
|
+
],
|
|
552
|
+
{ env, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
553
|
+
);
|
|
554
|
+
expect(Date.now() - started).to.be.below(5000);
|
|
555
|
+
}, 20_000);
|
|
556
|
+
});
|
|
@@ -61,6 +61,7 @@ describe('command capability classification', () => {
|
|
|
61
61
|
expect(classifyCommand('sudo dnf install -y cockpit')).to.equal('host:write');
|
|
62
62
|
expect(classifyCommand('sudo systemctl enable --now cockpit.socket')).to.equal('host:write');
|
|
63
63
|
expect(classifyCommand('curl https://example.com')).to.equal('net');
|
|
64
|
+
expect(classifyCommand("'/x/node_modules/.bin/socket' 'scan' 'create' '--json' '.'")).to.equal('net');
|
|
64
65
|
expect(classifyCommand('git add .')).to.equal('git');
|
|
65
66
|
});
|
|
66
67
|
|