From 5c22dd9aeea33a213bb5b4b33f1cfe26fdf41b54 Mon Sep 17 00:00:00 2001 From: dariusz gorzeba Date: Fri, 24 Jul 2026 12:02:04 +0200 Subject: [PATCH 1/2] add pos-cli dns command group: migrate domains between Partner Portals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New commands for customers migrating instances from the shared AWS stack (partners.platformos.com) to private-stack portals: - dns export: snapshot all domains/DNS records of an instance to a versioned JSON backup (pos-cli/dns-export/v1) - dns migrate : export -> transform -> apply portal-to-portal via /api/domains, then print per-domain cutover instructions (registrar nameservers for domain-full, SSL validation records + CNAME/A targets for domain-external) - dns import --file: apply a saved export (offline --dry-run supported) - dns status: show each domain's provisioning status and pending cutover steps - dns compare: parity verification (OK/ADVISORY/CRITICAL) with expected cross-stack noise normalized (MX case, TXT quoting/chunking, nameservers, data_center); --drop-value mirrors migration-time drops; exit 1 on real drift Safety model: - partners.platformos.com is protected as read-only — only ever a migration source, so a swapped `migrate ` fails immediately (--unsafe-allow-protected-target to override) - plan-then-confirm: the plan is displayed and must be confirmed before anything is applied (--yes for scripts/CI; --json refuses a blind interactive confirmation) - a source-export backup is always written before any apply; destructive target updates require --confirm-destructive; worker failures surface as APPLY-FAILED via last_operation_status instead of silent success - exit codes documented: 0 ok / 1 apply errors / 2 transform errors / 3 blocked-only - dns commands never read the ambient PARTNER_PORTAL_HOST env var; scheme-less portal URLs get an actionable error Only customer records move — the target portal re-derives platform DNS (fallback, ACME, www-redirect record) itself. Validated end-to-end on loremup.com (partners.platformos.com -> ps-pos01), including delete -> re-create against Cloudflare's restored-zone behavior (requires the partner-portal TASK-50/51 fixes, deployed 2026-07-23). Bulk cohort mode (--instances-file/--mapping-file) is parked on the dns-migration-bulk branch for a later release. 94 unit tests (vitest + nock) + an env-gated integration test. Work tracked in backlog/ TASK-1. --- CHANGELOG.md | 10 + README.md | 56 +++++ backlog/config.yml | 15 ++ ...mand-group-AWS-PPDNS-OCI-private-stacks.md | 34 +++ ...per-portal-API-client-with-typed-errors.md | 25 ++ ...ckups-when-the-directory-does-not-exist.md | 43 ++++ ...s-that-dropped-records-can-verify-clean.md | 45 ++++ ...mation-prompts-without-showing-the-plan.md | 44 ++++ ...ss-import-migrate-single-and-bulk-modes.md | 45 ++++ ...r-propagation-ambient-env-helper-dedupe.md | 56 +++++ ...pply-polling-and-record-name-validation.md | 44 ++++ ...vePortalContext-for-source-target-sides.md | 32 +++ ...-export-command-versioned-export-schema.md | 26 +++ ...-transform.js-pure-record-transform-TDD.md | 32 +++ ...ngine-dry-run-destructive-guard-polling.md | 26 +++ ...ns-migrate-command-cutover-instructions.md | 26 +++ ...d-port-compare-golden.rb-classification.md | 26 +++ ...g.js-instances-file-mapping-file-wiring.md | 32 +++ ...k-1.9 - docs-integration-test-smoke-run.md | 32 +++ ...l-to-pos-cli-dns-inspect-doctor-command.md | 25 ++ bin/pos-cli-dns-compare.js | 109 +++++++++ bin/pos-cli-dns-export.js | 70 ++++++ bin/pos-cli-dns-import.js | 111 +++++++++ bin/pos-cli-dns-migrate.js | 150 ++++++++++++ bin/pos-cli-dns-status.js | 58 +++++ bin/pos-cli-dns.js | 12 + bin/pos-cli.js | 1 + lib/dns/apply.js | 101 ++++++++ lib/dns/auth.js | 117 ++++++++++ lib/dns/cliHelpers.js | 56 +++++ lib/dns/compare.js | 212 +++++++++++++++++ lib/dns/cutover.js | 70 ++++++ lib/dns/export.js | 39 ++++ lib/dns/exportSchema.js | 52 +++++ lib/dns/guard.js | 44 ++++ lib/dns/normalize.js | 63 +++++ lib/dns/plan.js | 85 +++++++ lib/dns/portalClient.js | 156 +++++++++++++ lib/dns/transform.js | 154 ++++++++++++ package-lock.json | 6 + package.json | 6 + test/integration/dns.test.js | 77 ++++++ test/unit/dns/apply.test.js | 170 ++++++++++++++ test/unit/dns/auth.test.js | 206 ++++++++++++++++ test/unit/dns/cliHelpers.test.js | 64 +++++ test/unit/dns/compare.test.js | 211 +++++++++++++++++ test/unit/dns/cutover.test.js | 59 +++++ test/unit/dns/exportSchema.test.js | 85 +++++++ test/unit/dns/guard.test.js | 66 ++++++ test/unit/dns/normalize.test.js | 80 +++++++ test/unit/dns/portalClient.test.js | 149 ++++++++++++ test/unit/dns/transform.test.js | 220 ++++++++++++++++++ 52 files changed, 3733 insertions(+) create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - pos-cli-dns-portal-to-portal-DNS-migration-command-group-AWS-PPDNS-OCI-private-stacks.md create mode 100644 backlog/tasks/task-1.1 - DnsPortalClient-per-portal-API-client-with-typed-errors.md create mode 100644 backlog/tasks/task-1.10 - dns-migrate-bulk-backup-dir-overwrites-all-cohort-backups-when-the-directory-does-not-exist.md create mode 100644 backlog/tasks/task-1.11 - dns-compare-support-drop-value-so-migrations-that-dropped-records-can-verify-clean.md create mode 100644 backlog/tasks/task-1.12 - dns-import-migrate-json-with-interactive-confirmation-prompts-without-showing-the-plan.md create mode 100644 backlog/tasks/task-1.13 - dns-unify-and-document-exit-codes-across-import-migrate-single-and-bulk-modes.md create mode 100644 backlog/tasks/task-1.14 - dns-CLI-polish-batch-from-code-review-spinner-double-print-URL-errors-error-propagation-ambient-env-helper-dedupe.md create mode 100644 backlog/tasks/task-1.15 - dns-verify-portal-invariants-assumed-by-apply-polling-and-record-name-validation.md create mode 100644 backlog/tasks/task-1.2 - auth.js-resolvePortalContext-for-source-target-sides.md create mode 100644 backlog/tasks/task-1.3 - dns-export-command-versioned-export-schema.md create mode 100644 backlog/tasks/task-1.4 - normalize.js-transform.js-pure-record-transform-TDD.md create mode 100644 backlog/tasks/task-1.5 - dns-import-command-apply-engine-dry-run-destructive-guard-polling.md create mode 100644 backlog/tasks/task-1.6 - dns-migrate-command-cutover-instructions.md create mode 100644 backlog/tasks/task-1.7 - dns-compare-command-port-compare-golden.rb-classification.md create mode 100644 backlog/tasks/task-1.8 - bulk-mode-mapping.js-instances-file-mapping-file-wiring.md create mode 100644 backlog/tasks/task-1.9 - docs-integration-test-smoke-run.md create mode 100644 backlog/tasks/task-2 - Port-the-dns-inspect-skill-to-pos-cli-dns-inspect-doctor-command.md create mode 100755 bin/pos-cli-dns-compare.js create mode 100755 bin/pos-cli-dns-export.js create mode 100755 bin/pos-cli-dns-import.js create mode 100755 bin/pos-cli-dns-migrate.js create mode 100755 bin/pos-cli-dns-status.js create mode 100755 bin/pos-cli-dns.js create mode 100644 lib/dns/apply.js create mode 100644 lib/dns/auth.js create mode 100644 lib/dns/cliHelpers.js create mode 100644 lib/dns/compare.js create mode 100644 lib/dns/cutover.js create mode 100644 lib/dns/export.js create mode 100644 lib/dns/exportSchema.js create mode 100644 lib/dns/guard.js create mode 100644 lib/dns/normalize.js create mode 100644 lib/dns/plan.js create mode 100644 lib/dns/portalClient.js create mode 100644 lib/dns/transform.js create mode 100644 test/integration/dns.test.js create mode 100644 test/unit/dns/apply.test.js create mode 100644 test/unit/dns/auth.test.js create mode 100644 test/unit/dns/cliHelpers.test.js create mode 100644 test/unit/dns/compare.test.js create mode 100644 test/unit/dns/cutover.test.js create mode 100644 test/unit/dns/exportSchema.test.js create mode 100644 test/unit/dns/guard.test.js create mode 100644 test/unit/dns/normalize.test.js create mode 100644 test/unit/dns/portalClient.test.js create mode 100644 test/unit/dns/transform.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 87dd93bb..6a8edaf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### New Features + +* `pos-cli dns` — new command group for migrating an instance's custom domains and DNS records between Partner Portal deployments (e.g. from `partners.platformos.com` to a private-stack portal). `dns export` snapshots all domains/records to a versioned JSON backup; `dns migrate ` copies them portal-to-portal (dry-run support, destructive-change guard, always writes a backup first, source portal is read-only) and prints per-domain cutover instructions (registrar nameservers for `domain-full`, SSL validation records + CNAME/A targets for `domain-external`); `dns import --file` applies a saved export; `dns status` shows each domain's provisioning status and pending cutover steps; `dns compare` verifies parity between the two sides and exits non-zero on real differences while filtering expected cross-stack noise. +* Safety defaults for `dns migrate`/`dns import`: the plan is displayed and must be confirmed interactively before anything is applied (`--yes` to skip in scripts/CI), and `partners.platformos.com` is protected as read-only — it can only be a migration source, so a `migrate ` argument swap fails immediately (`--unsafe-allow-protected-target` to override). +* Code-review hardening for the `dns` commands: `dns compare` accepts `--drop-value` so migrations that intentionally dropped records verify clean; `--json` runs refuse a blind interactive confirmation (pass `--yes` or `--dry-run`); exit codes are documented (0/1/2/3); scheme-less portal URLs get an actionable error; dns commands never read the ambient `PARTNER_PORTAL_HOST` env var (a process-global could point both sides of a two-portal command at the same place). + + ## 6.2.4 (2026-07-24) ### Fixes @@ -7,6 +16,7 @@ * `pos-cli-mcp` no longer crashes the entire server when a single tool hits a fatal error. The MCP server runs all tools in one process, so a fatal `logger.Error` — which calls `process.exit(1)` — used to tear down the whole server and every tool it exposed, forcing a reconnect. Under the MCP server, fatal errors now throw instead of exiting, so the per-request handler returns a clean error and the server stays up. Standalone CLI behavior is unchanged (it still exits on fatal errors). The most common trigger — a tool called with an unresolvable environment — now returns a caught error instead of killing the server. +## 6.2.3 (2026-07-23) ### Fixes diff --git a/README.md b/README.md index 7461f487..6dc20499 100644 --- a/README.md +++ b/README.md @@ -685,6 +685,62 @@ Example: This command manually runs a specific migration script that updates an admin password in the staging environment. +### DNS (migrating domains between Partner Portals) + +The `dns` commands move an instance's custom domains and DNS records between two Partner Portal deployments (for example from `partners.platformos.com` to a private-stack portal). They talk to both portals' `/api/domains` API using the tokens stored in your `.pos` environments (each environment remembers its own `partner_portal_url`), so both the source and the target environment must be registered with `pos-cli env add` — or you can pass `--source-*`/`--target-*` flags (`-portal-url`, `-token`, `-email`, `-instance-uuid`) instead. + +Only your own records are migrated: platform-managed DNS (load-balancer targets, SSL validation entries, the www-redirect record) is re-created by the target portal automatically. + +#### Export + +Save all domains and DNS records of an instance to a versioned JSON file — a backup/audit artifact that `import` and `compare` also accept as input: + + pos-cli dns export [environment] + pos-cli dns export [environment] -o backup.json + +#### Migrate + +Export from the source portal, transform, apply to the target portal, and print per-domain cutover instructions (which nameservers or verification records to set where). The source portal is only ever read, and a backup export file is always written before anything is applied: + + pos-cli dns migrate [sourceEnv] [targetEnv] --dry-run # review the plan first + pos-cli dns migrate [sourceEnv] [targetEnv] + +After the plan is displayed you are asked to confirm before anything is applied — pass `--yes` to skip the prompt (required in scripts/CI). `partners.platformos.com` is protected as read-only: it can only ever be a migration *source*, so an accidental argument swap (`migrate target source`) fails immediately instead of writing to the legacy production portal. + +Domains end up in `ownership_verification_pending` until you complete the printed cutover steps (repoint nameservers at your registrar for `domain-full`, or add the verification records and repoint your CNAME/A for `domain-external`). That state is expected before cutover, not an error. + +Useful options: `--domain ` (repeatable) migrates selected domains only; `--confirm-destructive` is required when an update would delete many records already managed on the target. + +#### Status + +Show each provisioned domain's status and any pending cutover steps (in case they were missed after a migrate): + + pos-cli dns status [environment] + pos-cli dns status [environment] --domain example.com + +#### Import + +Apply a previously exported file to a portal (same transform and safety rules as `migrate`): + + pos-cli dns import [environment] --file backup.json --dry-run + pos-cli dns import [environment] --file backup.json + +#### Compare + +Verify DNS parity between the two sides — exits non-zero when a domain differs in a way that matters (status, setup type, record intent). Expected cross-stack differences (data centers, nameservers, MX case, TXT chunking) are filtered out; `--raw` compares byte-for-byte instead: + + pos-cli dns compare [sourceEnv] [targetEnv] + pos-cli dns compare [sourceEnv] [targetEnv] --ignore-status # before cutover + pos-cli dns compare --source-file before.json --target-file after.json # offline + +If the migration dropped records with `--drop-value`, pass the same patterns to `compare` so those records are excluded from the parity check instead of reporting as missing forever. + +The intended sequence is: `migrate` → complete the cutover steps → refresh validation from the target portal → `compare` comes back clean. + +#### Exit codes + +`import` and `migrate`: `0` success, `1` apply errors, `2` transform errors, `3` only destructive-blocked domains remain. `compare`: `0` parity, `1` any CRITICAL or missing domain. Dry-runs exit `0` (or `2` when the plan contains transform errors). + ### Data #### Export diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 00000000..3e82f1eb --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,15 @@ +project_name: "pos-cli" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +date_format: yyyy-mm-dd +max_column_width: 20 +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +filesystem_only: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - pos-cli-dns-portal-to-portal-DNS-migration-command-group-AWS-PPDNS-OCI-private-stacks.md b/backlog/tasks/task-1 - pos-cli-dns-portal-to-portal-DNS-migration-command-group-AWS-PPDNS-OCI-private-stacks.md new file mode 100644 index 00000000..bb2fd3db --- /dev/null +++ b/backlog/tasks/task-1 - pos-cli-dns-portal-to-portal-DNS-migration-command-group-AWS-PPDNS-OCI-private-stacks.md @@ -0,0 +1,34 @@ +--- +id: TASK-1 +title: >- + pos-cli dns: portal-to-portal DNS migration command group (AWS/PPDNS -> OCI + private stacks) +status: Done +assignee: [] +created_date: '2026-07-23 09:12' +updated_date: '2026-07-23 14:48' +labels: + - dns +dependencies: [] +priority: high +ordinal: 1000 +--- + +## Description + + +Umbrella task. Customers migrating instances from the legacy AWS shared stack (partners.platformos.com, frozen/read-only) to OCI private-stack portals need DNS records migrated. Build pos-cli dns export/import/migrate/compare doing API-to-API migration via both portals' /api/domains (contracts verified compatible; .pos Doorkeeper tokens authenticate). extra_dns_records contains ONLY customer records - no platform-record filtering needed; transform = validation/normalization + www-redirect handling. After import, print per-domain cutover instructions (domain-full: NS repoint; domain-external: DCV records + CNAME/A retarget). Full design: partner-portal repo plan /home/godot/.claude/plans/ppdns-is-still-running-wondrous-hellman.md + + +## Acceptance Criteria + +- [x] #1 R1 pre-check done: authenticated GET /api/domains?version=2 against deployed partners.platformos.com confirms v2 attrs (or v1 fallback implemented) +- [x] #2 All child tasks completed; npm run test:unit green +- [x] #3 Manual smoke: export from partners.platformos.com, dry-run + live migrate onto ps-pos01 test instance, compare --ignore-status clean + + +## Implementation Notes + + +Final validation 2026-07-23 after TASK-50/51 (+81058 follow-up, commit 0e68866b) deployed to ps-pos01: migrate aws->ps succeeded end-to-end against the CF-restored zone (APPLIED, last_operation_status 'apply completed', reparking_domain = expected pre-NS-cutover, cutover instructions printed curt/mina.ns.cloudflare.com); zone has 18/18 unique records (adoption, no duplicates); compare aws ps --ignore-status: Critical 0, only advisory = status ready vs reparking_domain (expected until registrar NS cutover). 83 unit tests green. Live-details compare now normalizes CF read-side noise (TXT quoting, hostname case). + diff --git a/backlog/tasks/task-1.1 - DnsPortalClient-per-portal-API-client-with-typed-errors.md b/backlog/tasks/task-1.1 - DnsPortalClient-per-portal-API-client-with-typed-errors.md new file mode 100644 index 00000000..5ef32318 --- /dev/null +++ b/backlog/tasks/task-1.1 - DnsPortalClient-per-portal-API-client-with-typed-errors.md @@ -0,0 +1,25 @@ +--- +id: TASK-1.1 +title: 'DnsPortalClient: per-portal API client with typed errors' +status: Done +assignee: [] +created_date: '2026-07-23 09:12' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: [] +parent_task_id: TASK-1 +priority: high +ordinal: 2000 +--- + +## Description + + +lib/dns/portalClient.js wrapping lib/apiRequest.js with explicit {baseUrl, token, readOnly}. Methods: static authenticate (POST /api/authenticate), listInstances, searchInstances({domain}), listDomains(uuid,{version:2}), getDomain, upsertDomain, refreshDomain. Typed errors: PortalAuthError (401), PortalAccessError (instance WRITE missing - message names portal+uuid+fix), DestructiveChangeError (422 body prefix 'Destructive DNS change blocked'). readOnly:true makes POST methods throw (old-stack safety invariant). Do NOT touch lib/portal.js. + + +## Acceptance Criteria + +- [x] #1 nock tests: Bearer header, version=2 query, 401->PortalAuthError, 422 destructive->DestructiveChangeError, readOnly POST throws + diff --git a/backlog/tasks/task-1.10 - dns-migrate-bulk-backup-dir-overwrites-all-cohort-backups-when-the-directory-does-not-exist.md b/backlog/tasks/task-1.10 - dns-migrate-bulk-backup-dir-overwrites-all-cohort-backups-when-the-directory-does-not-exist.md new file mode 100644 index 00000000..853d57cb --- /dev/null +++ b/backlog/tasks/task-1.10 - dns-migrate-bulk-backup-dir-overwrites-all-cohort-backups-when-the-directory-does-not-exist.md @@ -0,0 +1,43 @@ +--- +id: TASK-1.10 +title: >- + dns migrate: bulk --backup overwrites all cohort backups when the + directory does not exist +status: Done +assignee: [] +created_date: '2026-07-23 19:05' +updated_date: '2026-07-24 09:55' +labels: + - code-review + - dns +dependencies: [] +references: + - bin/pos-cli-dns-migrate.js + - bin/pos-cli-dns-export.js +parent_task_id: TASK-1 +priority: high +ordinal: 12000 +--- + +## Description + + +Found in code review of the dns-migration branch. + +In bulk cohort mode (--mapping-file/--instances-file), `backupPathFor` (bin/pos-cli-dns-migrate.js:23-28) only treats the --backup value as a directory if it already exists on disk. When the operator passes `--backup exports/` and that directory does not exist yet, every pair resolves to the same literal file path and each instance's source export overwrites the previous one — a cohort migration finishes with one backup instead of N, silently. The backup is the rollback artifact of the migration tool, so silent loss matters. + +`dns export` already handles this correctly: in bulk mode it unconditionally treats -o as a directory and creates it (bin/pos-cli-dns-export.js:51-52). Migrate's bulk mode should behave the same way. + + +## Acceptance Criteria + +- [x] #1 In bulk mode, --backup produces one backup file per source instance whether or not existed beforehand +- [x] #2 Single-pair behavior (default filename, explicit file path, existing directory) is unchanged +- [x] #3 A test covers bulk backup with a not-yet-existing directory + + +## Implementation Notes + + +Fix validated, but bulk mode itself was PARKED 2026-07-24 on the dns-migration-bulk branch (not in the current release). The fix ships with bulk whenever that branch lands; single-mode backupPathFor behavior is unaffected. + diff --git a/backlog/tasks/task-1.11 - dns-compare-support-drop-value-so-migrations-that-dropped-records-can-verify-clean.md b/backlog/tasks/task-1.11 - dns-compare-support-drop-value-so-migrations-that-dropped-records-can-verify-clean.md new file mode 100644 index 00000000..9d783052 --- /dev/null +++ b/backlog/tasks/task-1.11 - dns-compare-support-drop-value-so-migrations-that-dropped-records-can-verify-clean.md @@ -0,0 +1,45 @@ +--- +id: TASK-1.11 +title: >- + dns compare: support --drop-value so migrations that dropped records can + verify clean +status: Done +assignee: [] +created_date: '2026-07-23 19:05' +updated_date: '2026-07-23 19:20' +labels: + - code-review + - dns +dependencies: [] +references: + - bin/pos-cli-dns-compare.js + - lib/dns/compare.js + - lib/dns/transform.js +parent_task_id: TASK-1 +priority: medium +ordinal: 13000 +--- + +## Description + + +Found in code review of the dns-migration branch. + +`dns migrate` and `dns import` accept a repeatable `--drop-value ` option to intentionally drop records during the transform, but `dns compare` runs the transform without drop patterns (lib/dns/compare.js:57 calls transformDomain with no dropValuePatterns; bin/pos-cli-dns-compare.js has no such option). Any record dropped at migration time is therefore reported as CRITICAL "missing on target" forever, and compare exits 1. + +The documented workflow (README dns section: "migrate → complete the cutover steps → compare comes back clean") can never succeed for domains migrated with --drop-value, which also breaks CI usage of compare's exit code. + + +## Acceptance Criteria + +- [x] #1 dns compare accepts the same repeatable --drop-value option as migrate/import and excludes matching source records from the intent comparison +- [x] #2 A migration performed with --drop-value X followed by compare with --drop-value X reports OK for the affected domains +- [x] #3 README compare section documents the option +- [x] #4 Unit tests cover drop-value filtering in compare + + +## Implementation Notes + + +compareInstance accepts dropValuePatterns, applied symmetrically in intentFor (same path as the www-redirect drop). --drop-value wired into both compare modes (single + --mapping-file bulk). README compare section documents it. Test: CRITICAL without pattern, OK with the migration's pattern. + diff --git a/backlog/tasks/task-1.12 - dns-import-migrate-json-with-interactive-confirmation-prompts-without-showing-the-plan.md b/backlog/tasks/task-1.12 - dns-import-migrate-json-with-interactive-confirmation-prompts-without-showing-the-plan.md new file mode 100644 index 00000000..b46b3390 --- /dev/null +++ b/backlog/tasks/task-1.12 - dns-import-migrate-json-with-interactive-confirmation-prompts-without-showing-the-plan.md @@ -0,0 +1,44 @@ +--- +id: TASK-1.12 +title: >- + dns import/migrate: --json with interactive confirmation prompts without + showing the plan +status: Done +assignee: [] +created_date: '2026-07-23 19:05' +updated_date: '2026-07-23 19:20' +labels: + - code-review + - dns +dependencies: [] +references: + - bin/pos-cli-dns-import.js + - bin/pos-cli-dns-migrate.js + - lib/dns/guard.js +parent_task_id: TASK-1 +priority: medium +ordinal: 14000 +--- + +## Description + + +Found in code review of the dns-migration branch. + +Plan rendering is gated on `!params.json` (bin/pos-cli-dns-import.js:69-72, bin/pos-cli-dns-migrate.js:67-69), but the confirmApply gate still prompts when running interactively without --yes (import.js:84, migrate.js:188). An interactive run with --json therefore asks "Apply these DNS changes to ?" without ever having displayed what would be applied — a blind confirmation in a tool whose safety model is plan-then-confirm. + +Two reasonable resolutions, pick one: emit the plan as JSON before prompting, or refuse --json without --yes/--dry-run with an actionable message (mirroring the existing non-TTY refusal in lib/dns/guard.js confirmApply). + + +## Acceptance Criteria + +- [x] #1 An interactive --json run is never asked for confirmation without the plan having been output first (or the run is refused with an actionable message) +- [x] #2 Non-interactive, --yes, and --dry-run behavior is unchanged +- [x] #3 Tests cover the json + interactive confirmation path + + +## Implementation Notes + + +Chose the refusal resolution: confirmApply({json}) throws 'With --json there is no plan review to confirm — pass --yes to apply, or --dry-run to preview the plan as JSON' before any prompt. Non-interactive/--yes/--dry-run paths unchanged (guard tests cover all branches). + diff --git a/backlog/tasks/task-1.13 - dns-unify-and-document-exit-codes-across-import-migrate-single-and-bulk-modes.md b/backlog/tasks/task-1.13 - dns-unify-and-document-exit-codes-across-import-migrate-single-and-bulk-modes.md new file mode 100644 index 00000000..bf03bbd0 --- /dev/null +++ b/backlog/tasks/task-1.13 - dns-unify-and-document-exit-codes-across-import-migrate-single-and-bulk-modes.md @@ -0,0 +1,45 @@ +--- +id: TASK-1.13 +title: 'dns: unify and document exit codes across import/migrate single and bulk modes' +status: Done +assignee: [] +created_date: '2026-07-23 19:05' +updated_date: '2026-07-23 19:20' +labels: + - code-review + - dns +dependencies: [] +references: + - bin/pos-cli-dns-migrate.js + - bin/pos-cli-dns-import.js + - README.md +parent_task_id: TASK-1 +priority: low +ordinal: 15000 +--- + +## Description + + +Found in code review of the dns-migration branch. + +The dns commands have a deliberate exit-code contract for scripts/CI — 0 success, 1 apply/compare failures, 2 transform errors, 3 blocked-destructive — but it is applied inconsistently and documented nowhere: + +- Single-pair migrate exits 2 on transform errors (bin/pos-cli-dns-migrate.js:191), and import does the same, but bulk migrate collapses the same condition to 1 (bin/pos-cli-dns-migrate.js:253, `anyTransformErrors ? 1 : ...`). +- The README dns section only mentions "exit 1 on CRITICAL" for compare; the 0/1/2/3 contract for import/migrate is undocumented. + +Scripts driving cohort migrations need the same condition to yield the same code in both modes. + + +## Acceptance Criteria + +- [x] #1 The same failure condition produces the same exit code in single-pair and bulk migrate modes +- [x] #2 Exit codes for import, migrate, and compare are documented in the README dns section +- [x] #3 Tests assert exit codes for the transform-error and blocked-destructive paths + + +## Implementation Notes + + +exitCodeFor + exitCodeForOutcomes in lib/dns/cliHelpers.js; bulk migrate now maps pair-level transform errors to 2 like single mode (1 outranks 2 outranks 3 when mixed). README dns section documents 0/1/2/3 for import/migrate and 0/1 for compare. Contract tests in cliHelpers.test.js. + diff --git a/backlog/tasks/task-1.14 - dns-CLI-polish-batch-from-code-review-spinner-double-print-URL-errors-error-propagation-ambient-env-helper-dedupe.md b/backlog/tasks/task-1.14 - dns-CLI-polish-batch-from-code-review-spinner-double-print-URL-errors-error-propagation-ambient-env-helper-dedupe.md new file mode 100644 index 00000000..50d9dd71 --- /dev/null +++ b/backlog/tasks/task-1.14 - dns-CLI-polish-batch-from-code-review-spinner-double-print-URL-errors-error-propagation-ambient-env-helper-dedupe.md @@ -0,0 +1,56 @@ +--- +id: TASK-1.14 +title: >- + dns: CLI polish batch from code review (spinner double-print, URL errors, + error propagation, ambient env, helper dedupe) +status: Done +assignee: [] +created_date: '2026-07-23 19:06' +updated_date: '2026-07-23 19:20' +labels: + - code-review + - dns +dependencies: [] +references: + - bin/pos-cli-dns-export.js + - lib/dns/guard.js + - lib/dns/auth.js + - lib/dns/mapping.js + - bin/pos-cli-dns-import.js + - bin/pos-cli-dns-migrate.js +parent_task_id: TASK-1 +priority: low +ordinal: 16000 +--- + +## Description + + +Batch of small, independent findings from the dns-migration branch code review, all within the dns command group — one focused PR: + +1. bin/pos-cli-dns-export.js:71 — `spinner.stopAndPersist().succeed(...)` prints two lines per instance (the persisted "Exporting " frame plus the ✓ success line); a single `spinner.succeed(...)` is intended. + +2. A scheme-less portal URL (e.g. `--portal-url portal.example.com`) dies with a bare "Invalid URL" thrown by `new URL()` (lib/dns/guard.js:8 assertWritablePortal, lib/dns/auth.js:94 resolveInstanceUuid). Emit a friendly message telling the user the portal URL must include the scheme (https://). + +3. lib/dns/mapping.js:68 — matchByDomain swallows searchInstances failures via `.catch(() => null)`, so a transient network/portal error is misreported as "no instance on has any of the domains". It fails safe (the pair is skipped, nothing written) but the diagnosis is wrong; lookup failures should be distinguishable from genuine no-match. + +4. lib/dns/auth.js:43 — the `PARTNER_PORTAL_HOST` env fallback applies to BOTH source and target sides, which sits oddly next to the same file's design comment that dns commands deliberately avoid ambient env so source and target never silently resolve to the same place; the same-portal+same-uuid check in migrate only guards non-bulk mode. Decide whether the env fallback should apply to dns commands at all, and align code and comment. + +5. `collect`, `filterByDomains`, and `exitCodeFor` are duplicated between bin/pos-cli-dns-import.js and bin/pos-cli-dns-migrate.js — repo convention (CLAUDE.md) is thin bin files with shared logic in lib/, e.g. a lib/dns/cli-helpers.js. + + +## Acceptance Criteria + +- [x] #1 dns export prints exactly one line per exported instance +- [x] #2 A scheme-less --portal-url value produces an actionable error message instead of a bare 'Invalid URL' +- [x] #3 matchByDomain reports portal lookup failures distinctly from 'no matching instance' +- [x] #4 The PARTNER_PORTAL_HOST fallback behavior for dns commands is decided and the code and design comment in lib/dns/auth.js agree +- [x] #5 Helpers shared by the dns bin files live in lib/dns instead of being duplicated +- [x] #6 Behavior changes are covered by tests + + +## Implementation Notes + + +1) export uses spinner.succeed() - one line per instance (visual, verified by smoke). 2) hostnameOf() in cliHelpers wraps URL parsing with 'must include the scheme, e.g. https://...' - used for portal urls (resolvePortalContext + guard) and instance urls (resolveInstanceUuid); tested. 3) matchByDomain rethrows searchInstances failures as 'instance lookup for on failed: ...' - tested. 4) DECIDED: dns commands never read PARTNER_PORTAL_HOST (removed the fallback; design comment updated; test asserts the ambient var is ignored). 5) collect/filterByDomains/exitCodeFor/backupPathFor deduped into lib/dns/cliHelpers.js, all four bins import them. 6) all behavior changes tested except the cosmetic spinner fix. + diff --git a/backlog/tasks/task-1.15 - dns-verify-portal-invariants-assumed-by-apply-polling-and-record-name-validation.md b/backlog/tasks/task-1.15 - dns-verify-portal-invariants-assumed-by-apply-polling-and-record-name-validation.md new file mode 100644 index 00000000..ea891c1d --- /dev/null +++ b/backlog/tasks/task-1.15 - dns-verify-portal-invariants-assumed-by-apply-polling-and-record-name-validation.md @@ -0,0 +1,44 @@ +--- +id: TASK-1.15 +title: >- + dns: verify portal invariants assumed by apply polling and record-name + validation +status: Done +assignee: [] +created_date: '2026-07-23 19:06' +updated_date: '2026-07-23 19:20' +labels: + - code-review + - dns +dependencies: [] +references: + - lib/dns/apply.js + - lib/dns/transform.js +parent_task_id: TASK-1 +priority: low +ordinal: 17000 +--- + +## Description + + +Two open questions from the dns-migration branch code review — verify each against the partner-portal backend (or actual portal behavior) and fix the pos-cli side only where the assumption does not hold: + +1. lib/dns/apply.js:8 — `settled()` requires `locked === false` in the GET /api/domains/:name response. If any supported private-stack target portal omits the `locked` field, every apply polls to the full timeout (120s per domain by default) and reports stillProcessing even for completed provisions — painful in bulk cohort runs. Confirm all target portal versions in scope return `locked`, or make settled() tolerant of its absence. + +2. lib/dns/transform.js:6 — NAME_FORMAT (/^[a-z0-9_.-]*$/) rejects `*`, so a wildcard DNS record (e.g. *.example.com) fails validation and blocks the whole domain's transform. The comment says types mirror the portal's Api::Record model; confirm the target portal's record-name validation actually rejects wildcards. If the portal accepts them, transform must allow them through; if it rejects them, the current early failure with a clear message is correct. + + +## Acceptance Criteria + +- [x] #1 Both invariants are verified against portal code or live behavior, with the findings recorded in this task's notes +- [x] #2 settled() polling behaves correctly (no poll-to-timeout on settled domains) for every supported target portal version +- [x] #3 Wildcard record handling in the transform matches what the target portal actually accepts +- [x] #4 Any code change is covered by unit tests + + +## Implementation Notes + + +VERIFIED against partner-portal private-stack code: (1) locked is ALWAYS emitted - app/models/domain_status.rb:20 'locked: processing?' where processing? (line 89) returns dns_provision&.locked_at.present? || false, i.e. always boolean, both v1/v2 shapes; and only private-stack portals can be apply targets (legacy portal is write-protected). Invariant holds; settled() nevertheless hardened to !status.locked so a missing field settles instead of polling to timeout (tested). (2) Wildcards: app/models/api/record.rb:57 validate_name_format /\A[a-z0-9_\.-]*\z/ rejects '*' - the portal 422s wildcard names, so the transform's early per-record error is the correct matching behavior; no change. + diff --git a/backlog/tasks/task-1.2 - auth.js-resolvePortalContext-for-source-target-sides.md b/backlog/tasks/task-1.2 - auth.js-resolvePortalContext-for-source-target-sides.md new file mode 100644 index 00000000..fb07067b --- /dev/null +++ b/backlog/tasks/task-1.2 - auth.js-resolvePortalContext-for-source-target-sides.md @@ -0,0 +1,32 @@ +--- +id: TASK-1.2 +title: 'auth.js: resolvePortalContext for source/target sides' +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.1 +parent_task_id: TASK-1 +priority: high +ordinal: 3000 +--- + +## Description + + +lib/dns/auth.js resolvePortalContext(envName, {portalUrl, token, email, instanceUuid, label}). Resolution: .pos via fetchSettings (partner_portal_url + token) -> flag overrides -> email/password prompt fallback (JWT via authenticate, never persisted). Token smoke-test via listInstances. Instance UUID: flag, else searchInstances({domain: hostname(settings.url)}); ambiguous -> list candidates and exit. + + +## Acceptance Criteria + +- [x] #1 tests: .pos path, flag overrides, ambiguous-uuid error, prompt fallback on 401 + + +## Implementation Notes + + +All covered in test/unit/dns/auth.test.js incl. the 401 -> email/password prompt fallback (test added during AC review 2026-07-23). + diff --git a/backlog/tasks/task-1.3 - dns-export-command-versioned-export-schema.md b/backlog/tasks/task-1.3 - dns-export-command-versioned-export-schema.md new file mode 100644 index 00000000..cf030741 --- /dev/null +++ b/backlog/tasks/task-1.3 - dns-export-command-versioned-export-schema.md @@ -0,0 +1,26 @@ +--- +id: TASK-1.3 +title: dns export command + versioned export schema +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.2 +parent_task_id: TASK-1 +priority: high +ordinal: 4000 +--- + +## Description + + +lib/dns/exportSchema.js: envelope schema 'pos-cli/dns-export/v1' {schema, exported_at, portal_url, api_version, instance{uuid,url,env}, domains[] verbatim minus details.state (old-stack Terraform blob)}; buildEnvelope/stripBulkyDetails/validateEnvelope. bin/pos-cli-dns.js group + bin/pos-cli-dns-export.js leaf; register in bin/pos-cli.js + package.json bin map. Flags: -o out|dir, --instance-uuid, --api-version (v2 default, auto-fallback v1 deriving name from config._domains[0]), --raw. Export doubles as backup/audit artifact and fixture-capture tool. + + +## Acceptance Criteria + +- [x] #1 export against a live portal produces valid envelope; details.state stripped; unknown-major rejected on read + diff --git a/backlog/tasks/task-1.4 - normalize.js-transform.js-pure-record-transform-TDD.md b/backlog/tasks/task-1.4 - normalize.js-transform.js-pure-record-transform-TDD.md new file mode 100644 index 00000000..54e43095 --- /dev/null +++ b/backlog/tasks/task-1.4 - normalize.js-transform.js-pure-record-transform-TDD.md @@ -0,0 +1,32 @@ +--- +id: TASK-1.4 +title: 'normalize.js + transform.js: pure record transform (TDD)' +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.3 +parent_task_id: TASK-1 +priority: high +ordinal: 5000 +--- + +## Description + + +TESTS FIRST. lib/dns/normalize.js (TXT unquote + 255-byte chunk join, MX host case-fold, name lowercase, record sort - shared with compare). lib/dns/transform.js: transformDomain(sourceDomain,{targetInstanceUuid}) -> {payload, kept, dropped:[{record,reason}], errors, warnings}; transformEnvelope; deriveEnableWwwRedirect/deriveSetupType/deriveUseAsDefault/primaryDomains. Source of truth = attributes.config.extra_dns_records (customer records ONLY - no platform filtering). Only DROP rule: www-> CNAME when enable_www_redirect derives true. Old-infra values (elb.amazonaws.com etc) KEEP + warn; --drop-value regex escape hatch. Validate to new-stack rules: type whitelist, multi-value CNAME/ALIAS hard error, MX/SRV shape, proxied:false default, ttl 3600 default. + + +## Acceptance Criteria + +- [x] #1 failing tests written first over sanitized fixtures; www-redirect drop only when derived true; old-infra kept+warned; multi-value CNAME errors; transform idempotent + + +## Implementation Notes + + +TDD red run (module-not-found) before implementation; all AC cases in transform.test.js/normalize.test.js incl. idempotency test added during AC review 2026-07-23. + diff --git a/backlog/tasks/task-1.5 - dns-import-command-apply-engine-dry-run-destructive-guard-polling.md b/backlog/tasks/task-1.5 - dns-import-command-apply-engine-dry-run-destructive-guard-polling.md new file mode 100644 index 00000000..be995fc3 --- /dev/null +++ b/backlog/tasks/task-1.5 - dns-import-command-apply-engine-dry-run-destructive-guard-polling.md @@ -0,0 +1,26 @@ +--- +id: TASK-1.5 +title: 'dns import command + apply engine (dry-run, destructive guard, polling)' +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.4 +parent_task_id: TASK-1 +priority: high +ordinal: 6000 +--- + +## Description + + +lib/dns/plan.js (KEEP/DROP/ERROR dry-run table, chalk+text-table). lib/dns/apply.js applyPlans({client, plans, confirmDestructive, wait}): sequential POSTs (one provision worker each - no stampede), NEVER auto-set confirm_destructive; DestructiveChangeError -> 'blocked-destructive' + suggest --confirm-destructive, no retry; poll getDomain every 5s until locked===false && status!=='initializing' (cap 120s); ownership_verification_pending = expected success pre-cutover. bin/pos-cli-dns-import.js: --file --dry-run --confirm-destructive --domain --no-wait --json. + + +## Acceptance Criteria + +- [x] #1 dry-run POSTs nothing, exit 2 on transform errors; destructive 422 blocked without auto-retry; idempotent re-run + diff --git a/backlog/tasks/task-1.6 - dns-migrate-command-cutover-instructions.md b/backlog/tasks/task-1.6 - dns-migrate-command-cutover-instructions.md new file mode 100644 index 00000000..36190781 --- /dev/null +++ b/backlog/tasks/task-1.6 - dns-migrate-command-cutover-instructions.md @@ -0,0 +1,26 @@ +--- +id: TASK-1.6 +title: dns migrate command + cutover instructions +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.5 +parent_task_id: TASK-1 +priority: high +ordinal: 7000 +--- + +## Description + + +lib/dns/cutover.js cutoverInstructions(targetDomain): domain-full -> registrar NS list from details.dns_zone_name_servers; domain-external -> DCV records from details.dns_verification_records/dcv_delegation_record + point CNAME to details.private_lb_cname (apex -> A lb_public_ip); print status/substatus plain-English meaning; call refreshDomain after apply. bin/pos-cli-dns-migrate.js [srcEnv] [tgtEnv]: source client constructed readOnly; backup file ALWAYS written before any POST (--backup, --no-backup); --dry-run --confirm-destructive --domain --source/target-{portal-url,token,email,instance-uuid}. Mirrors clone-init two-env UX. + + +## Acceptance Criteria + +- [x] #1 migrate on test instance prints correct cutover block per setup_type; backup written before first POST + diff --git a/backlog/tasks/task-1.7 - dns-compare-command-port-compare-golden.rb-classification.md b/backlog/tasks/task-1.7 - dns-compare-command-port-compare-golden.rb-classification.md new file mode 100644 index 00000000..f52bbb0c --- /dev/null +++ b/backlog/tasks/task-1.7 - dns-compare-command-port-compare-golden.rb-classification.md @@ -0,0 +1,26 @@ +--- +id: TASK-1.7 +title: 'dns compare command: port compare-golden.rb classification' +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-23 18:27' +labels: + - dns +dependencies: + - TASK-1.4 +parent_task_id: TASK-1 +priority: medium +ordinal: 8000 +--- + +## Description + + +lib/dns/compare.js compareInstance(src,tgt,{transform:true,ignoreStatus}) -> {results, totals}; levels OK/ADVISORY/CRITICAL/MISSING_BEFORE/MISSING_AFTER. Default cross-stack mode: source normalized through transform first, data_center mismatch skipped; --raw = exact golden-file semantics (partner-portal scripts/pp-dns/ps-sg/compare-golden.rb). CRITICAL: status (unless --ignore-status), setup_type, intent records after normalization, verification-record shape/value. ADVISORY: live-vs-intent drift, NS churn, has_pending. Exit 1 on CRITICAL/MISSING. bin/pos-cli-dns-compare.js: --source-file/--target-file offline mode, --domain, --raw, --ignore-status, --json. + + +## Acceptance Criteria + +- [x] #1 fixture pair per classification branch incl. MX case-fold => OK and TXT chunk-join => OK; exit-code contract + diff --git a/backlog/tasks/task-1.8 - bulk-mode-mapping.js-instances-file-mapping-file-wiring.md b/backlog/tasks/task-1.8 - bulk-mode-mapping.js-instances-file-mapping-file-wiring.md new file mode 100644 index 00000000..0cd76f59 --- /dev/null +++ b/backlog/tasks/task-1.8 - bulk-mode-mapping.js-instances-file-mapping-file-wiring.md @@ -0,0 +1,32 @@ +--- +id: TASK-1.8 +title: 'bulk mode: mapping.js + --instances-file/--mapping-file wiring' +status: Done +assignee: [] +created_date: '2026-07-23 09:13' +updated_date: '2026-07-24 09:55' +labels: + - dns +dependencies: + - TASK-1.6 +parent_task_id: TASK-1 +priority: medium +ordinal: 9000 +--- + +## Description + + +lib/dns/mapping.js: CSV source_uuid,target_uuid[,label] or JSON array; --match-by-domain resolves target uuid via searchInstances (mismatch errors, never guesses). Sequential per-instance loop with small delay (sidekiq/CF pacing), per-instance backup in --backup dir, summary table label|domains|applied|blocked|errors; exit 1 any errors, exit 3 only-destructive-blocked. Wire --instances-file into export, --mapping-file into migrate/compare. + + +## Acceptance Criteria + +- [x] #1 cohort run produces per-instance backups + summary; exit codes distinguish errors vs destructive-blocked + + +## Implementation Notes + + +PARKED 2026-07-24: bulk cohort mode (mapping.js, --instances-file/--mapping-file/--match-by-domain) removed from the dns-migration release branch — preserved on the dns-migration-bulk branch for a later release. + diff --git a/backlog/tasks/task-1.9 - docs-integration-test-smoke-run.md b/backlog/tasks/task-1.9 - docs-integration-test-smoke-run.md new file mode 100644 index 00000000..afd118fc --- /dev/null +++ b/backlog/tasks/task-1.9 - docs-integration-test-smoke-run.md @@ -0,0 +1,32 @@ +--- +id: TASK-1.9 +title: docs + integration test + smoke run +status: Done +assignee: [] +created_date: '2026-07-23 09:14' +updated_date: '2026-07-23 09:44' +labels: + - dns +dependencies: + - TASK-1.8 +parent_task_id: TASK-1 +priority: medium +ordinal: 10000 +--- + +## Description + + +README command docs, CHANGELOG entry. Env-var-gated test/integration/dns.test.js: export->import->compare round-trip between two staging instances. Manual smoke per plan: export partners.platformos.com (read-only) -> migrate --dry-run -> live migrate onto ps-pos01 test instance (townhall.platformos.dev) -> compare --ignore-status -> post-cutover refresh + clean compare. + + +## Acceptance Criteria + +- [x] #1 README documents all four subcommands + migration sequence (migrate -> cutover -> refresh -> compare) + + +## Implementation Notes + + +README (DNS section before Data), CHANGELOG Unreleased entry, env-gated test/integration/dns.test.js done. Live-write smoke onto ps-pos01 (townhall instance) was blocked by the local sandbox permission classifier - needs an interactive run; offline dry-run + live read-only export against partners.platformos.com + self-compare all verified. + diff --git a/backlog/tasks/task-2 - Port-the-dns-inspect-skill-to-pos-cli-dns-inspect-doctor-command.md b/backlog/tasks/task-2 - Port-the-dns-inspect-skill-to-pos-cli-dns-inspect-doctor-command.md new file mode 100644 index 00000000..a999082c --- /dev/null +++ b/backlog/tasks/task-2 - Port-the-dns-inspect-skill-to-pos-cli-dns-inspect-doctor-command.md @@ -0,0 +1,25 @@ +--- +id: TASK-2 +title: Port the dns-inspect skill to pos-cli (dns inspect / doctor command) +status: To Do +assignee: [] +created_date: '2026-07-23 17:09' +labels: + - dns +dependencies: [] +priority: medium +ordinal: 11000 +--- + +## Description + + +Port partner-portal's in-repo dns-inspect skill (dig/curl battery, DNS provider detection, record-set variants per setup_type, CF error 1014 / cert / stuck-status triage) into pos-cli as e.g. `pos-cli dns inspect [--domain ]`. Unlike the skill, pos-cli has authenticated access to the REAL domain config via the portal /api/domains (setup_type, intent records, dns_verification_records, private_lb_cname/lb_public_ip, status/substatus) - so instead of inferring what the records SHOULD be, it can compare live public DNS (dig/DoH) against the portal's expected record set and produce precise, actionable instructions ('your _acme-challenge CNAME points at the OLD stack's DCV target, replace with X'). Building blocks already in lib/dns/: portalClient, cutover.js STATUS_MEANINGS, normalize.js, compare.js classification. Reference: partner-portal dns-inspect skill + docs/doc-1 (apex/www CH layout per setup_type and www-redirect mode). + + +## Acceptance Criteria + +- [ ] #1 pos-cli dns inspect [--domain] resolves live DNS (A/CNAME/NS/_acme-challenge) and diffs against the portal's expected records for the domain's setup_type +- [ ] #2 Detects the common failure signatures: CNAME pointing at old stack, missing/stale DCV records, NS not repointed (domain-full), CF 1014 cross-user, ECH/underscore Android quirks noted +- [ ] #3 Outputs per-domain verdict + copy-pasteable fix instructions; --json for support tooling + diff --git a/bin/pos-cli-dns-compare.js b/bin/pos-cli-dns-compare.js new file mode 100755 index 00000000..99cbbe1e --- /dev/null +++ b/bin/pos-cli-dns-compare.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import chalk from 'chalk'; + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { fetchDomains } from '../lib/dns/export.js'; +import { validateEnvelope } from '../lib/dns/exportSchema.js'; +import { compareInstance } from '../lib/dns/compare.js'; +import { collect } from '../lib/dns/cliHelpers.js'; + +const LEVEL_COLORS = { + OK: chalk.green, + ADVISORY: chalk.yellow, + CRITICAL: chalk.red, + MISSING_BEFORE: chalk.red, + MISSING_AFTER: chalk.red +}; + +// One side of the comparison: an export file (offline) or a live portal fetch. +const loadSide = async (envName, { file, portalUrl, token, email, instanceUuid, label }) => { + if (file) { + const envelope = validateEnvelope(JSON.parse(fs.readFileSync(file, 'utf8'))); + return { domains: envelope.domains, origin: `${file} (exported ${envelope.exported_at} from ${envelope.portal_url})` }; + } + const context = await resolvePortalContext(envName, { portalUrl, token, email, instanceUuid, label, readOnly: true }); + const { domains } = await fetchDomains(context.client, context.instanceUuid); + return { domains, origin: `${context.portalUrl} (instance ${context.instanceUuid})` }; +}; + +const renderResults = (results) => results.map(result => { + const paint = LEVEL_COLORS[result.level] || chalk.white; + const lines = [`${paint(result.level.padEnd(14))} ${result.domainName} ${result.status ? `[${result.status}]` : ''}`]; + for (const message of result.critical) lines.push(` ${message}`); + for (const message of result.advisory) lines.push(chalk.gray(` (advisory) ${message}`)); + return lines.join('\n'); +}).join('\n'); + +program.showHelpAfterError(); +program + .name('pos-cli dns compare') + .arguments('[sourceEnv]') + .arguments('[targetEnv]') + .option('--source-file ', 'compare from an export file instead of the live source portal') + .option('--target-file ', 'compare against an export file instead of the live target portal') + .option('--domain ', 'only compare this domain (repeatable)', collect, []) + .option('--drop-value ', 'ignore records whose value matches this pattern — use the same patterns the migration dropped (repeatable)', collect, []) + .option('--raw', 'exact same-stack semantics: no transform, include data_center/nameserver/DCV-value differences') + .option('--ignore-status', 'downgrade status mismatches to advisory (useful before cutover)') + .option('--source-portal-url ').option('--source-token ').option('--source-email ').option('--source-instance-uuid ') + .option('--target-portal-url ').option('--target-token ').option('--target-email ').option('--target-instance-uuid ') + .option('--json', 'machine-readable output') + .action(async (sourceEnv, targetEnv, params) => { + try { + const source = await loadSide(sourceEnv, { + file: params.sourceFile, + portalUrl: params.sourcePortalUrl, + token: params.sourceToken, + email: params.sourceEmail, + instanceUuid: params.sourceInstanceUuid, + label: 'source' + }); + const target = await loadSide(targetEnv, { + file: params.targetFile, + portalUrl: params.targetPortalUrl, + token: params.targetToken, + email: params.targetEmail, + instanceUuid: params.targetInstanceUuid, + label: 'target' + }); + + let { results, totals } = compareInstance(source.domains, target.domains, { + transform: !params.raw, + ignoreStatus: !!params.ignoreStatus, + dropValuePatterns: params.dropValue.map(pattern => new RegExp(pattern, 'i')) + }); + + if (params.domain.length) { + const wanted = new Set(params.domain.map(domain => domain.toLowerCase())); + results = results.filter(result => wanted.has(result.domainName.toLowerCase())); + totals = results.reduce((acc, result) => { + const key = { OK: 'ok', ADVISORY: 'advisory', CRITICAL: 'critical', MISSING_BEFORE: 'missingBefore', MISSING_AFTER: 'missingAfter' }[result.level]; + acc[key] += 1; + return acc; + }, { ok: 0, advisory: 0, critical: 0, missingBefore: 0, missingAfter: 0 }); + } + + const failed = totals.critical > 0 || totals.missingBefore > 0 || totals.missingAfter > 0; + + if (params.json) { + console.log(JSON.stringify({ source: source.origin, target: target.origin, results, totals }, null, 2)); + } else { + await logger.Info( + `Comparing ${source.origin}\n against ${target.origin}\n\n${renderResults(results)}\n\n` + + `OK: ${totals.ok} Advisory: ${totals.advisory} Critical: ${totals.critical} ` + + `Missing on source: ${totals.missingBefore} Missing on target: ${totals.missingAfter}`, + { hideTimestamp: true } + ); + } + + process.exit(failed ? 1 : 0); + } catch (error) { + logger.Error(error.message || error); + } + }); + +program.parse(process.argv); diff --git a/bin/pos-cli-dns-export.js b/bin/pos-cli-dns-export.js new file mode 100755 index 00000000..81110e7c --- /dev/null +++ b/bin/pos-cli-dns-export.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import ora from 'ora'; + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { exportInstance } from '../lib/dns/export.js'; + +const defaultFilename = (instanceUuid) => + `dns-export-${instanceUuid}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + +const resolveOutPath = (out, instanceUuid) => { + if (!out) return defaultFilename(instanceUuid); + if (fs.existsSync(out) && fs.statSync(out).isDirectory()) { + return path.join(out, `${instanceUuid}.json`); + } + return out; +}; + +program.showHelpAfterError(); +program + .name('pos-cli dns export') + .arguments('[environment]') + .option('-o, --out ', 'output file (or existing directory, writes .json inside)') + .option('--instance-uuid ', 'instance uuid (skips lookup by the environment domain)') + .option('--portal-url ', 'Partner Portal url (overrides the environment partner_portal_url)') + .option('--token ', 'portal API token (overrides the environment token)') + .option('--email ', 'authenticate with email + password prompt instead of a stored token') + .option('--api-version ', 'domains API version to request', '2') + .option('--raw', 'keep the full response verbatim, including legacy Terraform state blobs') + .action(async (environment, params) => { + const spinner = ora({ text: 'Exporting DNS', stream: process.stdout }); + try { + const context = await resolvePortalContext(environment, { + portalUrl: params.portalUrl, + token: params.token, + email: params.email, + instanceUuid: params.instanceUuid, + label: 'source', + readOnly: true + }); + + spinner.start(`Exporting ${context.instanceUuid}`); + const { envelope, names } = await exportInstance({ + client: context.client, + instanceUuid: context.instanceUuid, + instanceUrl: context.instanceUrl, + envName: environment, + apiVersion: parseInt(params.apiVersion, 10), + raw: !!params.raw + }); + + const outPath = resolveOutPath(params.out, context.instanceUuid); + fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(envelope, null, 2)); + + spinner.succeed( + `Exported ${envelope.domains.length} domain entr${envelope.domains.length === 1 ? 'y' : 'ies'} ` + + `(${names.join(', ') || 'none provisioned'}) from ${context.portalUrl} to ${outPath}` + ); + } catch (error) { + spinner.stop(); + logger.Error(error.message || error); + } + }); + +program.parse(process.argv); diff --git a/bin/pos-cli-dns-import.js b/bin/pos-cli-dns-import.js new file mode 100755 index 00000000..4c0974a5 --- /dev/null +++ b/bin/pos-cli-dns-import.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +import fs from 'fs'; + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { validateEnvelope } from '../lib/dns/exportSchema.js'; +import { transformEnvelope } from '../lib/dns/transform.js'; +import { applyPlans } from '../lib/dns/apply.js'; +import { renderPlans, renderSummary, renderResults } from '../lib/dns/plan.js'; +import { renderCutovers } from '../lib/dns/cutover.js'; +import { confirmApply } from '../lib/dns/guard.js'; +import { collect, filterByDomains, exitCodeFor } from '../lib/dns/cliHelpers.js'; + +program.showHelpAfterError(); +program + .name('pos-cli dns import') + .arguments('[environment]') + .requiredOption('--file ', 'dns export file produced by pos-cli dns export') + .option('--instance-uuid ', 'target instance uuid (skips lookup by the environment domain)') + .option('--portal-url ', 'target Partner Portal url') + .option('--token ', 'portal API token (overrides the environment token)') + .option('--email ', 'authenticate with email + password prompt instead of a stored token') + .option('--domain ', 'only import this domain (repeatable)', collect, []) + .option('--drop-value ', 'drop records whose value matches this pattern (repeatable)', collect, []) + .option('--dry-run', 'print the transform plan without writing anything') + .option('-y, --yes', 'apply without the interactive confirmation (required in scripts/CI)') + .option('--confirm-destructive', 'allow updates that delete many managed records on the target') + .option('--unsafe-allow-protected-target', 'allow writing to a protected portal (partners.platformos.com is read-only by default)') + .option('--no-wait', 'do not poll provisioning status after applying') + .option('--json', 'machine-readable output') + .action(async (environment, params) => { + try { + const envelope = validateEnvelope(JSON.parse(fs.readFileSync(params.file, 'utf8'))); + + // A dry-run with an explicit uuid needs no portal round-trip — the plan can be + // reviewed before target-portal access even exists. + const context = (params.dryRun && params.instanceUuid) + ? { instanceUuid: params.instanceUuid, portalUrl: params.portalUrl || '(target portal)' } + : await resolvePortalContext(environment, { + portalUrl: params.portalUrl, + token: params.token, + email: params.email, + instanceUuid: params.instanceUuid, + label: 'target', + allowProtectedTarget: !!params.unsafeAllowProtectedTarget + }); + + const { plans } = transformEnvelope(envelope, { + targetInstanceUuid: context.instanceUuid, + dropValuePatterns: params.dropValue.map(pattern => new RegExp(pattern, 'i')) + }); + const selected = filterByDomains(plans, params.domain); + + if (!params.json) { + await logger.Info(`Importing from ${envelope.portal_url} (exported ${envelope.exported_at}) into ${context.portalUrl}, instance ${context.instanceUuid}`, { hideTimestamp: true }); + await logger.Info(`\n${renderPlans(selected)}\n\n${renderSummary(selected)}`, { hideTimestamp: true }); + } + + const hasErrors = selected.some(plan => plan.errors.length); + if (params.dryRun) { + if (params.json) console.log(JSON.stringify({ plans: selected }, null, 2)); + process.exit(hasErrors ? 2 : 0); + } + if (hasErrors) { + await logger.Error('Fix the transform errors above (or exclude those domains with --domain) before importing.', { exit: false }); + process.exit(2); + } + + if (!(await confirmApply({ yes: !!params.yes, json: !!params.json, target: context.portalUrl }))) { + await logger.Info('Aborted — nothing was applied.', { hideTimestamp: true }); + process.exit(0); + } + + const { results } = await applyPlans({ + client: context.client, + plans: selected, + confirmDestructive: !!params.confirmDestructive, + wait: params.wait + }); + + // Fresh target statuses drive the cutover instructions (NS to set at the + // registrar / verification records to create); polling already captured + // them unless --no-wait was used. + const targetStatuses = []; + for (const result of results.filter(entry => entry.status === 'applied')) { + targetStatuses.push( + result.domainStatus || + await context.client.getDomain(result.domainName, context.instanceUuid).catch(() => null) + ); + } + + if (params.json) { + console.log(JSON.stringify({ results, target_domains: targetStatuses }, null, 2)); + } else { + await logger.Info(`\n${renderResults(results)}`, { hideTimestamp: true }); + if (results.some(result => result.status === 'blocked-destructive')) { + await logger.Warn('Some domains were blocked as destructive — review the messages above and re-run with --confirm-destructive to proceed.'); + } + const cutovers = targetStatuses.filter(Boolean); + if (cutovers.length) await logger.Info(`\n${renderCutovers(cutovers)}`, { hideTimestamp: true }); + } + + process.exit(exitCodeFor(results)); + } catch (error) { + logger.Error(error.message || error); + } + }); + +program.parse(process.argv); diff --git a/bin/pos-cli-dns-migrate.js b/bin/pos-cli-dns-migrate.js new file mode 100755 index 00000000..64d5cf54 --- /dev/null +++ b/bin/pos-cli-dns-migrate.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import ora from 'ora'; + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { exportInstance } from '../lib/dns/export.js'; +import { transformEnvelope } from '../lib/dns/transform.js'; +import { applyPlans } from '../lib/dns/apply.js'; +import { renderPlans, renderSummary, renderResults } from '../lib/dns/plan.js'; +import { renderCutovers } from '../lib/dns/cutover.js'; +import { confirmApply } from '../lib/dns/guard.js'; +import { collect, filterByDomains, backupPathFor, exitCodeForOutcomes } from '../lib/dns/cliHelpers.js'; + +// Export -> backup -> transform -> confirm -> (apply -> cutover) for one source/target +// instance pair. `confirm` is called after the plan is displayed; returning false aborts. +const migratePair = async ({ source, target, sourceUuid, targetUuid, sourceEnv, params, spinner, label, confirm }) => { + spinner.start(`${label}: exporting from ${source.portalUrl}`); + const { envelope } = await exportInstance({ + client: source.client, + instanceUuid: sourceUuid, + instanceUrl: source.instanceUrl, + envName: sourceEnv + }); + spinner.stop(); + + if (params.backup !== false) { + const outPath = backupPathFor(params.backup, sourceUuid); + fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(envelope, null, 2)); + await logger.Info(`${label}: source export backed up to ${outPath}`, { hideTimestamp: true }); + } + + const { plans } = transformEnvelope(envelope, { + targetInstanceUuid: targetUuid, + dropValuePatterns: params.dropValue.map(pattern => new RegExp(pattern, 'i')) + }); + const selected = filterByDomains(plans, params.domain); + + if (!params.json) { + await logger.Info(`\n${renderPlans(selected)}\n\n${label}: ${renderSummary(selected)}`, { hideTimestamp: true }); + } + + const hasErrors = selected.some(plan => plan.errors.length); + if (params.dryRun || hasErrors) { + if (hasErrors && !params.dryRun) { + await logger.Error(`${label}: fix the transform errors above (or exclude those domains with --domain) before migrating.`, { exit: false }); + } + return { label, plans: selected, results: [], dryRun: true, hasErrors, targetStatuses: [] }; + } + + if (confirm && !(await confirm())) { + await logger.Info(`${label}: aborted — nothing was applied.`, { hideTimestamp: true }); + return { label, plans: selected, results: [], aborted: true, hasErrors: false, targetStatuses: [] }; + } + + spinner.start(`${label}: applying to ${target.portalUrl}`); + const { results } = await applyPlans({ + client: target.client, + plans: selected, + confirmDestructive: !!params.confirmDestructive, + wait: params.wait, + onProgress: (domain, status) => { spinner.text = `${label}: applying ${domain} (${status?.status || 'waiting'})`; } + }); + spinner.stop(); + + const targetStatuses = []; + for (const result of results.filter(entry => entry.status === 'applied')) { + targetStatuses.push( + result.domainStatus || + await target.client.getDomain(result.domainName, targetUuid).catch(() => null) + ); + } + + if (!params.json) { + await logger.Info(`\n${renderResults(results)}`, { hideTimestamp: true }); + if (results.some(result => result.status === 'blocked-destructive')) { + await logger.Warn(`${label}: some domains were blocked as destructive — re-run with --confirm-destructive to proceed.`); + } + const cutovers = targetStatuses.filter(Boolean); + if (cutovers.length) await logger.Info(`\n${renderCutovers(cutovers)}`, { hideTimestamp: true }); + } + + return { label, plans: selected, results, dryRun: false, hasErrors: false, targetStatuses }; +}; + +program.showHelpAfterError(); +program + .name('pos-cli dns migrate') + .arguments('[sourceEnv]') + .arguments('[targetEnv]') + .option('--domain ', 'only migrate this domain (repeatable)', collect, []) + .option('--drop-value ', 'drop records whose value matches this pattern (repeatable)', collect, []) + .option('--dry-run', 'export + plan only, write nothing to the target') + .option('-y, --yes', 'apply without the interactive confirmation (required in scripts/CI)') + .option('--confirm-destructive', 'allow updates that delete many managed records on the target') + .option('--unsafe-allow-protected-target', 'allow writing to a protected portal (partners.platformos.com is read-only by default)') + .option('--backup ', 'where to write the source export backup (file or existing directory)') + .option('--no-backup', 'skip writing the backup file') + .option('--no-wait', 'do not poll provisioning status after applying') + .option('--source-portal-url ').option('--source-token ').option('--source-email ').option('--source-instance-uuid ') + .option('--target-portal-url ').option('--target-token ').option('--target-email ').option('--target-instance-uuid ') + .option('--json', 'machine-readable output') + .action(async (sourceEnv, targetEnv, params) => { + const spinner = ora({ text: 'Migrating DNS', stream: process.stdout }); + try { + const source = await resolvePortalContext(sourceEnv, { + portalUrl: params.sourcePortalUrl, + token: params.sourceToken, + email: params.sourceEmail, + instanceUuid: params.sourceInstanceUuid, + label: 'source', + readOnly: true + }); + const target = await resolvePortalContext(targetEnv, { + portalUrl: params.targetPortalUrl, + token: params.targetToken, + email: params.targetEmail, + instanceUuid: params.targetInstanceUuid, + label: 'target', + allowProtectedTarget: !!params.unsafeAllowProtectedTarget + }); + + if (source.portalUrl === target.portalUrl && source.instanceUuid === target.instanceUuid) { + await logger.Error('Source and target resolve to the same instance on the same portal — nothing to migrate.'); + } + + const outcome = await migratePair({ + source, + target, + sourceUuid: source.instanceUuid, + targetUuid: target.instanceUuid, + sourceEnv, + params, + spinner, + label: source.instanceUuid, + confirm: () => confirmApply({ yes: !!params.yes, json: !!params.json, target: target.portalUrl }) + }); + if (params.json) console.log(JSON.stringify({ plans: outcome.plans, results: outcome.results, target_domains: outcome.targetStatuses }, null, 2)); + process.exit(exitCodeForOutcomes([outcome])); + } catch (error) { + spinner.stop(); + logger.Error(error.message || error); + } + }); + +program.parse(process.argv); diff --git a/bin/pos-cli-dns-status.js b/bin/pos-cli-dns-status.js new file mode 100755 index 00000000..2583c2e4 --- /dev/null +++ b/bin/pos-cli-dns-status.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { fetchDomains } from '../lib/dns/export.js'; +import { renderCutovers } from '../lib/dns/cutover.js'; +import { domainName } from '../lib/dns/exportSchema.js'; +import { collect } from '../lib/dns/cliHelpers.js'; + +program.showHelpAfterError(); +program + .name('pos-cli dns status') + .arguments('[environment]') + .option('--domain ', 'only show this domain (repeatable)', collect, []) + .option('--instance-uuid ', 'instance uuid (skips lookup by the environment domain)') + .option('--portal-url ', 'Partner Portal url (overrides the environment partner_portal_url)') + .option('--token ', 'portal API token (overrides the environment token)') + .option('--email ', 'authenticate with email + password prompt instead of a stored token') + .option('--json', 'machine-readable output') + .action(async (environment, params) => { + try { + const context = await resolvePortalContext(environment, { + portalUrl: params.portalUrl, + token: params.token, + email: params.email, + instanceUuid: params.instanceUuid, + label: 'portal', + readOnly: true + }); + + const { domains } = await fetchDomains(context.client, context.instanceUuid); + const wanted = new Set(params.domain.map(name => name.toLowerCase())); + const provisioned = domains.filter(domain => + domain.status && (!wanted.size || wanted.has((domainName(domain) || '').toLowerCase())) + ); + + if (params.json) { + console.log(JSON.stringify({ portal: context.portalUrl, instance_uuid: context.instanceUuid, domains: provisioned }, null, 2)); + } else if (!provisioned.length) { + await logger.Info( + wanted.size + ? `No provisioned domain matching ${[...wanted].join(', ')} on ${context.portalUrl} (instance ${context.instanceUuid}).` + : `No provisioned domains on ${context.portalUrl} (instance ${context.instanceUuid}).`, + { hideTimestamp: true } + ); + } else { + await logger.Info( + `${context.portalUrl} (instance ${context.instanceUuid})\n\n${renderCutovers(provisioned)}`, + { hideTimestamp: true } + ); + } + } catch (error) { + logger.Error(error.message || error); + } + }); + +program.parse(process.argv); diff --git a/bin/pos-cli-dns.js b/bin/pos-cli-dns.js new file mode 100755 index 00000000..6f6de442 --- /dev/null +++ b/bin/pos-cli-dns.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { program } from '../lib/program.js'; + +program + .name('pos-cli dns') + .command('export [environment]', 'export all domains and DNS records of an instance to a JSON file (backup/audit artifact)') + .command('import [environment]', 'import domains and DNS records from an export file into a portal') + .command('migrate [sourceEnv] [targetEnv]', 'migrate domains portal-to-portal: export, transform, import, print cutover instructions') + .command('status [environment]', 'show each domain\'s provisioning status and pending cutover steps') + .command('compare [sourceEnv] [targetEnv]', 'verify DNS parity between source and target instances (exit 1 on CRITICAL)') + .parse(process.argv); diff --git a/bin/pos-cli.js b/bin/pos-cli.js index fa75eaf8..b8f21b70 100755 --- a/bin/pos-cli.js +++ b/bin/pos-cli.js @@ -25,6 +25,7 @@ program .command('constants', 'manage constants') .command('data', 'export, import or clean data on instance') .command('deploy ', 'deploy code to environment').alias('d') + .command('dns', 'export, migrate and verify instance domains/DNS between Partner Portals') .command('env', 'manage environments') .command('exec', 'execute code on instance') .command('gui', 'gui for content editor, graphql, logs') diff --git a/lib/dns/apply.js b/lib/dns/apply.js new file mode 100644 index 00000000..944bced1 --- /dev/null +++ b/lib/dns/apply.js @@ -0,0 +1,101 @@ +import { DestructiveChangeError } from './portalClient.js'; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// A provision has settled when the worker released the lock and left 'initializing'. +// 'ownership_verification_pending' / 'ssl_validation_pending' are settled-and-expected +// pre-cutover: the target waits for the customer's registrar/DNS changes. +// `!status.locked` (not `=== false`): tolerate a portal that omits the field rather +// than polling every apply to the full timeout (TASK-1.15). +const settled = (status) => !!status && !status.locked && status.status !== 'initializing'; + +const pollUntilSettled = async (client, domainName, instanceUuid, { pollIntervalMs, timeoutMs, onProgress }) => { + const startedAt = Date.now(); + let lastSeen = null; + while (Date.now() - startedAt < timeoutMs) { + lastSeen = await client.getDomain(domainName, instanceUuid).catch(() => null); + if (settled(lastSeen)) return { status: lastSeen, stillProcessing: false }; + onProgress(domainName, lastSeen); + await sleep(pollIntervalMs); + } + return { status: lastSeen, stillProcessing: true }; +}; + +// Applies transform plans against the target portal, sequentially — every accepted POST +// enqueues a provision worker on the portal, so multiple domains must not stampede the queue. +// confirm_destructive is only ever sent when the operator explicitly passed the flag. +const applyPlans = async ({ + client, + plans, + confirmDestructive = false, + wait = true, + pollIntervalMs = 5000, + timeoutMs = 120000, + interPostDelayMs = 500, + onProgress = () => {} +}) => { + const results = []; + let firstPost = true; + + for (const plan of plans) { + if (plan.skipped) { + results.push({ domainName: plan.domainName, status: 'skipped', serverMessage: plan.skipReason }); + continue; + } + if (plan.errors.length || !plan.payload) { + results.push({ domainName: plan.domainName, status: 'invalid', serverMessage: plan.errors.join('; ') }); + continue; + } + + if (!firstPost && interPostDelayMs) await sleep(interPostDelayMs); + firstPost = false; + + const payload = confirmDestructive ? { ...plan.payload, confirm_destructive: true } : plan.payload; + + let response; + try { + response = await client.upsertDomain(payload); + } catch (error) { + if (error instanceof DestructiveChangeError) { + results.push({ domainName: plan.domainName, status: 'blocked-destructive', serverMessage: error.message }); + } else { + results.push({ domainName: plan.domainName, status: 'error', serverMessage: error.message }); + } + continue; + } + + const result = { + domainName: plan.domainName, + status: 'applied', + serverMessage: response && response.message, + requestedStatus: response?.data?.status + }; + + if (wait) { + const polled = await pollUntilSettled(client, plan.domainName, plan.payload.instance_uuid, { + pollIntervalMs, + timeoutMs, + onProgress + }); + result.finalStatus = polled.status?.status; + result.finalSubstatus = polled.status?.substatus; + result.stillProcessing = polled.stillProcessing; + result.domainStatus = polled.status; + + // The POST is accepted immediately; the actual provisioning runs in a worker. + // Its outcome lands in last_operation_status — surface failures instead of + // letting an accepted-but-failed apply masquerade as success. + const lastOperation = polled.status?.last_operation_status; + if (lastOperation?.operation === 'apply' && ['failed', 'blocked'].includes(lastOperation.status)) { + result.status = lastOperation.status === 'blocked' ? 'blocked-destructive' : 'apply-failed'; + result.serverMessage = [].concat(lastOperation.message || []).join(' '); + } + } + + results.push(result); + } + + return { results }; +}; + +export { applyPlans }; diff --git a/lib/dns/auth.js b/lib/dns/auth.js new file mode 100644 index 00000000..99ee09c6 --- /dev/null +++ b/lib/dns/auth.js @@ -0,0 +1,117 @@ +import logger from '../logger.js'; +import { settingsFromDotPos } from '../settings.js'; +import { readPassword } from '../utils/password.js'; +import { DnsPortalClient, PortalAuthError } from './portalClient.js'; +import { assertWritablePortal } from './guard.js'; +import { hostnameOf } from './cliHelpers.js'; + +const DEFAULT_PORTAL_URL = 'https://partners.platformos.com'; + +// dns commands deliberately read .pos entries and flags only (not MPKIT_* env vars): +// a two-portal tool must never resolve source and target to the same ambient credentials. +const resolveSettings = (envName, label) => { + if (!envName) return {}; + const settings = settingsFromDotPos(envName); + if (!settings) { + throw new Error( + `No settings for ${label} environment "${envName}" — add it with \`pos-cli env add ${envName}\` ` + + 'or pass --portal-url/--token flags instead of an environment name.' + ); + } + return settings; +}; + +const authenticateInteractively = async (baseUrl, email) => { + logger.Info(`Authenticating ${email} on ${baseUrl}`); + const password = await readPassword(); + return DnsPortalClient.authenticate(baseUrl, email, password); +}; + +// Resolves everything a dns command needs to talk to one portal ("source" or "target"): +// an authenticated client and the instance uuid. Order: .pos env entry -> flag overrides +// -> interactive email/password fallback (session-only JWT, never persisted). +const resolvePortalContext = async (envName, { + portalUrl, + token, + email, + instanceUuid, + label = 'portal', + readOnly = false, + skipInstanceLookup = false, + allowProtectedTarget = false +} = {}) => { + const settings = resolveSettings(envName, label); + // Deliberately NO ambient PARTNER_PORTAL_HOST fallback (unlike lib/portal.js): a + // process-global env var could silently point BOTH sides of a two-portal command + // at the same place. Only per-env settings, explicit flags, or the public default. + const baseUrl = (portalUrl || settings.partner_portal_url || DEFAULT_PORTAL_URL) + .replace(/\/+$/, ''); + hostnameOf(baseUrl, `${label} portal url`); + + // A write context to a protected portal (the legacy production portal) is refused + // here, centrally — every dns command resolves through this path. + if (!readOnly) assertWritablePortal(baseUrl, { allowProtectedTarget }); + + let authToken = token || settings.token; + if (email) { + authToken = await authenticateInteractively(baseUrl, email); + } + if (!authToken) { + throw new Error( + `No credentials for ${label} portal ${baseUrl} — pass an environment name, --token, or --email.` + ); + } + + let client = new DnsPortalClient({ baseUrl, token: authToken, readOnly }); + + try { + await client.listInstances(); + } catch (error) { + const fallbackEmail = settings.email; + const interactive = process.stdin.isTTY; + if (!(error instanceof PortalAuthError) || email || !fallbackEmail || !interactive) throw error; + + logger.Warn(`${error.message}`); + authToken = await authenticateInteractively(baseUrl, fallbackEmail); + client = new DnsPortalClient({ baseUrl, token: authToken, readOnly }); + await client.listInstances(); + } + + const uuid = skipInstanceLookup + ? instanceUuid + : await resolveInstanceUuid(client, { instanceUuid, instanceUrl: settings.url, label }); + + return { + client, + instanceUuid: uuid, + instanceUrl: settings.url, + portalUrl: baseUrl, + envName + }; +}; + +const resolveInstanceUuid = async (client, { instanceUuid, instanceUrl, label }) => { + if (instanceUuid) return instanceUuid; + if (!instanceUrl) { + throw new Error(`Cannot determine the ${label} instance — pass --${label}-instance-uuid.`); + } + + const hostname = hostnameOf(instanceUrl, `${label} environment instance url`); + const response = await client.searchInstances({ domain: hostname }); + const matches = (response && response.data) || []; + if (matches.length === 1) return matches[0].uuid; + + const all = await client.listInstances().catch(() => null); + const candidates = ((all && all.data) || []) + .map(instance => ` ${instance.uuid} ${instance.name}`) + .join('\n'); + const problem = matches.length === 0 + ? `No instance on ${client.baseUrl} has the domain ${hostname}` + : `${matches.length} instances on ${client.baseUrl} match the domain ${hostname}`; + throw new Error( + `${problem} — pass --${label}-instance-uuid explicitly.` + + (candidates ? `\nInstances your token can access:\n${candidates}` : '') + ); +}; + +export { resolvePortalContext, resolveInstanceUuid, DEFAULT_PORTAL_URL }; diff --git a/lib/dns/cliHelpers.js b/lib/dns/cliHelpers.js new file mode 100644 index 00000000..7af843fc --- /dev/null +++ b/lib/dns/cliHelpers.js @@ -0,0 +1,56 @@ +import fs from 'fs'; +import path from 'path'; + +// Shared helpers for the dns bin files (repo convention: thin bins, logic in lib/). + +const collect = (value, previous) => previous.concat([value]); + +const filterByDomains = (plans, domains) => { + if (!domains.length) return plans; + const wanted = new Set(domains.map(domain => domain.toLowerCase())); + return plans.filter(plan => wanted.has((plan.domainName || '').toLowerCase())); +}; + +const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-'); + +// Where a source-export backup lands: a file path, an existing directory, or absent +// (default timestamped name). commander sets the value to `true` when --backup/--no-backup +// are defined but not passed. +const backupPathFor = (backup, instanceUuid) => { + if (!backup || backup === true) return `dns-export-${instanceUuid}-${timestamp()}.json`; + if (fs.existsSync(backup) && fs.statSync(backup).isDirectory()) return path.join(backup, `${instanceUuid}.json`); + return backup; +}; + +// Exit-code contract for import/migrate (documented in the README dns section): +// 0 success, 1 apply errors/invalid plans, 2 transform errors, 3 only-destructive-blocked. +const exitCodeFor = (results) => { + if (results.some(result => ['error', 'invalid', 'apply-failed'].includes(result.status))) return 1; + if (results.some(result => result.status === 'blocked-destructive')) return 3; + return 0; +}; + +// Outcome-level variant: folds per-pair transform errors (exit 2) into the contract; +// worst code wins when conditions are mixed (1 > 2 > 3). +const exitCodeForOutcomes = (outcomes) => { + const codes = outcomes.map(outcome => { + const resultCode = exitCodeFor(outcome.results || []); + if (resultCode === 1) return 1; + if (outcome.hasErrors) return 2; + return resultCode; + }); + if (codes.includes(1)) return 1; + if (codes.includes(2)) return 2; + if (codes.includes(3)) return 3; + return 0; +}; + +const hostnameOf = (url, what = 'url') => { + try { + return new URL(url).hostname; + } catch { + throw new Error(`Invalid ${what} "${url}" — it must include the scheme, e.g. https://${url}`); + } +}; + +export { collect, filterByDomains, backupPathFor, exitCodeFor, exitCodeForOutcomes, hostnameOf, timestamp }; diff --git a/lib/dns/compare.js b/lib/dns/compare.js new file mode 100644 index 00000000..c922800b --- /dev/null +++ b/lib/dns/compare.js @@ -0,0 +1,212 @@ +import { domainName } from './exportSchema.js'; +import { transformDomain, isPlatformSubdomain } from './transform.js'; +import { normalizeRecordValue, normalizeName, sortedRecordValues } from './normalize.js'; + +// Port of partner-portal scripts/pp-dns/ps-sg/compare-golden.rb. +// +// Default mode is CROSS-STACK: the source side is normalized through the transform +// (www-redirect record dropped, values normalized) and fields that legitimately differ +// between stacks are skipped — data_center, nameservers, and verification-record values +// (the target mints new DCV records by design). transform: false restores the exact +// same-stack golden-file semantics of the rb script. + +const recordKey = (record, domain) => + `${normalizeName(record.name, domain) || '@'}/${(record.type || '').toUpperCase()}`; + +const indexIntentRecords = (records, domain, { normalizeValues }) => { + const index = new Map(); + for (const record of records || []) { + const values = (record.records || []).map(value => + normalizeValues ? normalizeRecordValue((record.type || '').toUpperCase(), String(value)) : String(value) + ); + const key = recordKey(record, domain); + index.set(key, sortedRecordValues([...(index.get(key) || []), ...values])); + } + return index; +}; + +const indexLiveRecords = (records) => { + const index = new Map(); + for (const record of records || []) { + if (!record || typeof record !== 'object') continue; + index.set(`${(record.name || '').toLowerCase()}/${(record.type || '').toUpperCase()}`, record); + } + return index; +}; + +const indexVerificationRecords = (records) => { + const index = new Map(); + for (const record of records || []) { + if (!record || typeof record !== 'object') continue; + if (record.resource_record_name && record.resource_record_type) { + index.set(`${record.resource_record_name.toLowerCase()}/${record.resource_record_type.toUpperCase()}`, record); + } else if (record.txt_name) { + index.set(`:raw_cf_shape:${record.txt_name}`, { ...record, ':shape': 'raw_cf' }); + } + } + return index; +}; + +// In transform mode BOTH sides pass through the transform: the target portal itself +// stores the auto-added www-redirect CNAME in config, so it must be dropped symmetrically +// or every www-redirect domain would diff as "only on target". dropValuePatterns mirrors +// migrate/import — records intentionally dropped at migration time must not report as +// CRITICAL "missing on target" forever (TASK-1.11). +const intentFor = (domain, name, transform, dropValuePatterns) => { + if (!transform) { + return indexIntentRecords(domain?.attributes?.config?.extra_dns_records, name, { normalizeValues: false }); + } + const plan = transformDomain(domain, { targetInstanceUuid: 'compare', dropValuePatterns }); + const records = plan.payload ? plan.kept : (domain?.attributes?.config?.extra_dns_records || []); + return indexIntentRecords(records, name, { normalizeValues: true }); +}; + +const compareDomain = (sourceDomain, targetDomain, name, { transform, ignoreStatus, dropValuePatterns }) => { + const critical = []; + const advisory = []; + + const sourceStatus = sourceDomain.status || null; + const targetStatus = targetDomain.status || null; + + if (sourceStatus !== targetStatus) { + const message = `status: source=${JSON.stringify(sourceStatus)} target=${JSON.stringify(targetStatus)}`; + (ignoreStatus ? advisory : critical).push(message); + } + + const attributeKeys = transform ? ['setup_type'] : ['setup_type', 'data_center']; + for (const key of attributeKeys) { + const sourceValue = sourceDomain?.attributes?.[key]; + const targetValue = targetDomain?.attributes?.[key]; + if (sourceValue !== targetValue) { + critical.push(`attributes.${key}: source=${JSON.stringify(sourceValue)} target=${JSON.stringify(targetValue)}`); + } + } + + const sourceRecords = intentFor(sourceDomain, name, transform, dropValuePatterns); + const targetRecords = intentFor(targetDomain, name, transform, dropValuePatterns); + for (const key of new Set([...sourceRecords.keys(), ...targetRecords.keys()])) { + const sourceValues = sourceRecords.get(key); + const targetValues = targetRecords.get(key); + if (!sourceValues) { + critical.push(`extra_dns_records intent [${key}]: only on target (${JSON.stringify(targetValues)})`); + } else if (!targetValues) { + critical.push(`extra_dns_records intent [${key}]: missing on target (source: ${JSON.stringify(sourceValues)})`); + } else if (JSON.stringify(sourceValues) !== JSON.stringify(targetValues)) { + critical.push( + `extra_dns_records intent [${key}] values differ:\n source: ${JSON.stringify(sourceValues)}\n target: ${JSON.stringify(targetValues)}` + ); + } + } + + const sourceVerification = indexVerificationRecords(sourceDomain?.details?.dns_verification_records); + const targetVerification = indexVerificationRecords(targetDomain?.details?.dns_verification_records); + for (const key of new Set([...sourceVerification.keys(), ...targetVerification.keys()]).values()) { + const sourceRecord = sourceVerification.get(key); + const targetRecord = targetVerification.get(key); + if (targetRecord && targetRecord[':shape']) { + const { ':shape': _shape, ...rest } = targetRecord; + critical.push(`dns_verification_records [${key}] wrong shape on target: ${JSON.stringify(rest)}`); + } else if (transform) { + // Cross-stack: the target mints new DCV records; only the shape check above applies. + continue; + } else if (sourceRecord && !targetRecord) { + critical.push(`dns_verification_records [${key}] missing on target\n source: ${JSON.stringify(sourceRecord)}`); + } else if (!sourceRecord && targetRecord) { + advisory.push(`dns_verification_records [${key}] only on target\n target: ${JSON.stringify(targetRecord)}`); + } else if (sourceRecord && targetRecord && sourceRecord.resource_record_value !== targetRecord.resource_record_value) { + critical.push( + `dns_verification_records [${key}] value differs:\n source: ${JSON.stringify(sourceRecord.resource_record_value)}\n target: ${JSON.stringify(targetRecord.resource_record_value)}` + ); + } + } + + if (!!sourceDomain.has_pending !== !!targetDomain.has_pending) { + advisory.push(`has_pending: source=${JSON.stringify(sourceDomain.has_pending)} target=${JSON.stringify(targetDomain.has_pending)}`); + } + + // Live values carry read-side noise (CF returns TXT quoted/chunked and lowercases + // hostnames) — normalize in transform mode so advisories only flag real drift. + const liveValues = (record) => { + const values = (record.records || []).map(value => + transform ? normalizeRecordValue((record.type || '').toUpperCase(), String(value)) : String(value) + ); + return sortedRecordValues(values); + }; + const sourceLive = indexLiveRecords(sourceDomain?.details?.extra_dns_records); + const targetLive = indexLiveRecords(targetDomain?.details?.extra_dns_records); + for (const key of new Set([...sourceLive.keys(), ...targetLive.keys()])) { + const sourceRecord = sourceLive.get(key); + const targetRecord = targetLive.get(key); + if (!sourceRecord) { + advisory.push(`details.extra_dns_records [${key}]: only on target`); + } else if (!targetRecord) { + advisory.push(`details.extra_dns_records [${key}]: only on source`); + } else if (JSON.stringify(liveValues(sourceRecord)) !== JSON.stringify(liveValues(targetRecord))) { + advisory.push( + `details.extra_dns_records [${key}] records differ:\n source: ${JSON.stringify(sourceRecord.records)}\n target: ${JSON.stringify(targetRecord.records)}` + ); + } + } + + if (!transform) { + const sourceNs = [...(sourceDomain?.details?.dns_zone_name_servers || [])].sort(); + const targetNs = [...(targetDomain?.details?.dns_zone_name_servers || [])].sort(); + if (sourceNs.length && JSON.stringify(sourceNs) !== JSON.stringify(targetNs)) { + advisory.push(`dns_zone_name_servers:\n source: ${JSON.stringify(sourceNs)}\n target: ${JSON.stringify(targetNs)}`); + } + } + + const level = critical.length ? 'CRITICAL' : (advisory.length ? 'ADVISORY' : 'OK'); + return { domainName: name, level, critical, advisory, status: targetStatus }; +}; + +const indexDomains = (domains) => { + const index = new Map(); + for (const domain of domains || []) { + const name = domainName(domain); + if (name) index.set(name, domain); + } + return index; +}; + +const compareInstance = (sourceDomains, targetDomains, { transform = true, ignoreStatus = false, dropValuePatterns = [] } = {}) => { + const sourceIndex = indexDomains(sourceDomains); + const targetIndex = indexDomains(targetDomains); + const results = []; + const totals = { ok: 0, advisory: 0, critical: 0, missingBefore: 0, missingAfter: 0 }; + + const relevant = (name, domain) => { + if (!domain) return false; + if (transform && isPlatformSubdomain(name)) return false; + return !!domain.status; + }; + + for (const name of [...new Set([...sourceIndex.keys(), ...targetIndex.keys()])].sort()) { + const sourceDomain = sourceIndex.get(name); + const targetDomain = targetIndex.get(name); + const sourceRelevant = relevant(name, sourceDomain); + const targetRelevant = relevant(name, targetDomain); + + if (!sourceRelevant && !targetRelevant) continue; + if (!sourceDomain) { + results.push({ domainName: name, level: 'MISSING_BEFORE', critical: [], advisory: [], status: targetDomain?.status }); + totals.missingBefore += 1; + continue; + } + if (!targetDomain) { + results.push({ domainName: name, level: 'MISSING_AFTER', critical: [], advisory: [], status: sourceDomain?.status }); + totals.missingAfter += 1; + continue; + } + + const result = compareDomain(sourceDomain, targetDomain, name, { transform, ignoreStatus, dropValuePatterns }); + results.push(result); + if (result.level === 'OK') totals.ok += 1; + else if (result.level === 'ADVISORY') totals.advisory += 1; + else totals.critical += 1; + } + + return { results, totals }; +}; + +export { compareInstance, compareDomain }; diff --git a/lib/dns/cutover.js b/lib/dns/cutover.js new file mode 100644 index 00000000..00af4f14 --- /dev/null +++ b/lib/dns/cutover.js @@ -0,0 +1,70 @@ +import chalk from 'chalk'; +import table from 'text-table'; + +import { domainName } from './exportSchema.js'; + +const STATUS_MEANINGS = { + ready: 'live on the target portal — cutover complete', + initializing: 'the target portal is still provisioning — re-check in a minute', + ownership_verification_pending: 'waiting for the DNS changes below', + ssl_validation_pending: 'ownership verified — the SSL certificate is being issued', + reparking_domain: 'the domain is being re-parked — re-check in a few minutes', + not_found: 'the target portal found no hostname yet — apply may still be running or failed, check the portal', + unknown: 'the target portal could not determine the state — check the portal' +}; + +const statusLine = (domain) => { + const status = domain.substatus || domain.status || 'unknown'; + const meaning = STATUS_MEANINGS[status] || 'see the target portal'; + return `${chalk.bold(status)} — ${meaning}`; +}; + +const verificationRows = (details) => + (details.dns_verification_records || []).map(record => [ + ` ${record.resource_record_name}`, + record.resource_record_type, + record.resource_record_value + ]); + +// Per-domain "what to change where" block, rendered from a fresh target-portal +// GET /api/domains/ response after import. +const cutoverInstructions = (targetDomain) => { + const name = domainName(targetDomain); + const setupType = targetDomain?.attributes?.setup_type; + const details = targetDomain?.details || {}; + const lines = [`${chalk.bold.underline(`CUTOVER ${name}`)} (${setupType})`, ` status: ${statusLine(targetDomain)}`]; + + if ((targetDomain.substatus || targetDomain.status) === 'ready') { + lines.push(' Nothing left to do for this domain.'); + return lines.join('\n'); + } + + if (setupType === 'domain-full') { + const nameServers = details.dns_zone_name_servers || []; + lines.push(' At your domain registrar, replace the nameservers with:'); + lines.push(nameServers.map(ns => ` ${ns}`).join('\n') || chalk.yellow(' (not assigned yet — re-check in a minute)')); + lines.push(' DNS records are then served by the target portal zone — nothing else to change.'); + } else { + const verification = verificationRows(details); + let step = 1; + if (verification.length) { + lines.push(` ${step}. Create/replace these SSL validation records at your DNS provider:`); + lines.push(table(verification)); + step += 1; + } + lines.push(` ${step}. Point the hostname at the target stack:`); + if (details.private_lb_cname) lines.push(` subdomains: CNAME -> ${details.private_lb_cname}`); + if (details.lb_public_ip) lines.push(` apex: A -> ${details.lb_public_ip}`); + if (!details.private_lb_cname && !details.lb_public_ip) { + lines.push(chalk.yellow(' (target endpoints not published in the API response — check the target portal)')); + } + lines.push(' Once the records are in place, use the target portal\'s refresh to re-check validation, then run `pos-cli dns compare`.'); + } + + return lines.join('\n'); +}; + +const renderCutovers = (targetDomains) => + targetDomains.filter(Boolean).map(cutoverInstructions).join('\n\n'); + +export { cutoverInstructions, renderCutovers, STATUS_MEANINGS }; diff --git a/lib/dns/export.js b/lib/dns/export.js new file mode 100644 index 00000000..eddf82c6 --- /dev/null +++ b/lib/dns/export.js @@ -0,0 +1,39 @@ +import logger from '../logger.js'; +import { buildEnvelope, domainName } from './exportSchema.js'; + +// A provisioned domain (non-empty status) must carry attributes.domain_name on v2. +// When the deployed portal ignores the version param (old backends), fall back to v1 — +// the name is then derived from config._domains by domainName(). +const v2Supported = (domains) => + domains + .filter(domain => domain && domain.status) + .every(domain => domain.attributes && domain.attributes.domain_name); + +const fetchDomains = async (client, instanceUuid, { apiVersion = 2 } = {}) => { + let domains = await client.listDomains(instanceUuid, { version: apiVersion }); + if (!Array.isArray(domains)) domains = []; + + if (apiVersion === 2 && domains.length && !v2Supported(domains)) { + logger.Warn(`${client.baseUrl} did not return version=2 attributes — falling back to version=1.`); + domains = await client.listDomains(instanceUuid, { version: 1 }); + if (!Array.isArray(domains)) domains = []; + return { domains, apiVersion: 1 }; + } + + return { domains, apiVersion }; +}; + +const exportInstance = async ({ client, instanceUuid, instanceUrl, envName, apiVersion = 2, raw = false }) => { + const fetched = await fetchDomains(client, instanceUuid, { apiVersion }); + const envelope = buildEnvelope({ + portalUrl: client.baseUrl, + apiVersion: fetched.apiVersion, + instance: { uuid: instanceUuid, url: instanceUrl, env: envName }, + domains: fetched.domains, + raw + }); + const names = fetched.domains.map(domainName).filter(Boolean); + return { envelope, names }; +}; + +export { exportInstance, fetchDomains }; diff --git a/lib/dns/exportSchema.js b/lib/dns/exportSchema.js new file mode 100644 index 00000000..83211602 --- /dev/null +++ b/lib/dns/exportSchema.js @@ -0,0 +1,52 @@ +const SCHEMA_PREFIX = 'pos-cli/dns-export/v'; +const SCHEMA_MAJOR = 1; +const SCHEMA = `${SCHEMA_PREFIX}${SCHEMA_MAJOR}`; + +// The old-stack v2 payload embeds the legacy pp-dns Terraform state under details.state — +// thousands of lines of backend cruft with no migration value. Anything else oversized is +// dropped defensively so export files stay reviewable. +const MAX_DETAIL_VALUE_BYTES = 100 * 1024; + +const domainName = (domain) => + domain?.attributes?.domain_name || + domain?.attributes?.config?._domains?.find(d => d?.name)?.name || + null; + +const stripBulkyDetails = (domain) => { + const copy = structuredClone(domain); + if (copy && copy.details && typeof copy.details === 'object') { + delete copy.details.state; + for (const [key, value] of Object.entries(copy.details)) { + if (JSON.stringify(value ?? null).length > MAX_DETAIL_VALUE_BYTES) { + copy.details[key] = `[stripped: ${key} exceeded ${MAX_DETAIL_VALUE_BYTES} bytes]`; + } + } + } + return copy; +}; + +const buildEnvelope = ({ portalUrl, apiVersion, instance, domains, raw = false }) => ({ + schema: SCHEMA, + exported_at: new Date().toISOString(), + portal_url: portalUrl, + api_version: apiVersion, + instance, + domains: raw ? domains : domains.map(stripBulkyDetails) +}); + +const validateEnvelope = (json) => { + if (!json || typeof json !== 'object') throw new Error('Not a dns export file (expected a JSON object).'); + const schema = json.schema; + if (typeof schema !== 'string' || !schema.startsWith(SCHEMA_PREFIX)) { + throw new Error(`Not a dns export file (missing "schema: ${SCHEMA}").`); + } + const major = parseInt(schema.slice(SCHEMA_PREFIX.length), 10); + if (major !== SCHEMA_MAJOR) { + throw new Error(`Unsupported export schema ${schema} — this pos-cli understands ${SCHEMA}. Upgrade pos-cli or re-export.`); + } + if (!json.instance || !json.instance.uuid) throw new Error('Export file is missing instance.uuid.'); + if (!Array.isArray(json.domains)) throw new Error('Export file is missing the domains array.'); + return json; +}; + +export { SCHEMA, buildEnvelope, stripBulkyDetails, validateEnvelope, domainName }; diff --git a/lib/dns/guard.js b/lib/dns/guard.js new file mode 100644 index 00000000..dfcb3a43 --- /dev/null +++ b/lib/dns/guard.js @@ -0,0 +1,44 @@ +import prompts from 'prompts'; + +import { hostnameOf } from './cliHelpers.js'; + +// The legacy production portal is the migration SOURCE — dns commands must never +// write to it. Guards against argument swaps like `migrate `. +const PROTECTED_TARGET_HOSTS = ['partners.platformos.com']; + +const assertWritablePortal = (portalUrl, { allowProtectedTarget = false } = {}) => { + const host = hostnameOf(portalUrl, 'portal url'); + if (!PROTECTED_TARGET_HOSTS.includes(host) || allowProtectedTarget) return; + throw new Error( + `${host} is protected as read-only for dns commands — it can be a migration source, ` + + 'never a target (did you swap the source and target arguments?). ' + + 'If you really intend to write to it, pass --unsafe-allow-protected-target.' + ); +}; + +// Plan-then-confirm gate: applying requires an explicit yes — interactively after +// the plan has been displayed, or via --yes in scripts/CI. +const confirmApply = async ({ yes = false, interactive = process.stdin.isTTY, json = false, target }) => { + if (yes) return true; + if (json) { + // --json suppresses the human-readable plan, so an interactive prompt would be + // a blind confirmation — refuse instead (TASK-1.12). + throw new Error( + 'With --json there is no plan review to confirm — pass --yes to apply, or --dry-run to preview the plan as JSON.' + ); + } + if (!interactive) { + throw new Error( + 'Refusing to apply without confirmation in non-interactive mode — re-run with --yes, or use --dry-run to only preview.' + ); + } + const response = await prompts({ + type: 'confirm', + name: 'confirmed', + message: `Apply these DNS changes to ${target}?`, + initial: false + }); + return !!response.confirmed; +}; + +export { assertWritablePortal, confirmApply, PROTECTED_TARGET_HOSTS }; diff --git a/lib/dns/normalize.js b/lib/dns/normalize.js new file mode 100644 index 00000000..1589e234 --- /dev/null +++ b/lib/dns/normalize.js @@ -0,0 +1,63 @@ +// Record-noise normalizers shared by transform (what we send) and compare (what we diff). +// Rules mirror partner-portal's Api::Record validation and Cloudflare read-side quirks: +// CF lowercases hostnames and returns TXT quoted (long values chunked into 255-byte quoted +// parts); the new stack's MX validation rejects a trailing dot on the host. + +const stripTrailingDot = (value) => value.replace(/\.$/, ''); + +const HOST_TYPES = new Set(['CNAME', 'ALIAS', 'NS', 'PTR']); + +// '"part-one" "part-two"' -> 'part-onepart-two'; '"quoted"' -> 'quoted'; unquoted stays as-is. +const normalizeTxtValue = (value) => { + const trimmed = value.trim(); + if (!/^".*"$/s.test(trimmed)) return value; + const chunks = trimmed.match(/"((?:[^"\\]|\\.)*)"/g); + if (!chunks) return value; + return chunks.map(chunk => chunk.slice(1, -1)).join(''); +}; + +const normalizeMxValue = (value) => { + const [priority, ...host] = value.trim().split(/\s+/); + return [priority, stripTrailingDot(host.join(' ').toLowerCase())].join(' '); +}; + +const normalizeSrvValue = (value) => { + const parts = value.trim().split(/\s+/); + if (parts.length < 4) return value.trim().toLowerCase(); + const target = stripTrailingDot(parts.slice(3).join(' ').toLowerCase()); + return [...parts.slice(0, 3), target].join(' '); +}; + +const normalizeRecordValue = (type, value) => { + switch (type) { + case 'TXT': + case 'SPF': + return normalizeTxtValue(value); + case 'MX': + return normalizeMxValue(value); + case 'SRV': + return normalizeSrvValue(value); + default: + return HOST_TYPES.has(type) ? stripTrailingDot(value.trim().toLowerCase()) : value.trim(); + } +}; + +// Canonical record-name form: lowercase, short (relative to the domain), '' for apex. +const normalizeName = (name, domainName) => { + let short = (name || '').trim().toLowerCase().replace(/\.$/, ''); + if (short === '@' || short === domainName) return ''; + if (domainName && short.endsWith(`.${domainName}`)) short = short.slice(0, -(domainName.length + 1)); + return short; +}; + +const sortedRecordValues = (values) => [...values].sort(); + +export { + normalizeTxtValue, + normalizeMxValue, + normalizeSrvValue, + normalizeRecordValue, + normalizeName, + sortedRecordValues, + stripTrailingDot +}; diff --git a/lib/dns/plan.js b/lib/dns/plan.js new file mode 100644 index 00000000..65ce5853 --- /dev/null +++ b/lib/dns/plan.js @@ -0,0 +1,85 @@ +import chalk from 'chalk'; +import table from 'text-table'; + +const recordRow = (marker, record, note = '') => [ + ` ${marker}`, + record.name || '@', + record.type, + String(record.ttl), + record.records.join(' | ') + (record.proxied ? ' (proxied)' : ''), + note +]; + +// Human-readable dry-run plan for one transformed domain. +const renderPlan = (plan) => { + const lines = []; + const header = chalk.bold(plan.domainName || '(unknown domain)'); + + if (plan.skipped) { + lines.push(`${header} ${chalk.gray(`SKIP — ${plan.skipReason}`)}`); + return lines.join('\n'); + } + + const meta = plan.payload + ? ` (${plan.payload.setup_type}, default: ${plan.payload.use_as_default ? 'yes' : 'no'}, www-redirect: ${plan.payload.enable_www_redirect ? 'on' : 'off'})` + : ''; + lines.push(`${header}${chalk.gray(meta)}`); + + const rows = [ + ...plan.kept.map(record => recordRow(chalk.green('KEEP'), record)), + ...plan.dropped.map(({ record, reason }) => recordRow(chalk.yellow('DROP'), record, chalk.gray(`[${reason}]`))) + ]; + if (rows.length) lines.push(table(rows)); + + for (const warning of plan.warnings) lines.push(chalk.yellow(` WARN ${warning}`)); + for (const error of plan.errors) lines.push(chalk.red(` ERROR ${error}`)); + + return lines.join('\n'); +}; + +const renderPlans = (plans) => plans.map(renderPlan).join('\n\n'); + +const planSummary = (plans) => { + const active = plans.filter(plan => !plan.skipped); + return { + toApply: active.filter(plan => plan.payload).length, + skipped: plans.length - active.length, + withErrors: active.filter(plan => plan.errors.length).length, + records: active.reduce((sum, plan) => sum + plan.kept.length, 0) + }; +}; + +const renderSummary = (plans) => { + const summary = planSummary(plans); + const parts = [ + `${summary.toApply} domain(s) to apply (${summary.records} records)`, + summary.skipped ? chalk.gray(`${summary.skipped} skipped`) : null, + summary.withErrors ? chalk.red(`${summary.withErrors} with errors`) : null + ].filter(Boolean); + return parts.join(', '); +}; + +const STATUS_COLORS = { + applied: chalk.green, + 'apply-failed': chalk.red, + 'blocked-destructive': chalk.yellow, + skipped: chalk.gray, + invalid: chalk.red, + error: chalk.red +}; + +const renderResults = (results) => { + const rows = results.map(result => { + const paint = STATUS_COLORS[result.status] || chalk.white; + const statusInfo = [ + result.finalStatus, + result.finalSubstatus, + result.stillProcessing ? 'still processing…' : null + ].filter(Boolean).join(' / '); + const detail = result.status === 'applied' ? (statusInfo || result.serverMessage || '') : (result.serverMessage || statusInfo); + return [` ${paint(result.status.toUpperCase())}`, result.domainName, detail || '']; + }); + return table(rows); +}; + +export { renderPlan, renderPlans, renderSummary, renderResults, planSummary }; diff --git a/lib/dns/portalClient.js b/lib/dns/portalClient.js new file mode 100644 index 00000000..548b290d --- /dev/null +++ b/lib/dns/portalClient.js @@ -0,0 +1,156 @@ +import { apiRequest } from '../apiRequest.js'; + +const DESTRUCTIVE_PREFIX = 'Destructive DNS change blocked'; + +class PortalAuthError extends Error { + constructor(portalUrl) { + super( + `Not authorized on ${portalUrl} — the stored token is invalid or expired. ` + + 'Run `pos-cli env refresh-token ` or pass --token/--email.' + ); + this.name = 'PortalAuthError'; + this.portalUrl = portalUrl; + } +} + +class PortalAccessError extends Error { + constructor(portalUrl, instanceUuid) { + super( + `Your user lacks write access to instance ${instanceUuid} on ${portalUrl}. ` + + 'Ask the instance owner to grant your portal user WRITE permission, or authenticate as a user that has it.' + ); + this.name = 'PortalAccessError'; + this.portalUrl = portalUrl; + this.instanceUuid = instanceUuid; + } +} + +class DestructiveChangeError extends Error { + constructor(serverMessage, domainName) { + super(serverMessage); + this.name = 'DestructiveChangeError'; + this.domainName = domainName; + } +} + +class ReadOnlyPortalError extends Error { + constructor(portalUrl, operation) { + super(`Refusing ${operation} on ${portalUrl} — this portal is configured as a read-only source.`); + this.name = 'ReadOnlyPortalError'; + this.portalUrl = portalUrl; + } +} + +const errorsArray = (body) => { + const errors = body && body.errors; + if (Array.isArray(errors)) return errors.filter(e => typeof e === 'string'); + return []; +}; + +class DnsPortalClient { + constructor({ baseUrl, token, readOnly = false }) { + if (!baseUrl) throw new Error('DnsPortalClient requires a baseUrl'); + this.baseUrl = String(baseUrl).replace(/\/+$/, ''); + this.token = token; + this.readOnly = readOnly; + } + + static async authenticate(baseUrl, email, password) { + const base = String(baseUrl).replace(/\/+$/, ''); + try { + const response = await apiRequest({ + method: 'POST', + uri: `${base}/api/authenticate`, + body: { email, password } + }); + if (!response || !response.auth_token) throw new PortalAuthError(base); + return response.auth_token; + } catch (error) { + if (error.name === 'StatusCodeError' && error.statusCode === 401) throw new PortalAuthError(base); + throw error; + } + } + + listInstances() { + return this.request('GET', '/api/instances'); + } + + searchInstances({ domain }) { + return this.request('GET', `/api/instances/search?domain=${encodeURIComponent(domain)}`); + } + + listDomains(instanceUuid, { version = 2 } = {}) { + const query = new URLSearchParams({ instance_uuid: instanceUuid, version: String(version) }); + return this.request('GET', `/api/domains?${query}`, { instanceUuid }); + } + + getDomain(name, instanceUuid, { version = 2 } = {}) { + // The show action reads params[:name], but the route binds the path segment to + // params[:id] (dots in domain names trigger format inference) — the name must + // ALSO be passed as a query param or the portal responds 404 for every domain. + const query = new URLSearchParams({ instance_uuid: instanceUuid, name, version: String(version) }); + return this.request('GET', `/api/domains/${encodeURIComponent(name)}?${query}`, { instanceUuid }); + } + + upsertDomain(payload) { + this.assertWritable('POST /api/domains'); + return this.request('POST', '/api/domains', { + instanceUuid: payload.instance_uuid, + domainName: payload.name, + body: payload + }); + } + + refreshDomain(name, instanceUuid) { + this.assertWritable(`POST /api/domains/${name}/refresh`); + const query = new URLSearchParams({ instance_uuid: instanceUuid }); + return this.request('POST', `/api/domains/${encodeURIComponent(name)}/refresh?${query}`, { + instanceUuid, + domainName: name, + body: {} + }); + } + + assertWritable(operation) { + if (this.readOnly) throw new ReadOnlyPortalError(this.baseUrl, operation); + } + + async request(method, path, { instanceUuid, domainName, body } = {}) { + try { + return await apiRequest({ + method, + uri: `${this.baseUrl}${path}`, + headers: { Authorization: `Bearer ${this.token}` }, + ...(body ? { body } : {}) + }); + } catch (error) { + throw this.mapError(error, { instanceUuid, domainName }); + } + } + + mapError(error, { instanceUuid, domainName } = {}) { + if (error.name !== 'StatusCodeError') return error; + + const responseBody = error.response && error.response.body; + const errors = errorsArray(responseBody); + + if (error.statusCode === 401) return new PortalAuthError(this.baseUrl); + if (errors.some(message => message.includes('NotAuthorized'))) { + return new PortalAccessError(this.baseUrl, instanceUuid); + } + if (error.statusCode === 422 && errors.some(message => message.startsWith(DESTRUCTIVE_PREFIX))) { + const serverMessage = errors.find(message => message.startsWith(DESTRUCTIVE_PREFIX)); + return new DestructiveChangeError(serverMessage, domainName); + } + return error; + } +} + +export { + DnsPortalClient, + PortalAuthError, + PortalAccessError, + DestructiveChangeError, + ReadOnlyPortalError, + DESTRUCTIVE_PREFIX +}; diff --git a/lib/dns/transform.js b/lib/dns/transform.js new file mode 100644 index 00000000..1eacac4c --- /dev/null +++ b/lib/dns/transform.js @@ -0,0 +1,154 @@ +import { domainName } from './exportSchema.js'; +import { normalizeRecordValue, normalizeName } from './normalize.js'; + +// Valid types per partner-portal app/models/api/record.rb on the private-stack branch. +const VALID_TYPES = new Set(['A', 'TXT', 'ALIAS', 'CNAME', 'MX', 'PTR', 'SRV', 'SPF', 'NS']); +const NAME_FORMAT = /^[a-z0-9_.-]*$/; +const MX_FORMAT = /^\d{1,3}\s\S+$/; +const SRV_FORMAT = /^\d{1,5}\s\d{1,5}\s\d{1,5}\s\S+$/; +const DEFAULT_TTL = 3600; + +// extra_dns_records carries ONLY customer-created records (platform fallback/service records +// are never exposed by the API), so nothing platform-side needs filtering here. Values that +// reference old-stack infrastructure are still customer records — keep them, but flag for review. +const OLD_INFRA_PATTERNS = [/\.elb\.amazonaws\.com\.?$/i, /^_fallback\./i]; + +const deriveEnableWwwRedirect = (domain) => + domain?.www_redirect?.enabled ?? !!domain?.attributes?.config?.enable_www_redirect; + +const deriveSetupType = (domain) => + domain?.attributes?.setup_type || + ((domain?.details?.dns_zone_name_servers || []).length ? 'domain-full' : 'domain-external'); + +// The POST's use_as_default applies to the www companion when the redirect is on +// (Api::DomainsController#domain_bundle), so honor whichever of the pair holds it. +const deriveUseAsDefault = (domain, name) => { + const domains = domain?.attributes?.config?._domains || []; + return domains.some(entry => [name, `www.${name}`].includes(entry?.name) && entry.use_as_default); +}; + +const isWwwRedirectRecord = (record, name) => + record.name === 'www' && record.type === 'CNAME' && record.records.length === 1 && record.records[0] === name; + +const isPlatformSubdomain = (name) => /\.(platform-os\.com|platformos\.dev)$/i.test(name || ''); + +const describeRecord = (record) => `${record.name || '@'} ${record.type} -> ${record.records.join(', ')}`; + +const validateRecord = (record) => { + if (!VALID_TYPES.has(record.type)) { + return `record "${describeRecord(record)}": type ${record.type} is not supported by the target portal (${[...VALID_TYPES].join(', ')})`; + } + if (!NAME_FORMAT.test(record.name)) { + return `record "${describeRecord(record)}": name is invalid — allowed characters are a-z, 0-9, '_', '.', '-'`; + } + if (!record.records.length) { + return `record "${describeRecord(record)}": has no values`; + } + if (['CNAME', 'ALIAS'].includes(record.type) && record.records.length > 1) { + return `record "${record.name || '@'}" (${record.type}): the target accepts exactly one value — split into separate records or fix at source`; + } + if (record.type === 'MX' && !record.records.every(value => MX_FORMAT.test(value))) { + return `record "${describeRecord(record)}": invalid MX value — expected " "`; + } + if (record.type === 'SRV' && !record.records.every(value => SRV_FORMAT.test(value))) { + return `record "${describeRecord(record)}": invalid SRV value — expected " "`; + } + return null; +}; + +// Pure transform of one source domain-status object into a target POST /api/domains payload. +// Returns { domainName, payload|null, kept, dropped, errors, warnings, skipped }. +const transformDomain = (sourceDomain, { targetInstanceUuid, dropValuePatterns = [] } = {}) => { + const name = domainName(sourceDomain); + const result = { domainName: name, payload: null, kept: [], dropped: [], errors: [], warnings: [], skipped: false }; + + if (!name) { + result.errors.push('cannot determine the domain name from the export entry'); + return result; + } + if (!sourceDomain.status) { + result.skipped = true; + result.skipReason = 'not provisioned on the source portal — nothing to migrate'; + return result; + } + if (isPlatformSubdomain(name)) { + result.skipped = true; + result.skipReason = 'platform subdomain — the target instance has its own'; + return result; + } + + const enableWwwRedirect = deriveEnableWwwRedirect(sourceDomain); + const sourceRecords = sourceDomain?.attributes?.config?.extra_dns_records || []; + + for (const raw of sourceRecords) { + const type = (raw.type || '').toUpperCase(); + const record = { + name: normalizeName(raw.name, name), + type, + ttl: raw.ttl || DEFAULT_TTL, + records: (raw.records || []).map(value => normalizeRecordValue(type, String(value))) + }; + if (typeof raw.proxied === 'boolean') record.proxied = raw.proxied; + + if (enableWwwRedirect && isWwwRedirectRecord(record, name)) { + result.dropped.push({ record, reason: 'auto-added by the target portal from enable_www_redirect' }); + continue; + } + + const dropPattern = dropValuePatterns.find(pattern => record.records.some(value => pattern.test(value))); + if (dropPattern) { + result.dropped.push({ record, reason: `matches --drop-value ${dropPattern}` }); + continue; + } + + const error = validateRecord(record); + if (error) { + result.errors.push(error); + continue; + } + + if (OLD_INFRA_PATTERNS.some(pattern => record.records.some(value => pattern.test(value)))) { + result.warnings.push( + `record "${describeRecord(record)}" points at old-stack infrastructure — verify it is still needed after migration` + ); + } + + result.kept.push(record); + } + + if (result.errors.length) return result; + + const setupType = deriveSetupType(sourceDomain); + if (setupType === 'domain-external' && result.kept.length) { + result.warnings.push( + 'domain-external: the records are stored on the target portal but not applied anywhere — your own DNS provider remains authoritative for this domain' + ); + } + + result.payload = { + name, + instance_uuid: targetInstanceUuid, + setup_type: setupType, + use_as_default: deriveUseAsDefault(sourceDomain, name), + enable_www_redirect: enableWwwRedirect, + extra_dns_records: result.kept + }; + return result; +}; + +const transformEnvelope = (envelope, options = {}) => { + const plans = (envelope.domains || []).map(domain => transformDomain(domain, options)); + const errors = plans.flatMap(plan => + plan.errors.map(error => `${plan.domainName || '(unknown domain)'}: ${error}`) + ); + return { plans, errors }; +}; + +export { + transformDomain, + transformEnvelope, + deriveEnableWwwRedirect, + deriveSetupType, + deriveUseAsDefault, + isPlatformSubdomain +}; diff --git a/package-lock.json b/package-lock.json index 63c84fdd..63c155e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,12 @@ "pos-cli-check-init": "bin/pos-cli-check-init.js", "pos-cli-check-run": "bin/pos-cli-check-run.js", "pos-cli-deploy": "bin/pos-cli-deploy.js", + "pos-cli-dns": "bin/pos-cli-dns.js", + "pos-cli-dns-compare": "bin/pos-cli-dns-compare.js", + "pos-cli-dns-export": "bin/pos-cli-dns-export.js", + "pos-cli-dns-import": "bin/pos-cli-dns-import.js", + "pos-cli-dns-migrate": "bin/pos-cli-dns-migrate.js", + "pos-cli-dns-status": "bin/pos-cli-dns-status.js", "pos-cli-env": "bin/pos-cli-env.js", "pos-cli-env-add": "bin/pos-cli-env-add.js", "pos-cli-env-list": "bin/pos-cli-env-list.js", diff --git a/package.json b/package.json index 55672812..422675ac 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,12 @@ "pos-cli-check-init": "bin/pos-cli-check-init.js", "pos-cli-check-run": "bin/pos-cli-check-run.js", "pos-cli-deploy": "bin/pos-cli-deploy.js", + "pos-cli-dns": "bin/pos-cli-dns.js", + "pos-cli-dns-export": "bin/pos-cli-dns-export.js", + "pos-cli-dns-import": "bin/pos-cli-dns-import.js", + "pos-cli-dns-migrate": "bin/pos-cli-dns-migrate.js", + "pos-cli-dns-status": "bin/pos-cli-dns-status.js", + "pos-cli-dns-compare": "bin/pos-cli-dns-compare.js", "pos-cli-env": "bin/pos-cli-env.js", "pos-cli-env-add": "bin/pos-cli-env-add.js", "pos-cli-env-list": "bin/pos-cli-env-list.js", diff --git a/test/integration/dns.test.js b/test/integration/dns.test.js new file mode 100644 index 00000000..4c14dfd1 --- /dev/null +++ b/test/integration/dns.test.js @@ -0,0 +1,77 @@ +/** + * Integration tests for `pos-cli dns` — gated on real portal credentials. + * + * Required env vars (skipped otherwise): + * POS_DNS_TEST_SOURCE_PORTAL_URL, POS_DNS_TEST_SOURCE_TOKEN, POS_DNS_TEST_SOURCE_UUID + * Optional (enables the compare round-trip against a second portal/instance): + * POS_DNS_TEST_TARGET_PORTAL_URL, POS_DNS_TEST_TARGET_TOKEN, POS_DNS_TEST_TARGET_UUID + * + * Read-only against the portals: exports + offline dry-run import + offline compare. + * The live-write migrate path is exercised manually (see backlog task-1.9). + */ +import { describe, test, expect, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import exec from '#test/utils/exec'; +import cliPath from '#test/utils/cliPath'; + +vi.setConfig({ testTimeout: 120000 }); + +const SOURCE = { + portalUrl: process.env.POS_DNS_TEST_SOURCE_PORTAL_URL, + token: process.env.POS_DNS_TEST_SOURCE_TOKEN, + uuid: process.env.POS_DNS_TEST_SOURCE_UUID +}; +const TARGET = { + portalUrl: process.env.POS_DNS_TEST_TARGET_PORTAL_URL, + token: process.env.POS_DNS_TEST_TARGET_TOKEN, + uuid: process.env.POS_DNS_TEST_TARGET_UUID +}; + +const hasSource = SOURCE.portalUrl && SOURCE.token && SOURCE.uuid; +const hasTarget = TARGET.portalUrl && TARGET.token && TARGET.uuid; + +describe.skipIf(!hasSource)('pos-cli dns (integration)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-dns-')); + const exportFile = path.join(tmpDir, 'export.json'); + + test('export produces a valid envelope from the live portal', async () => { + const { code } = await exec( + `${cliPath} dns export --portal-url ${SOURCE.portalUrl} --token ${SOURCE.token} --instance-uuid ${SOURCE.uuid} -o ${exportFile}` + ); + expect(code).toEqual(0); + + const envelope = JSON.parse(fs.readFileSync(exportFile, 'utf8')); + expect(envelope.schema).toEqual('pos-cli/dns-export/v1'); + expect(envelope.instance.uuid).toEqual(SOURCE.uuid); + expect(Array.isArray(envelope.domains)).toBe(true); + expect(envelope.domains.every(domain => domain?.details?.state === undefined)).toBe(true); + }); + + test('offline dry-run import renders a plan without touching any portal', async () => { + const { stdout, code } = await exec( + `${cliPath} dns import --file ${exportFile} --instance-uuid fake-target-uuid --dry-run` + ); + expect(code).toEqual(0); + expect(stdout).toMatch(/domain\(s\) to apply|skipped/); + }); + + test('offline self-compare is clean', async () => { + const { stdout, code } = await exec( + `${cliPath} dns compare --source-file ${exportFile} --target-file ${exportFile}` + ); + expect(code).toEqual(0); + expect(stdout).toMatch(/Critical: 0/); + }); + + test.skipIf(!hasTarget)('cross-portal compare runs against both live portals', async () => { + const { stdout } = await exec( + `${cliPath} dns compare ` + + `--source-portal-url ${SOURCE.portalUrl} --source-token ${SOURCE.token} --source-instance-uuid ${SOURCE.uuid} ` + + `--target-portal-url ${TARGET.portalUrl} --target-token ${TARGET.token} --target-instance-uuid ${TARGET.uuid} ` + + '--ignore-status' + ); + expect(stdout).toMatch(/OK: \d+ {2}Advisory: \d+ {2}Critical: \d+/); + }); +}); diff --git a/test/unit/dns/apply.test.js b/test/unit/dns/apply.test.js new file mode 100644 index 00000000..3d6682f7 --- /dev/null +++ b/test/unit/dns/apply.test.js @@ -0,0 +1,170 @@ +/** + * Unit tests for lib/dns/apply.js + * applyPlans: sequential upserts with destructive-change guard and status polling. + */ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import nock from 'nock'; + +vi.mock('#lib/logger.js', () => ({ + default: { Debug: vi.fn(), Warn: vi.fn(), Error: vi.fn(), Info: vi.fn() } +})); + +import { DnsPortalClient } from '#lib/dns/portalClient.js'; +import { applyPlans } from '#lib/dns/apply.js'; + +const PORTAL = 'https://portal.target.test'; +const UUID = 'target-uuid'; + +const plan = (name, overrides = {}) => ({ + domainName: name, + payload: { + name, + instance_uuid: UUID, + setup_type: 'domain-external', + use_as_default: false, + enable_www_redirect: false, + extra_dns_records: [] + }, + kept: [], + dropped: [], + errors: [], + warnings: [], + skipped: false, + ...overrides +}); + +const fastOpts = { pollIntervalMs: 1, timeoutMs: 50, interPostDelayMs: 0 }; + +describe('applyPlans', () => { + let client; + + beforeEach(() => { + nock.disableNetConnect(); + client = new DnsPortalClient({ baseUrl: PORTAL, token: 't' }); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + test('POSTs each applicable plan and polls until the provision settles', async () => { + nock(PORTAL).post('/api/domains', body => body.name === 'a.test' && body.confirm_destructive === undefined) + .reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true) + .reply(200, { status: 'initializing', locked: true }); + nock(PORTAL).get('/api/domains/a.test').query(true) + .reply(200, { status: 'ownership_verification_pending', substatus: null, locked: false }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + domainName: 'a.test', + status: 'applied', + finalStatus: 'ownership_verification_pending' + }); + }); + + test('destructive 422 is reported as blocked-destructive and never auto-retried', async () => { + const warning = 'Destructive DNS change blocked: would delete 7 managed records. Re-submit with explicit confirmation (confirm_destructive) to proceed.'; + const scope = nock(PORTAL).post('/api/domains').reply(422, { errors: [warning] }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts }); + + expect(results[0]).toMatchObject({ domainName: 'a.test', status: 'blocked-destructive', serverMessage: warning }); + expect(scope.isDone()).toBe(true); + expect(nock.pendingMocks()).toEqual([]); + }); + + test('confirmDestructive forwards confirm_destructive=true', async () => { + nock(PORTAL).post('/api/domains', body => body.confirm_destructive === true) + .reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true) + .reply(200, { status: 'ready', locked: false }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], confirmDestructive: true, ...fastOpts }); + expect(results[0].status).toEqual('applied'); + }); + + test('other errors are recorded per-domain and do not abort the batch', async () => { + nock(PORTAL).post('/api/domains').reply(500, { errors: ['DomainsAPIError: boom'] }); + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/b.test').query(true).reply(200, { status: 'ready', locked: false }); + + const { results } = await applyPlans({ client, plans: [plan('a.test'), plan('b.test')], ...fastOpts }); + + expect(results[0].status).toEqual('error'); + expect(results[1].status).toEqual('applied'); + }); + + test('skipped and errored plans are not POSTed', async () => { + const plans = [ + plan('skip.test', { skipped: true, skipReason: 'not provisioned', payload: null }), + plan('bad.test', { errors: ['record invalid'], payload: null }) + ]; + + const { results } = await applyPlans({ client, plans, ...fastOpts }); + + expect(results.map(r => r.status)).toEqual(['skipped', 'invalid']); + expect(nock.pendingMocks()).toEqual([]); + }); + + test('a response without the locked field still settles (TASK-1.15 tolerance)', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true) + .reply(200, { status: 'ready' }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts }); + expect(results[0]).toMatchObject({ status: 'applied', finalStatus: 'ready', stillProcessing: false }); + }); + + test('wait: false skips polling', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], wait: false, ...fastOpts }); + expect(results[0].status).toEqual('applied'); + expect(results[0].finalStatus).toBeUndefined(); + }); + + test('a failed provision worker run surfaces as apply-failed with the server message', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).reply(200, { + status: 'unknown', + locked: false, + last_operation_status: { + operation: 'apply', + status: 'failed', + message: ['Dns::DomainProvisioner::ProvisionError: DNS record create failed: [{"code"=>81053}]'] + } + }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts }); + + expect(results[0].status).toEqual('apply-failed'); + expect(results[0].serverMessage).toContain('81053'); + }); + + test('a blocked provision worker run surfaces as blocked-destructive', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).reply(200, { + status: 'ready', + locked: false, + last_operation_status: { operation: 'apply', status: 'blocked', message: ['Destructive DNS change blocked: ...'] } + }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts }); + expect(results[0].status).toEqual('blocked-destructive'); + }); + + test('polling timeout reports the last seen status as still processing', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).times(100) + .reply(200, { status: 'initializing', locked: true }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts, timeoutMs: 5 }); + expect(results[0].status).toEqual('applied'); + expect(results[0].finalStatus).toEqual('initializing'); + expect(results[0].stillProcessing).toBe(true); + }); +}); diff --git a/test/unit/dns/auth.test.js b/test/unit/dns/auth.test.js new file mode 100644 index 00000000..d2f0e970 --- /dev/null +++ b/test/unit/dns/auth.test.js @@ -0,0 +1,206 @@ +/** + * Unit tests for lib/dns/auth.js + * resolvePortalContext: per-side (source/target) portal credentials + instance uuid resolution. + */ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import nock from 'nock'; + +vi.mock('#lib/logger.js', () => ({ + default: { Debug: vi.fn(), Warn: vi.fn(), Error: vi.fn(), Info: vi.fn(), Log: vi.fn() } +})); + +const mockSettings = vi.fn(); +vi.mock('#lib/settings.js', () => ({ + settingsFromDotPos: (env) => mockSettings(env) +})); + +const mockReadPassword = vi.fn(); +vi.mock('#lib/utils/password.js', () => ({ + readPassword: () => mockReadPassword() +})); + +import { resolvePortalContext } from '#lib/dns/auth.js'; + +const PORTAL = 'https://portal.source.test'; +const UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const INSTANCE_URL = 'https://myapp.staging.oregon.platform-os.com'; +const HOSTNAME = 'myapp.staging.oregon.platform-os.com'; + +const stubInstances = (portal = PORTAL) => + nock(portal).get('/api/instances').reply(200, { data: [{ name: 'myapp', uuid: UUID }] }); + +describe('resolvePortalContext', () => { + beforeEach(() => { + nock.disableNetConnect(); + mockSettings.mockReset(); + mockReadPassword.mockReset(); + delete process.env.PARTNER_PORTAL_HOST; + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + test('resolves portal url + token from the .pos environment entry', async () => { + mockSettings.mockReturnValue({ + url: INSTANCE_URL, + token: 'pos-token', + email: 'user@example.com', + partner_portal_url: PORTAL + }); + stubInstances(); + nock(PORTAL, { reqheaders: { authorization: 'Bearer pos-token' } }) + .get('/api/instances/search') + .query({ domain: HOSTNAME }) + .reply(200, { data: [{ name: 'myapp', uuid: UUID }] }); + + const context = await resolvePortalContext('staging', { label: 'source' }); + + expect(mockSettings).toHaveBeenCalledWith('staging'); + expect(context.portalUrl).toEqual(PORTAL); + expect(context.instanceUuid).toEqual(UUID); + expect(context.client.readOnly).toBe(false); + }); + + test('flag overrides win over .pos values and instanceUuid skips the search', async () => { + mockSettings.mockReturnValue({ url: INSTANCE_URL, token: 'pos-token', partner_portal_url: PORTAL }); + const OTHER = 'https://portal.other.test'; + stubInstances(OTHER); + + const context = await resolvePortalContext('staging', { + portalUrl: OTHER, + token: 'flag-token', + instanceUuid: 'flag-uuid', + label: 'target' + }); + + expect(context.portalUrl).toEqual(OTHER); + expect(context.instanceUuid).toEqual('flag-uuid'); + expect(context.client.token).toEqual('flag-token'); + }); + + test('ambient PARTNER_PORTAL_HOST is ignored — dns commands never resolve portals from a process-global (TASK-1.14)', async () => { + process.env.PARTNER_PORTAL_HOST = 'https://ambient.portal.test'; + mockSettings.mockReturnValue({ url: INSTANCE_URL, token: 't' }); + nock('https://partners.platformos.com').get('/api/instances').reply(200, { data: [] }); + + const context = await resolvePortalContext('staging', { instanceUuid: UUID, label: 'source', readOnly: true }); + expect(context.portalUrl).toEqual('https://partners.platformos.com'); + }); + + test('a scheme-less portal url produces an actionable error (TASK-1.14)', async () => { + mockSettings.mockReturnValue({ url: INSTANCE_URL, token: 't', partner_portal_url: 'portal.example.com' }); + await expect(resolvePortalContext('staging', { instanceUuid: UUID, label: 'source', readOnly: true })) + .rejects.toThrow(/include the scheme.*https:\/\/portal\.example\.com/s); + }); + + test('errors when the environment is missing from .pos', async () => { + mockSettings.mockReturnValue(undefined); + await expect(resolvePortalContext('nope', { label: 'source' })) + .rejects.toThrow('No settings for source environment "nope"'); + }); + + test('errors when no token can be resolved', async () => { + mockSettings.mockReturnValue({ url: INSTANCE_URL, partner_portal_url: PORTAL }); + await expect(resolvePortalContext('staging', { label: 'source' })) + .rejects.toThrow('No credentials for source portal'); + }); + + test('ambiguous domain match lists candidates and asks for --