@atlasauth/cli 0.25.1 → 0.26.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/CHANGELOG.md +31 -0
- package/README.md +26 -5
- package/dist/args.d.ts +30 -0
- package/dist/args.js +15 -0
- package/dist/args.js.map +1 -1
- package/dist/commands/config.d.ts +26 -2
- package/dist/commands/config.js +334 -44
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/doctor-scan.d.ts +27 -0
- package/dist/commands/doctor-scan.js +221 -0
- package/dist/commands/doctor-scan.js.map +1 -0
- package/dist/commands/golive.js +16 -4
- package/dist/commands/golive.js.map +1 -1
- package/dist/commands/import-jsonl.d.ts +50 -0
- package/dist/commands/import-jsonl.js +252 -0
- package/dist/commands/import-jsonl.js.map +1 -0
- package/dist/commands/import.js +22 -12
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/init-next.d.ts +4 -0
- package/dist/commands/init-next.js +86 -0
- package/dist/commands/init-next.js.map +1 -0
- package/dist/commands/init.js +112 -25
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/project-commands.js +74 -4
- package/dist/commands/project-commands.js.map +1 -1
- package/dist/commands/setup-guide.d.ts +3 -0
- package/dist/commands/setup-guide.js +38 -0
- package/dist/commands/setup-guide.js.map +1 -0
- package/dist/commands/users.js +12 -1
- package/dist/commands/users.js.map +1 -1
- package/dist/config-http.d.ts +9 -0
- package/dist/config-http.js +7 -1
- package/dist/config-http.js.map +1 -1
- package/dist/context.d.ts +18 -1
- package/dist/context.js +6 -1
- package/dist/context.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.js +70 -12
- package/dist/help.js.map +1 -1
- package/dist/redact.d.ts +41 -0
- package/dist/redact.js +115 -0
- package/dist/redact.js.map +1 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +16 -4
- package/dist/run.js.map +1 -1
- package/package.json +5 -5
package/dist/commands/config.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.config = config;
|
|
|
38
38
|
exports.resolveFormat = resolveFormat;
|
|
39
39
|
exports.planConfigImport = planConfigImport;
|
|
40
40
|
exports.diffFields = diffFields;
|
|
41
|
+
exports.printedFields = printedFields;
|
|
41
42
|
exports.readSecretsEnv = readSecretsEnv;
|
|
42
43
|
exports.unresolvedPlaceholders = unresolvedPlaceholders;
|
|
43
44
|
exports.configTypes = configTypes;
|
|
@@ -57,6 +58,9 @@ const serialize_1 = require("../serialize");
|
|
|
57
58
|
const backend_1 = require("@atlasauth/backend");
|
|
58
59
|
const shared_1 = require("./shared");
|
|
59
60
|
const secrets_1 = require("../secrets");
|
|
61
|
+
const redact_1 = require("../redact");
|
|
62
|
+
/** The refusal `rotate_secret` answers when the secret is the one already stored. */
|
|
63
|
+
const isSameSecretRefusal = (err) => err instanceof backend_1.AtlasApiError && err.status === 422 && /must differ/.test(err.message);
|
|
60
64
|
// ───────────────────────────── small helpers ─────────────────────────────
|
|
61
65
|
/** GET a `{ data: [...] }` list envelope, tolerating an absent/garbled body. */
|
|
62
66
|
async function listData(http, path) {
|
|
@@ -130,12 +134,14 @@ function registry() {
|
|
|
130
134
|
async apply(http, config) {
|
|
131
135
|
await http.put('/v1/messaging_providers/email', compact(withoutMarkers(config)));
|
|
132
136
|
},
|
|
133
|
-
|
|
137
|
+
fileKeys: ['transport', 'from_address', 'mode', 'config'],
|
|
138
|
+
reportsSecrets: true,
|
|
134
139
|
},
|
|
135
140
|
// 1. Instance config (auth_config + allowed origins) — the §9.3 singleton.
|
|
136
141
|
{
|
|
137
142
|
kind: 'singleton',
|
|
138
143
|
type: 'instance',
|
|
144
|
+
fileKeys: ['allowed_origins', 'auth_config'],
|
|
139
145
|
async fetchLive(http) {
|
|
140
146
|
const inst = await http.get('/v1/instance');
|
|
141
147
|
const hints = obj(inst.allowed_origin_integrations ?? null);
|
|
@@ -185,6 +191,7 @@ function registry() {
|
|
|
185
191
|
{
|
|
186
192
|
kind: 'singleton',
|
|
187
193
|
type: 'org_settings_schema',
|
|
194
|
+
fileKeys: ['schema'],
|
|
188
195
|
async fetchLive(http) {
|
|
189
196
|
try {
|
|
190
197
|
const s = await http.get('/v1/organization_settings_schema');
|
|
@@ -205,6 +212,7 @@ function registry() {
|
|
|
205
212
|
{
|
|
206
213
|
kind: 'collection',
|
|
207
214
|
type: 'redirect_urls',
|
|
215
|
+
fileKeys: ['url'],
|
|
208
216
|
keyOf: (c) => String(obj(c).url ?? ''),
|
|
209
217
|
async fetchLive(http) {
|
|
210
218
|
const rows = await listData(http, '/v1/redirect_urls');
|
|
@@ -221,6 +229,7 @@ function registry() {
|
|
|
221
229
|
{
|
|
222
230
|
kind: 'collection',
|
|
223
231
|
type: 'oauth_clients',
|
|
232
|
+
fileKeys: [...OAUTH_CLIENT_KEYS, ...OAUTH_CLIENT_WRITE_KEYS],
|
|
224
233
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
225
234
|
async fetchLive(http) {
|
|
226
235
|
const rows = await listData(http, '/v1/oauth_clients');
|
|
@@ -278,12 +287,31 @@ function registry() {
|
|
|
278
287
|
throw err;
|
|
279
288
|
}
|
|
280
289
|
},
|
|
281
|
-
|
|
290
|
+
fileKeys: ['before_user_create'],
|
|
291
|
+
nestedKeys: { before_user_create: ['url', 'timeout_ms', 'fail_closed', 'enabled', 'signing_secret'] },
|
|
292
|
+
reportsSecrets: true,
|
|
282
293
|
},
|
|
283
294
|
// 5. Social / enterprise OAuth providers — key = the catalog provider slug.
|
|
284
295
|
{
|
|
285
296
|
kind: 'collection',
|
|
286
297
|
type: 'oauth_providers',
|
|
298
|
+
fileKeys: [...OAUTH_PROVIDER_KEYS, 'client_secret', 'values', 'settings'],
|
|
299
|
+
reportsSecrets: true,
|
|
300
|
+
defaulted: ['mode'],
|
|
301
|
+
writeOnly: ['values'],
|
|
302
|
+
warnings(config, live) {
|
|
303
|
+
const c = obj(config);
|
|
304
|
+
const lv = obj(live ?? null);
|
|
305
|
+
const inFile = typeof c.client_secret === 'string' && c.client_secret !== '';
|
|
306
|
+
const out = [];
|
|
307
|
+
if (c.mode === 'redirect' && !inFile && !lv.client_secret) {
|
|
308
|
+
out.push(`oauth_providers ${String(c.provider)}: mode redirect needs a client_secret, and neither the file nor the instance has one.`);
|
|
309
|
+
}
|
|
310
|
+
if ((c.mode ?? lv.mode) === 'native' && inFile) {
|
|
311
|
+
out.push(`oauth_providers ${String(c.provider)}: secret unused in native mode. Remove client_secret, or set mode: redirect.`);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
},
|
|
287
315
|
keyOf: (c) => String(obj(c).provider ?? ''),
|
|
288
316
|
async fetchLive(http) {
|
|
289
317
|
const rows = await listData(http, '/v1/oauth_providers');
|
|
@@ -292,7 +320,11 @@ function registry() {
|
|
|
292
320
|
.map((r) => ({
|
|
293
321
|
key: String(r.provider ?? ''),
|
|
294
322
|
id: String(r.provider ?? ''),
|
|
295
|
-
config:
|
|
323
|
+
config: {
|
|
324
|
+
...obj(pick(r, OAUTH_PROVIDER_KEYS)),
|
|
325
|
+
...(r.setting_values && Object.keys(r.setting_values).length ? { settings: r.setting_values } : {}),
|
|
326
|
+
...(r.has_secret === true ? { client_secret: SECRET_SET } : {}),
|
|
327
|
+
},
|
|
296
328
|
}));
|
|
297
329
|
},
|
|
298
330
|
create: putProvider,
|
|
@@ -305,13 +337,15 @@ function registry() {
|
|
|
305
337
|
{
|
|
306
338
|
kind: 'collection',
|
|
307
339
|
type: 'custom_oauth_providers',
|
|
340
|
+
fileKeys: [...CUSTOM_PROVIDER_KEYS, ...CUSTOM_PROVIDER_WRITE_KEYS],
|
|
341
|
+
reportsSecrets: true,
|
|
308
342
|
keyOf: (c) => String(obj(c).provider_key ?? ''),
|
|
309
343
|
async fetchLive(http) {
|
|
310
344
|
const rows = await listData(http, '/v1/custom_oauth_providers');
|
|
311
345
|
return rows.map((r) => ({
|
|
312
346
|
key: String(r.provider_key ?? ''),
|
|
313
347
|
id: String(r.id ?? ''),
|
|
314
|
-
config: pick(r, CUSTOM_PROVIDER_KEYS),
|
|
348
|
+
config: { ...obj(pick(r, CUSTOM_PROVIDER_KEYS)), ...(r.has_secret === true ? { client_secret: SECRET_SET } : {}) },
|
|
315
349
|
}));
|
|
316
350
|
},
|
|
317
351
|
async create(http, config) {
|
|
@@ -330,6 +364,7 @@ function registry() {
|
|
|
330
364
|
{
|
|
331
365
|
kind: 'collection',
|
|
332
366
|
type: 'resource_servers',
|
|
367
|
+
fileKeys: [...RESOURCE_SERVER_KEYS, 'publish_resource_metadata', 'access_token_format', 'refresh_token_absolute_ttl_seconds', 'refresh_token_idle_ttl_seconds', 'introspection_client_ids'],
|
|
333
368
|
keyOf: (c) => String(obj(c).identifier ?? ''),
|
|
334
369
|
async fetchLive(http) {
|
|
335
370
|
const rows = await listData(http, '/v1/resource_servers');
|
|
@@ -358,6 +393,7 @@ function registry() {
|
|
|
358
393
|
{
|
|
359
394
|
kind: 'collection',
|
|
360
395
|
type: 'jwt_templates',
|
|
396
|
+
fileKeys: ['name', 'claims'],
|
|
361
397
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
362
398
|
async fetchLive(http) {
|
|
363
399
|
const rows = await listData(http, '/v1/jwt_templates');
|
|
@@ -382,6 +418,7 @@ function registry() {
|
|
|
382
418
|
{
|
|
383
419
|
kind: 'collection',
|
|
384
420
|
type: 'permissions',
|
|
421
|
+
fileKeys: PERMISSION_KEYS,
|
|
385
422
|
keyOf: (c) => String(obj(c).key ?? ''),
|
|
386
423
|
async fetchLive(http) {
|
|
387
424
|
const rows = await listData(http, '/v1/permissions');
|
|
@@ -404,6 +441,7 @@ function registry() {
|
|
|
404
441
|
{
|
|
405
442
|
kind: 'collection',
|
|
406
443
|
type: 'roles',
|
|
444
|
+
fileKeys: [...ROLE_KEYS, 'visibility'],
|
|
407
445
|
keyOf: (c) => String(obj(c).key ?? ''),
|
|
408
446
|
async fetchLive(http) {
|
|
409
447
|
const rows = await listData(http, '/v1/roles');
|
|
@@ -436,6 +474,17 @@ function registry() {
|
|
|
436
474
|
{
|
|
437
475
|
kind: 'collection',
|
|
438
476
|
type: 'webhook_endpoints',
|
|
477
|
+
fileKeys: [...WEBHOOK_KEYS, 'secret'],
|
|
478
|
+
// A dry-run rotation tells a new secret from the stored one without revealing either.
|
|
479
|
+
async secretChanged(http, live, config) {
|
|
480
|
+
try {
|
|
481
|
+
await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret?dry_run=true`, { secret: obj(config).secret });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
return isSameSecretRefusal(err) ? false : null;
|
|
486
|
+
}
|
|
487
|
+
},
|
|
439
488
|
keyOf: (c) => String(obj(c).url ?? ''),
|
|
440
489
|
async fetchLive(http) {
|
|
441
490
|
const rows = await listData(http, '/v1/webhook_endpoints');
|
|
@@ -453,10 +502,22 @@ function registry() {
|
|
|
453
502
|
enabled_events: c.enabled_events ?? [],
|
|
454
503
|
// Sent on create so a paused endpoint is never live, even briefly.
|
|
455
504
|
...(typeof c.active === 'boolean' ? { active: c.active } : {}),
|
|
505
|
+
...(typeof c.secret === 'string' ? { secret: c.secret } : {}),
|
|
456
506
|
});
|
|
457
507
|
},
|
|
458
508
|
async update(http, live, config) {
|
|
459
|
-
|
|
509
|
+
// PATCH leaves the signing secret alone; only rotate_secret changes it.
|
|
510
|
+
const { secret, ...rest } = obj(config);
|
|
511
|
+
await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(rest, true));
|
|
512
|
+
if (typeof secret !== 'string' || !secret)
|
|
513
|
+
return;
|
|
514
|
+
try {
|
|
515
|
+
await http.post(`/v1/webhook_endpoints/${enc(live.id)}/rotate_secret`, { secret });
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
if (!isSameSecretRefusal(err))
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
460
521
|
},
|
|
461
522
|
async remove(http, live) {
|
|
462
523
|
await http.delete(`/v1/webhook_endpoints/${enc(live.id)}`);
|
|
@@ -466,11 +527,14 @@ function registry() {
|
|
|
466
527
|
{
|
|
467
528
|
kind: 'collection',
|
|
468
529
|
type: 'sso_connections',
|
|
530
|
+
fileKeys: [...SSO_KEYS, ...SSO_WRITE_KEYS],
|
|
531
|
+
reportsSecrets: true,
|
|
532
|
+
writeOnly: ['saml_idp_certificate'],
|
|
469
533
|
keyOf: ssoKey,
|
|
470
534
|
async fetchLive(http) {
|
|
471
535
|
const rows = await listData(http, '/v1/sso_connections');
|
|
472
536
|
return rows.map((r) => {
|
|
473
|
-
const config = pick(r, SSO_KEYS);
|
|
537
|
+
const config = { ...obj(pick(r, SSO_KEYS)), ...(r.has_secret === true ? { oidc_client_secret: SECRET_SET, discourse_secret: SECRET_SET } : {}) };
|
|
474
538
|
return { key: ssoKey(config), id: String(r.id ?? ''), config };
|
|
475
539
|
});
|
|
476
540
|
},
|
|
@@ -491,6 +555,7 @@ function registry() {
|
|
|
491
555
|
{
|
|
492
556
|
kind: 'collection',
|
|
493
557
|
type: 'sso_onboarding_profiles',
|
|
558
|
+
fileKeys: SSO_PROFILE_KEYS,
|
|
494
559
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
495
560
|
async fetchLive(http) {
|
|
496
561
|
let rows;
|
|
@@ -521,6 +586,7 @@ function registry() {
|
|
|
521
586
|
{
|
|
522
587
|
kind: 'collection',
|
|
523
588
|
type: 'billing_plans',
|
|
589
|
+
fileKeys: [...BILLING_KEYS, 'grace_days', 'provider_price_id', 'annual_amount', 'seat_feature_id', 'promotion'],
|
|
524
590
|
keyOf: (c) => String(obj(c).slug ?? ''),
|
|
525
591
|
async fetchLive(http) {
|
|
526
592
|
const rows = await listData(http, '/v1/billing/plans');
|
|
@@ -546,6 +612,7 @@ function registry() {
|
|
|
546
612
|
{
|
|
547
613
|
kind: 'collection',
|
|
548
614
|
type: 'notification_templates',
|
|
615
|
+
fileKeys: NOTIFICATION_KEYS,
|
|
549
616
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
550
617
|
async fetchLive(http) {
|
|
551
618
|
const rows = await listData(http, '/v1/notification_templates');
|
|
@@ -571,6 +638,7 @@ function registry() {
|
|
|
571
638
|
{
|
|
572
639
|
kind: 'collection',
|
|
573
640
|
type: 'email_templates',
|
|
641
|
+
fileKeys: EMAIL_KEYS,
|
|
574
642
|
keyOf: (c) => String(obj(c).name ?? ''),
|
|
575
643
|
async fetchLive(http) {
|
|
576
644
|
const rows = await listData(http, '/v1/email_templates');
|
|
@@ -596,6 +664,7 @@ function registry() {
|
|
|
596
664
|
{
|
|
597
665
|
kind: 'collection',
|
|
598
666
|
type: 'organization_policies',
|
|
667
|
+
fileKeys: ORG_POLICY_KEYS,
|
|
599
668
|
keyOf: (c) => String(obj(c).organization_id ?? ''),
|
|
600
669
|
async fetchLive(http) {
|
|
601
670
|
const orgs = await listData(http, '/v1/organizations');
|
|
@@ -648,6 +717,7 @@ function registry() {
|
|
|
648
717
|
{
|
|
649
718
|
kind: 'singleton',
|
|
650
719
|
type: 'cookie_consent',
|
|
720
|
+
fileKeys: COOKIE_CONSENT_KEYS,
|
|
651
721
|
async fetchLive(http) {
|
|
652
722
|
let cc;
|
|
653
723
|
try {
|
|
@@ -677,6 +747,7 @@ function registry() {
|
|
|
677
747
|
{
|
|
678
748
|
kind: 'singleton',
|
|
679
749
|
type: 'sign_in_with_atlas',
|
|
750
|
+
fileKeys: ['enabled', 'connection_id'],
|
|
680
751
|
async fetchLive(http) {
|
|
681
752
|
let live;
|
|
682
753
|
try {
|
|
@@ -705,6 +776,7 @@ function registry() {
|
|
|
705
776
|
{
|
|
706
777
|
kind: 'singleton',
|
|
707
778
|
type: 'signing_key_policy',
|
|
779
|
+
fileKeys: ['prepublish_days'],
|
|
708
780
|
async fetchLive(http) {
|
|
709
781
|
let live;
|
|
710
782
|
try {
|
|
@@ -754,6 +826,20 @@ const OAUTH_CLIENT_KEYS = [
|
|
|
754
826
|
'access_token_format',
|
|
755
827
|
'token_lifetimes',
|
|
756
828
|
];
|
|
829
|
+
/** What a file may also send on an OAuth client: accepted on write, never exported. */
|
|
830
|
+
const OAUTH_CLIENT_WRITE_KEYS = [
|
|
831
|
+
'application_type',
|
|
832
|
+
'client_secret',
|
|
833
|
+
'secret_expires_in_seconds',
|
|
834
|
+
'jwks',
|
|
835
|
+
'jwks_uri',
|
|
836
|
+
'tls_client_auth_subject_dn',
|
|
837
|
+
'frontchannel_logout_session_required',
|
|
838
|
+
'disabled',
|
|
839
|
+
'metadata',
|
|
840
|
+
'external_ref',
|
|
841
|
+
'add_redirect_origins',
|
|
842
|
+
];
|
|
757
843
|
const OAUTH_PROVIDER_KEYS = [
|
|
758
844
|
'provider',
|
|
759
845
|
'enabled',
|
|
@@ -761,6 +847,7 @@ const OAUTH_PROVIDER_KEYS = [
|
|
|
761
847
|
'allow_sign_up',
|
|
762
848
|
'scopes',
|
|
763
849
|
'client_id',
|
|
850
|
+
'mode',
|
|
764
851
|
];
|
|
765
852
|
const RESOURCE_SERVER_KEYS = [
|
|
766
853
|
'identifier',
|
|
@@ -794,6 +881,18 @@ const CUSTOM_PROVIDER_KEYS = [
|
|
|
794
881
|
'allow_sign_in',
|
|
795
882
|
'allow_sign_up',
|
|
796
883
|
];
|
|
884
|
+
const CUSTOM_PROVIDER_WRITE_KEYS = [
|
|
885
|
+
'client_secret',
|
|
886
|
+
'end_session_endpoint',
|
|
887
|
+
'revocation_endpoint',
|
|
888
|
+
'host_params_schema',
|
|
889
|
+
'data_only',
|
|
890
|
+
'scope_separator',
|
|
891
|
+
'userinfo_auth',
|
|
892
|
+
'client_assertion_audience',
|
|
893
|
+
'logout',
|
|
894
|
+
'idp_initiated_login',
|
|
895
|
+
];
|
|
797
896
|
const WEBHOOK_KEYS = ['url', 'enabled_events', 'active'];
|
|
798
897
|
const SSO_KEYS = [
|
|
799
898
|
'type',
|
|
@@ -812,6 +911,29 @@ const SSO_KEYS = [
|
|
|
812
911
|
'claim_role_mappings',
|
|
813
912
|
'default_role_id',
|
|
814
913
|
];
|
|
914
|
+
const SSO_WRITE_KEYS = [
|
|
915
|
+
'oidc_client_secret',
|
|
916
|
+
'saml_idp_certificate',
|
|
917
|
+
'saml_single_logout',
|
|
918
|
+
'saml_idp_slo_url',
|
|
919
|
+
'saml_idp_slo_response_url',
|
|
920
|
+
'saml_idp_slo_binding',
|
|
921
|
+
'saml_idp_slo_soap_url',
|
|
922
|
+
'saml_require_encrypted_assertions',
|
|
923
|
+
'saml_acs_path',
|
|
924
|
+
'require_consent_screen',
|
|
925
|
+
'discourse_secret',
|
|
926
|
+
'provisioning_mode',
|
|
927
|
+
'manual_endpoints',
|
|
928
|
+
'github_access',
|
|
929
|
+
'attribute_mapping',
|
|
930
|
+
'oidc_scopes',
|
|
931
|
+
'profile_mapping',
|
|
932
|
+
'token_endpoint_auth_method',
|
|
933
|
+
'client_assertion_audience',
|
|
934
|
+
'logout',
|
|
935
|
+
'idp_initiated_login',
|
|
936
|
+
];
|
|
815
937
|
const SSO_PROFILE_KEYS = ['name', 'allowed_connection_types', 'organization_id', 'company_name', 'allow_scim', 'intents', 'return_url'];
|
|
816
938
|
const COOKIE_CONSENT_KEYS = ['enabled', 'script_url', 'script_id', 'site_id', 'blocking_mode', 'extra_origins', 'settings_link', 'culture', 'host_aliases'];
|
|
817
939
|
const BILLING_KEYS = [
|
|
@@ -852,18 +974,96 @@ function obj(config) {
|
|
|
852
974
|
}
|
|
853
975
|
/** What a write-only secret looks like on the live side, and in a plan. */
|
|
854
976
|
const SECRET_SET = '(secret set)';
|
|
855
|
-
const
|
|
977
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
856
978
|
/**
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
979
|
+
* Bring a file's secrets and the live document to one comparable shape. A secret
|
|
980
|
+
* value becomes "(secret set)", so a plan never holds it and it compares equal to
|
|
981
|
+
* the live marker. `unverifiable` lists the secrets set on both sides, or that the
|
|
982
|
+
* API never reports (`reported` false): Atlas cannot say whether they differ, so
|
|
983
|
+
* they are not drift, and the plan says so. A secret the API reports as unset stays
|
|
984
|
+
* a difference. An unresolved ${VAR} stays visible (apply refuses it), as does an
|
|
985
|
+
* empty value.
|
|
860
986
|
*/
|
|
861
|
-
function
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
987
|
+
function reconcileSecrets(desired, live, reported, prefix = '') {
|
|
988
|
+
if (!isObj(desired))
|
|
989
|
+
return { desired, live, unverifiable: [] };
|
|
990
|
+
const d = { ...desired };
|
|
991
|
+
const l = isObj(live) ? { ...live } : {};
|
|
992
|
+
const unverifiable = [];
|
|
993
|
+
for (const [k, v] of Object.entries(desired)) {
|
|
994
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
995
|
+
if (isObj(v)) {
|
|
996
|
+
const inner = reconcileSecrets(v, l[k], reported, path);
|
|
997
|
+
d[k] = inner.desired;
|
|
998
|
+
if (isObj(l[k]))
|
|
999
|
+
l[k] = inner.live;
|
|
1000
|
+
unverifiable.push(...inner.unverifiable);
|
|
1001
|
+
}
|
|
1002
|
+
else if ((0, redact_1.isSecretKey)(k) && typeof v === 'string' && v !== '' && v !== SECRET_SET && !(0, redact_1.isPlaceholder)(v)) {
|
|
1003
|
+
if (l[k] === SECRET_SET) {
|
|
1004
|
+
d[k] = SECRET_SET;
|
|
1005
|
+
unverifiable.push(path);
|
|
1006
|
+
}
|
|
1007
|
+
else if (l[k] === undefined || l[k] === null) {
|
|
1008
|
+
d[k] = SECRET_SET;
|
|
1009
|
+
if (!reported) {
|
|
1010
|
+
l[k] = SECRET_SET;
|
|
1011
|
+
unverifiable.push(path);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return { desired: d, live: isObj(live) || unverifiable.length ? l : live, unverifiable };
|
|
1017
|
+
}
|
|
1018
|
+
/** The live document without the secret markers and server defaults the file says nothing about. */
|
|
1019
|
+
function withoutUnstated(live, desired, defaulted = []) {
|
|
1020
|
+
if (!isObj(live))
|
|
1021
|
+
return live;
|
|
1022
|
+
const want = isObj(desired) ? desired : {};
|
|
1023
|
+
const out = {};
|
|
1024
|
+
for (const [k, v] of Object.entries(live)) {
|
|
1025
|
+
if (!(k in want) && (v === SECRET_SET || defaulted.includes(k)))
|
|
1026
|
+
continue;
|
|
1027
|
+
out[k] = isObj(v) && isObj(want[k]) ? withoutUnstated(v, want[k]) : v;
|
|
1028
|
+
}
|
|
1029
|
+
return out;
|
|
1030
|
+
}
|
|
1031
|
+
/** How close two key names are (edit distance). */
|
|
1032
|
+
function distance(a, b) {
|
|
1033
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1034
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1035
|
+
const row = [i];
|
|
1036
|
+
for (let j = 1; j <= b.length; j++)
|
|
1037
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1038
|
+
prev = row;
|
|
1039
|
+
}
|
|
1040
|
+
return prev[b.length];
|
|
1041
|
+
}
|
|
1042
|
+
function didYouMean(key, known) {
|
|
1043
|
+
const norm = (x) => x.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1044
|
+
const best = [...known].sort((a, b) => distance(norm(key), a) - distance(norm(key), b))[0];
|
|
1045
|
+
return best && distance(norm(key), best) <= Math.max(2, Math.floor(key.length / 3)) ? ` (did you mean ${best}?)` : '';
|
|
1046
|
+
}
|
|
1047
|
+
/** The keys in one parsed resource file that no spec knows, each named with its nearest real key. */
|
|
1048
|
+
function unknownFileKeys(spec, doc) {
|
|
1049
|
+
const out = [];
|
|
1050
|
+
const check = (value, known, label) => {
|
|
1051
|
+
if (!isObj(value))
|
|
1052
|
+
return;
|
|
1053
|
+
for (const k of Object.keys(value))
|
|
1054
|
+
if (!known.includes(k))
|
|
1055
|
+
out.push(`${label}: ${k}${didYouMean(k, known)}`);
|
|
1056
|
+
};
|
|
1057
|
+
if (spec.kind === 'collection') {
|
|
1058
|
+
for (const entry of asArray(doc))
|
|
1059
|
+
check(entry, spec.fileKeys, `${spec.type} ${spec.keyOf(entry) || '(no key)'}`);
|
|
1060
|
+
}
|
|
1061
|
+
else if (spec.fileKeys) {
|
|
1062
|
+
check(doc, spec.fileKeys, spec.type);
|
|
1063
|
+
for (const [k, known] of Object.entries(spec.nestedKeys ?? {}))
|
|
1064
|
+
if (isObj(doc))
|
|
1065
|
+
check(doc[k], known, `${spec.type}.${k}`);
|
|
1066
|
+
}
|
|
867
1067
|
return out;
|
|
868
1068
|
}
|
|
869
1069
|
/** A document without any "(secret set)" marker: what an export writes and what apply sends. */
|
|
@@ -910,6 +1110,8 @@ async function putProvider(http, config) {
|
|
|
910
1110
|
// Credential-less providers (siwe, siws, siwb, siwf) take verifier settings instead.
|
|
911
1111
|
if (c.settings && typeof c.settings === 'object' && !Array.isArray(c.settings))
|
|
912
1112
|
body.settings = c.settings;
|
|
1113
|
+
if (c.mode === 'native' || c.mode === 'redirect')
|
|
1114
|
+
body.mode = c.mode;
|
|
913
1115
|
const provider = String(c.provider ?? '');
|
|
914
1116
|
if (Object.keys(body).length) {
|
|
915
1117
|
await http.put(`/v1/oauth_providers/${enc(provider)}`, body);
|
|
@@ -976,10 +1178,10 @@ function resolveDir(values) {
|
|
|
976
1178
|
return (0, args_1.str)(values, 'dir') || './atlas-config';
|
|
977
1179
|
}
|
|
978
1180
|
/** Build the config HTTP client from the resolved profile credential. */
|
|
979
|
-
function requireHttp(ctx, values) {
|
|
1181
|
+
function requireHttp(ctx, values, onUnknownFields) {
|
|
980
1182
|
const auth = (0, shared_1.resolveAuth)(ctx, values);
|
|
981
1183
|
const make = ctx.makeHttp ?? config_http_1.createConfigHttp;
|
|
982
|
-
return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl });
|
|
1184
|
+
return make({ secretKey: auth.secretKey, apiUrl: auth.apiUrl, ...(onUnknownFields ? { onUnknownFields } : {}) });
|
|
983
1185
|
}
|
|
984
1186
|
/** Write every resource type's live config to `dir` (deterministic, secrets omitted); returns the file count. */
|
|
985
1187
|
async function writeExport(http, dir, format) {
|
|
@@ -998,7 +1200,7 @@ async function writeExport(http, dir, format) {
|
|
|
998
1200
|
const entries = (await spec.fetchLive(http)).filter((e) => !e.managedBy);
|
|
999
1201
|
// Deterministic: entries sorted by natural key, keys sorted inside each.
|
|
1000
1202
|
const sorted = [...entries].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1001
|
-
const doc = sorted.map((e) => (0, serialize_1.canonicalize)(e.config));
|
|
1203
|
+
const doc = sorted.map((e) => (0, serialize_1.canonicalize)(withoutMarkers(e.config)));
|
|
1002
1204
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(doc, format));
|
|
1003
1205
|
fileCount += 1;
|
|
1004
1206
|
}
|
|
@@ -1027,6 +1229,12 @@ async function planConfigImport(http, opts) {
|
|
|
1027
1229
|
throw new errors_1.CliError(`Config directory not found: ${dir}. Run 'atlas config export' first.`);
|
|
1028
1230
|
}
|
|
1029
1231
|
const present = new Set((0, node_fs_1.readdirSync)(dir));
|
|
1232
|
+
const unknown = registry().flatMap((spec) => present.has(`${spec.type}.${ext}`) ? unknownFileKeys(spec, (0, serialize_1.deserialize)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), 'utf8'), format)) : []);
|
|
1233
|
+
if (unknown.length)
|
|
1234
|
+
throw new errors_1.CliError(`Unknown key(s) in the config:\n ${unknown.join('\n ')}`);
|
|
1235
|
+
const rotate = opts.rotateSecrets === true;
|
|
1236
|
+
const warnings = [];
|
|
1237
|
+
const unverifiableSecrets = [];
|
|
1030
1238
|
const changes = [];
|
|
1031
1239
|
let unchanged = 0;
|
|
1032
1240
|
const skippedDeletes = [];
|
|
@@ -1048,12 +1256,21 @@ async function planConfigImport(http, opts) {
|
|
|
1048
1256
|
unchanged += 1;
|
|
1049
1257
|
continue;
|
|
1050
1258
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1259
|
+
if (liveRaw === null) {
|
|
1260
|
+
const masked = reconcileSecrets(desiredN, undefined, true).desired;
|
|
1261
|
+
changes.push({ type: spec.type, action: 'create', key: '', after: masked, raw: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
const seen = withoutUnstated(comparable(norm(liveRaw), desiredN, prune), desiredN);
|
|
1265
|
+
const r = reconcileSecrets(desiredN, seen, spec.reportsSecrets === true);
|
|
1266
|
+
const forced = rotate ? r.unverifiable : [];
|
|
1267
|
+
if (!rotate)
|
|
1268
|
+
unverifiableSecrets.push(...r.unverifiable.map((path) => ({ type: spec.type, key: '', path })));
|
|
1269
|
+
if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, r.live)) {
|
|
1053
1270
|
unchanged += 1;
|
|
1054
1271
|
continue;
|
|
1055
1272
|
}
|
|
1056
|
-
changes.push({ type: spec.type, action:
|
|
1273
|
+
changes.push({ type: spec.type, action: 'update', key: '', before: r.live, after: r.desired, raw: desiredN, changedSecrets: forced, apply: () => spec.apply(http, desired, applyOpts) });
|
|
1057
1274
|
continue;
|
|
1058
1275
|
}
|
|
1059
1276
|
// Collection: diff desired-by-key against live-by-key.
|
|
@@ -1069,19 +1286,40 @@ async function planConfigImport(http, opts) {
|
|
|
1069
1286
|
const liveByKey = new Map(live.map((e) => [e.key, e]));
|
|
1070
1287
|
for (const [key, cfg] of desiredByKey) {
|
|
1071
1288
|
const existing = liveByKey.get(key);
|
|
1289
|
+
warnings.push(...(spec.warnings?.(cfg, existing?.config) ?? []));
|
|
1072
1290
|
if (!existing) {
|
|
1073
|
-
changes.push({ type: spec.type, action: 'create', key, after: cfg, apply: () => spec.create(http, cfg) });
|
|
1291
|
+
changes.push({ type: spec.type, action: 'create', key, after: reconcileSecrets(cfg, undefined, true).desired, raw: cfg, apply: () => spec.create(http, cfg) });
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
const seen = withoutUnstated(comparable(existing.config, cfg, prune), cfg, spec.defaulted);
|
|
1295
|
+
const r = reconcileSecrets(cfg, seen, spec.reportsSecrets === true);
|
|
1296
|
+
const unverifiable = [...r.unverifiable];
|
|
1297
|
+
const liveSeen = obj(r.live);
|
|
1298
|
+
for (const k of spec.writeOnly ?? []) {
|
|
1299
|
+
if (obj(r.desired)[k] === undefined)
|
|
1300
|
+
continue;
|
|
1301
|
+
liveSeen[k] = obj(r.desired)[k];
|
|
1302
|
+
unverifiable.push(k);
|
|
1074
1303
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1304
|
+
let forced = rotate ? unverifiable : [];
|
|
1305
|
+
let unknownToApi = rotate ? [] : unverifiable;
|
|
1306
|
+
if (spec.secretChanged && unverifiable.length) {
|
|
1307
|
+
const changed = await spec.secretChanged(http, existing, cfg);
|
|
1308
|
+
if (changed !== null) {
|
|
1309
|
+
forced = changed ? unverifiable : [];
|
|
1310
|
+
unknownToApi = [];
|
|
1078
1311
|
}
|
|
1079
|
-
const upd = spec.update;
|
|
1080
|
-
changes.push({ type: spec.type, action: 'update', key, before: comparable(existing.config, cfg, prune), after: cfg, apply: () => upd(http, existing, cfg) });
|
|
1081
1312
|
}
|
|
1082
|
-
|
|
1313
|
+
unverifiableSecrets.push(...unknownToApi.map((path) => ({ type: spec.type, key, path })));
|
|
1314
|
+
if (forced.length === 0 && (0, serialize_1.deepEqual)(r.desired, liveSeen)) {
|
|
1083
1315
|
unchanged += 1;
|
|
1316
|
+
continue;
|
|
1084
1317
|
}
|
|
1318
|
+
if (!spec.update) {
|
|
1319
|
+
throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
|
|
1320
|
+
}
|
|
1321
|
+
const upd = spec.update;
|
|
1322
|
+
changes.push({ type: spec.type, action: 'update', key, before: liveSeen, after: r.desired, raw: cfg, changedSecrets: forced, apply: () => upd(http, existing, cfg) });
|
|
1085
1323
|
}
|
|
1086
1324
|
for (const entry of live) {
|
|
1087
1325
|
if (desiredByKey.has(entry.key))
|
|
@@ -1100,7 +1338,7 @@ async function planConfigImport(http, opts) {
|
|
|
1100
1338
|
}
|
|
1101
1339
|
}
|
|
1102
1340
|
}
|
|
1103
|
-
return { changes, unchanged, skippedDeletes, managed };
|
|
1341
|
+
return { changes, unchanged, skippedDeletes, managed, warnings, unverifiableSecrets };
|
|
1104
1342
|
}
|
|
1105
1343
|
/** True for a non-empty object whose every leaf is `null` (a file that only clears fields). */
|
|
1106
1344
|
function onlyNulls(x) {
|
|
@@ -1120,7 +1358,6 @@ function onlyNulls(x) {
|
|
|
1120
1358
|
function comparable(live, desired, prune) {
|
|
1121
1359
|
if (prune)
|
|
1122
1360
|
return live;
|
|
1123
|
-
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
1124
1361
|
if (!isObj(live) || !isObj(desired))
|
|
1125
1362
|
return live;
|
|
1126
1363
|
const out = {};
|
|
@@ -1180,6 +1417,7 @@ async function promoteConfig(ctx, values) {
|
|
|
1180
1417
|
const plan = await planConfigImport(target.http, { dir, format, env: ctx.env, prune });
|
|
1181
1418
|
ctx.io.out(`Promoting ${from} -> ${to}`);
|
|
1182
1419
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
|
|
1420
|
+
printPlanNotes(ctx, plan);
|
|
1183
1421
|
if (dryRun)
|
|
1184
1422
|
return 0;
|
|
1185
1423
|
for (const change of plan.changes)
|
|
@@ -1197,8 +1435,9 @@ async function importConfig(ctx, values) {
|
|
|
1197
1435
|
const format = resolveFormat(values);
|
|
1198
1436
|
const dryRun = values['dry-run'] === true;
|
|
1199
1437
|
const prune = values.prune === true;
|
|
1200
|
-
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true });
|
|
1438
|
+
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
|
|
1201
1439
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
|
|
1440
|
+
printPlanNotes(ctx, plan);
|
|
1202
1441
|
if (dryRun)
|
|
1203
1442
|
return 0;
|
|
1204
1443
|
for (const change of plan.changes)
|
|
@@ -1211,7 +1450,6 @@ async function importConfig(ctx, values) {
|
|
|
1211
1450
|
}
|
|
1212
1451
|
/** Leaf-level differences between two documents, as dotted paths (arrays compare whole). */
|
|
1213
1452
|
function diffFields(before, after, path = '') {
|
|
1214
|
-
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
1215
1453
|
if (isObj(before) && isObj(after)) {
|
|
1216
1454
|
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
1217
1455
|
return keys.flatMap((k) => diffFields(before[k], after[k], path ? `${path}.${k}` : k));
|
|
@@ -1223,6 +1461,35 @@ function diffFields(before, after, path = '') {
|
|
|
1223
1461
|
return [{ path: path || '(document)', ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) }];
|
|
1224
1462
|
}
|
|
1225
1463
|
const show = (v) => (v === undefined ? '(unset)' : typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v));
|
|
1464
|
+
/**
|
|
1465
|
+
* The only form a change's fields are printed or serialised in. A secret-named
|
|
1466
|
+
* string is `<redacted>` with a changed flag, whatever the resource; everything else
|
|
1467
|
+
* goes through the deep redactor.
|
|
1468
|
+
*/
|
|
1469
|
+
function printedFields(c) {
|
|
1470
|
+
const reveal = (0, redact_1.revealingSecrets)();
|
|
1471
|
+
const diffs = diffFields(c.before, reveal ? (c.raw ?? c.after) : c.after);
|
|
1472
|
+
const rows = diffs.map((f) => {
|
|
1473
|
+
const leaf = f.path.split('.').pop();
|
|
1474
|
+
const textual = [f.before, f.after].every((v) => v === undefined || v === null || typeof v === 'string');
|
|
1475
|
+
if (!reveal && (0, redact_1.isSecretKey)(leaf) && textual && !(0, redact_1.isPlaceholder)(f.after))
|
|
1476
|
+
return { path: f.path, redacted: true, changed: true };
|
|
1477
|
+
return { path: f.path, ...(f.before === undefined ? {} : { before: (0, redact_1.redactDeep)(f.before, leaf) }), ...(f.after === undefined ? {} : { after: (0, redact_1.redactDeep)(f.after, leaf) }) };
|
|
1478
|
+
});
|
|
1479
|
+
for (const path of c.changedSecrets ?? [])
|
|
1480
|
+
if (!rows.some((r) => r.path === path))
|
|
1481
|
+
rows.push({ path, redacted: true, changed: true });
|
|
1482
|
+
return rows;
|
|
1483
|
+
}
|
|
1484
|
+
const printedLine = (f) => (f.redacted ? `${f.path}: ${redact_1.REDACTED} (changed)` : `${f.path}: ${show(f.before)} -> ${show(f.after)}`);
|
|
1485
|
+
/** The plan's advice: warnings, and the secrets Atlas cannot compare. */
|
|
1486
|
+
function printPlanNotes(ctx, plan) {
|
|
1487
|
+
for (const w of plan.warnings)
|
|
1488
|
+
ctx.io.out(`warning: ${w}`);
|
|
1489
|
+
for (const u of plan.unverifiableSecrets) {
|
|
1490
|
+
ctx.io.out(`note: ${u.type}${u.key ? ` ${u.key}` : ''} ${u.path}: Atlas does not return this value, so a change to it cannot be detected. Send it again with --rotate-secret.`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1226
1493
|
/**
|
|
1227
1494
|
* `atlas config plan`: a Terraform-style, read-only diff of a config directory
|
|
1228
1495
|
* (`atlas config export` output, committed to git) against the live instance.
|
|
@@ -1234,14 +1501,14 @@ async function planConfig(ctx, values, source) {
|
|
|
1234
1501
|
const { dir, format, env } = source;
|
|
1235
1502
|
const where = source.label ?? dir;
|
|
1236
1503
|
const prune = values.prune === true;
|
|
1237
|
-
const plan = await planConfigImport(http, { dir, format, env, prune });
|
|
1504
|
+
const plan = await planConfigImport(http, { dir, format, env, prune, rotateSecrets: values['rotate-secret'] === true });
|
|
1238
1505
|
const deletes = prune ? [] : plan.skippedDeletes;
|
|
1239
1506
|
const all = plan.changes;
|
|
1240
1507
|
const rows = all.map((c) => ({
|
|
1241
1508
|
type: c.type,
|
|
1242
1509
|
key: c.key || null,
|
|
1243
1510
|
action: c.action,
|
|
1244
|
-
fields: c.action === 'update' ?
|
|
1511
|
+
fields: c.action === 'update' ? printedFields(c) : [],
|
|
1245
1512
|
}));
|
|
1246
1513
|
const pending = rows.length;
|
|
1247
1514
|
if (values.json === true) {
|
|
@@ -1251,6 +1518,8 @@ async function planConfig(ctx, values, source) {
|
|
|
1251
1518
|
unchanged: plan.unchanged,
|
|
1252
1519
|
unmanaged_live: deletes.map((d) => ({ type: d.type, key: d.key || null })),
|
|
1253
1520
|
managed_by_platform: plan.managed.map((m) => ({ type: m.type, key: m.key, managed_by: m.managedBy })),
|
|
1521
|
+
warnings: plan.warnings,
|
|
1522
|
+
unverifiable_secrets: plan.unverifiableSecrets.map((u) => ({ type: u.type, key: u.key || null, path: u.path })),
|
|
1254
1523
|
has_changes: pending > 0,
|
|
1255
1524
|
}, null, 2));
|
|
1256
1525
|
}
|
|
@@ -1262,8 +1531,8 @@ async function planConfig(ctx, values, source) {
|
|
|
1262
1531
|
ctx.io.out('');
|
|
1263
1532
|
ctx.io.out(`${sign[c.action]} ${label} (${c.action})`);
|
|
1264
1533
|
if (c.action === 'update') {
|
|
1265
|
-
for (const f of
|
|
1266
|
-
ctx.io.out(` ${
|
|
1534
|
+
for (const f of printedFields(c))
|
|
1535
|
+
ctx.io.out(` ${printedLine(f)}`);
|
|
1267
1536
|
}
|
|
1268
1537
|
}
|
|
1269
1538
|
if (deletes.length) {
|
|
@@ -1278,6 +1547,9 @@ async function planConfig(ctx, values, source) {
|
|
|
1278
1547
|
for (const m of plan.managed)
|
|
1279
1548
|
ctx.io.out(` ${m.type} ${m.key} (${m.managedBy})`);
|
|
1280
1549
|
}
|
|
1550
|
+
if (plan.warnings.length || plan.unverifiableSecrets.length)
|
|
1551
|
+
ctx.io.out('');
|
|
1552
|
+
printPlanNotes(ctx, plan);
|
|
1281
1553
|
if (pending === 0)
|
|
1282
1554
|
ctx.io.out(`No drift: the instance matches ${where}.`);
|
|
1283
1555
|
}
|
|
@@ -1420,10 +1692,21 @@ async function applyCommand(ctx, args) {
|
|
|
1420
1692
|
return planCommand(ctx, args);
|
|
1421
1693
|
const source = await resolveSource(ctx, args);
|
|
1422
1694
|
try {
|
|
1423
|
-
const
|
|
1695
|
+
const onUnknownFields = (n) => ctx.io.err(`atlas: warning: ${n.method} ${n.path} ignored field(s) ${n.fields}${n.suggestions ? ` (did you mean: ${n.suggestions})` : ''}. This instance is lenient; set auth_config.strictRequestBodies to refuse them.`);
|
|
1696
|
+
const kept = keepingSecrets(requireHttp(ctx, values, onUnknownFields), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
|
|
1424
1697
|
const prune = values.prune === true;
|
|
1425
|
-
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true });
|
|
1698
|
+
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true, rotateSecrets: values['rotate-secret'] === true });
|
|
1426
1699
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun: false, prune });
|
|
1700
|
+
for (const c of plan.changes) {
|
|
1701
|
+
if (c.action !== 'update')
|
|
1702
|
+
continue;
|
|
1703
|
+
const fields = printedFields(c);
|
|
1704
|
+
if (fields.length)
|
|
1705
|
+
ctx.io.out(` ~ ${c.key ? `${c.type} ${c.key}` : c.type}:`);
|
|
1706
|
+
for (const f of fields)
|
|
1707
|
+
ctx.io.out(` ${printedLine(f)}`);
|
|
1708
|
+
}
|
|
1709
|
+
printPlanNotes(ctx, plan);
|
|
1427
1710
|
if (plan.changes.length === 0) {
|
|
1428
1711
|
ctx.io.out('No changes to apply.');
|
|
1429
1712
|
return 0;
|
|
@@ -1471,7 +1754,8 @@ function keepingSecrets(http, dir) {
|
|
|
1471
1754
|
if (path === '/v1/oauth_clients' && typeof r.client_secret === 'string') {
|
|
1472
1755
|
entries = { ATLAS_CLIENT_ID: String(r.client_id ?? ''), ATLAS_CLIENT_SECRET: r.client_secret };
|
|
1473
1756
|
}
|
|
1474
|
-
else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string') {
|
|
1757
|
+
else if (path === '/v1/webhook_endpoints' && typeof r.secret === 'string' && !body?.secret) {
|
|
1758
|
+
// A secret the file chose is already the caller's; only a minted one needs keeping.
|
|
1475
1759
|
entries = { ATLAS_WEBHOOK_SECRET: r.secret };
|
|
1476
1760
|
}
|
|
1477
1761
|
if (entries) {
|
|
@@ -1515,16 +1799,22 @@ function asArray(value) {
|
|
|
1515
1799
|
* on disk. An unset variable is left verbatim so the plan surfaces it rather
|
|
1516
1800
|
* than silently blanking a credential.
|
|
1517
1801
|
*/
|
|
1518
|
-
function interpolate(value, env) {
|
|
1802
|
+
function interpolate(value, env, key) {
|
|
1519
1803
|
if (typeof value === 'string') {
|
|
1520
|
-
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) =>
|
|
1804
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => {
|
|
1805
|
+
const resolved = env[name];
|
|
1806
|
+
if (resolved === undefined)
|
|
1807
|
+
return match;
|
|
1808
|
+
(0, redact_1.rememberResolved)(key, resolved);
|
|
1809
|
+
return resolved;
|
|
1810
|
+
});
|
|
1521
1811
|
}
|
|
1522
1812
|
if (Array.isArray(value))
|
|
1523
|
-
return value.map((v) => interpolate(v, env));
|
|
1813
|
+
return value.map((v) => interpolate(v, env, key));
|
|
1524
1814
|
if (value !== null && typeof value === 'object') {
|
|
1525
1815
|
const out = {};
|
|
1526
1816
|
for (const [k, v] of Object.entries(value))
|
|
1527
|
-
out[k] = interpolate(v, env);
|
|
1817
|
+
out[k] = interpolate(v, env, k);
|
|
1528
1818
|
return out;
|
|
1529
1819
|
}
|
|
1530
1820
|
return value;
|