diff --git a/CHANGELOG.md b/CHANGELOG.md index 87dd93bb..51531066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ # Changelog +## Unreleased + ## 6.2.4 (2026-07-24) +### 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 (bulk via `--instances-file`, sharing one backup directory per run, one file per instance); `dns migrate ` copies them portal-to-portal (dry-run support, 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. Bulk cohort support via `--instances-file`/`--mapping-file`, with a per-instance summary table (applied/blocked/error counts). +* Safety: the plan is displayed and must be confirmed interactively before anything is applied — the prompt names the actual instance being written to — with `--yes` to skip in scripts/CI; `partners.platformos.com` is protected as read-only and can only be a migration source, so an accidental `migrate ` argument swap fails immediately (`--unsafe-allow-protected-target` to override); `--json` runs refuse a blind interactive confirmation (pass `--yes` or `--dry-run` instead); exit codes are unified and documented across single and bulk modes (0/1/2/3). +* `dns compare` filters out expected cross-stack noise (data centers, nameservers, MX case, TXT chunking, and records whose name one DNS provider stores fully-qualified and another stores short) while still catching real differences (status, setup type, record intent, and any field an export had to strip for exceeding the size limit); `--drop-value` excludes records a migration intentionally dropped; `--domain` scopes both single-pair and bulk (`--mapping-file`) runs; `--raw` compares byte-for-byte instead. +* Network failures during any `dns` command get the same friendly, host-naming error messages the rest of pos-cli uses. + ### Fixes * `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..f54feb49 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, and records whose name one DNS provider stores fully-qualified and another stores short, e.g. SRV records moving from Route53 to Cloudflare) 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.16 - dns-compare-stripped-oversized-DNS-record-details-cause-silent-false-negatives.md b/backlog/tasks/task-1.16 - dns-compare-stripped-oversized-DNS-record-details-cause-silent-false-negatives.md new file mode 100644 index 00000000..1ff8e825 --- /dev/null +++ b/backlog/tasks/task-1.16 - dns-compare-stripped-oversized-DNS-record-details-cause-silent-false-negatives.md @@ -0,0 +1,40 @@ +--- +id: TASK-1.16 +title: >- + dns compare: stripped oversized DNS record details cause silent false + negatives +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'lib/dns/exportSchema.js:15' + - lib/dns/compare.js +parent_task_id: TASK-1 +priority: high +--- + +## Description + + +Code review of the DNS migration feature found that `dns export`'s `stripBulkyDetails` (lib/dns/exportSchema.js:15) replaces any `details.` value exceeding 100KB (e.g. `dns_verification_records`, `extra_dns_records`, `dns_zone_name_servers`) with a plain string like `[stripped: ... exceeded ... bytes]`. + +When such an export file is later used as `--source-file`/`--target-file` in `pos-cli dns compare`, `lib/dns/compare.js`'s `indexVerificationRecords`/`indexLiveRecords` iterate that field expecting an array of record objects. Each character of the string is skipped by the `typeof record !== 'object'` guard, so the index silently comes back empty instead of erroring. Any real record drift in that oversized field becomes invisible in the compare output — the tool reports OK/no advisory even though the two portals actually differ, defeating the DNS-parity check for exactly the domains most likely to have real content (many live DNS records). + +This is a correctness gap in a safety tool: `dns compare` exists specifically to catch drift before/after migration, and a silent false negative undermines that guarantee. + + +## Acceptance Criteria + +- [x] #1 When `dns compare` processes a domain whose details field was stripped during export (per exportSchema.js's stripBulkyDetails marker), it surfaces a clear warning or error for that domain instead of silently treating the field as empty/matching +- [x] #2 The comparison result for a domain with stripped details is never reported as clean/OK without an explicit caveat that some record data could not be compared +- [x] #3 Add a test in test/ covering a domain whose exported details exceeded the stripping threshold, verifying compare surfaces the caveat/error rather than a false-clean result + + +## Final Summary + + +Added `isStrippedDetail()` to lib/dns/exportSchema.js and a symmetric `uncomparableDetail()` guard in lib/dns/compare.js covering all three details fields compare reads (dns_verification_records, extra_dns_records, dns_zone_name_servers). When either side's field was stripped during export, compareDomain now pushes a CRITICAL message ("could not be compared — the export stripped it...") instead of silently indexing the marker string as an empty array. Added unit tests in exportSchema.test.js and compare.test.js. + diff --git a/backlog/tasks/task-1.17 - dns-compare-domain-filter-silently-ignored-in-bulk-mapping-file-mode.md b/backlog/tasks/task-1.17 - dns-compare-domain-filter-silently-ignored-in-bulk-mapping-file-mode.md new file mode 100644 index 00000000..fdb6cf83 --- /dev/null +++ b/backlog/tasks/task-1.17 - dns-compare-domain-filter-silently-ignored-in-bulk-mapping-file-mode.md @@ -0,0 +1,35 @@ +--- +id: TASK-1.17 +title: 'dns compare: --domain filter silently ignored in bulk (--mapping-file) mode' +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'bin/pos-cli-dns-compare.js:59' +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +Code review found that in `bin/pos-cli-dns-compare.js`, the bulk `params.mappingFile` branch (lines 59-103) never reads `params.domain`. It calls `compareInstance(sourceSide.domains, targetSide.domains, {...})` for every domain in each instance pair with no filtering applied — unlike the non-bulk branch further down, which does `results.filter(result => wanted.has(...))`. + +Running `pos-cli dns compare --mapping-file pairs.csv --domain foo.com` therefore silently prints/returns full per-instance results for every domain instead of the requested subset, and the exit code / totals are based on all domains rather than the filtered set. This is misleading in CI, where the caller expects a scoped pass/fail signal for just the named domain. + + +## Acceptance Criteria + +- [x] #1 When `pos-cli dns compare --mapping-file --domain ` is run, only the named domain is compared/reported per instance pair in the bulk branch, matching the filtering behavior of the non-bulk branch +- [x] #2 Totals and exit code in bulk mode reflect only the filtered domain set when --domain is supplied +- [x] #3 Add a test covering bulk compare combined with --domain, asserting output/totals are scoped to the named domain + + +## Final Summary + + +Extracted the domain-filter + totals-recompute logic (previously inline only in the single-pair branch) into a shared `filterOutcomeByDomain()` helper in lib/dns/cliHelpers.js, and applied it in both the bulk (--mapping-file) and single-pair branches of bin/pos-cli-dns-compare.js. --domain now scopes results/totals identically in both modes. Added unit tests in cliHelpers.test.js. + diff --git a/backlog/tasks/task-1.18 - dns-migrate-bulk-backups-scatter-across-per-pair-timestamped-directories-instead-of-one-shared-cohort-dir.md b/backlog/tasks/task-1.18 - dns-migrate-bulk-backups-scatter-across-per-pair-timestamped-directories-instead-of-one-shared-cohort-dir.md new file mode 100644 index 00000000..c15d84f1 --- /dev/null +++ b/backlog/tasks/task-1.18 - dns-migrate-bulk-backups-scatter-across-per-pair-timestamped-directories-instead-of-one-shared-cohort-dir.md @@ -0,0 +1,39 @@ +--- +id: TASK-1.18 +title: >- + dns migrate: bulk backups scatter across per-pair timestamped directories + instead of one shared cohort dir +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'lib/dns/cliHelpers.js:22' + - 'bin/pos-cli-dns-migrate.js:33' +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +Code review found that `backupPathFor()` in `lib/dns/cliHelpers.js:22` generates a fresh timestamp on every call: `const dir = (!backup || backup === true) ? \`dns-backups-${timestamp()}\` : backup;`. + +`migratePair` (bin/pos-cli-dns-migrate.js:33) calls `backupPathFor(params.backup, sourceUuid, { bulk })` once per pair. When running bulk `dns migrate` (`--instances-file`/`--mapping-file`, `--match-by-domain`) without an explicit `--backup` flag, every pair in the cohort gets a different `dns-backups-/` directory (since each pair's export/transform takes at least a second), rather than the single shared cohort directory that `dns export`'s bulk mode already produces (by computing `outDir` once, before its loop). For a 20-instance cohort this leaves 20 differently-named top-level directories with one backup file each, and no single place to find/restore "the backup for this migration run". + + +## Acceptance Criteria + +- [x] #1 Bulk `dns migrate` without an explicit --backup uses one shared timestamped directory computed once for the whole bulk run, matching the pattern already used by dns export's bulk mode +- [x] #2 Each pair's backup file is written into that single shared directory rather than a per-pair directory +- [x] #3 Explicit --backup behavior (single directory when it exists) is unchanged +- [x] #4 Add/update a test simulating multiple pairs in a bulk migrate run and asserting all backups land in the same directory + + +## Final Summary + + +Added `resolveBulkBackupDir()` to lib/dns/cliHelpers.js, called once in bin/pos-cli-dns-migrate.js before the per-pair loop; the resolved directory is threaded into each migratePair() call via an overridden `params.backup`, so backupPathFor() receives an already-concrete directory string per pair instead of regenerating `dns-backups-` on every call. Explicit --backup and --no-backup behavior unchanged. Added unit tests in cliHelpers.test.js. + diff --git a/backlog/tasks/task-1.19 - dns-migrate-bulk-summary-table-hides-apply-failed-domains-as-zero-errors.md b/backlog/tasks/task-1.19 - dns-migrate-bulk-summary-table-hides-apply-failed-domains-as-zero-errors.md new file mode 100644 index 00000000..1e7595ad --- /dev/null +++ b/backlog/tasks/task-1.19 - dns-migrate-bulk-summary-table-hides-apply-failed-domains-as-zero-errors.md @@ -0,0 +1,36 @@ +--- +id: TASK-1.19 +title: 'dns migrate: bulk summary table hides apply-failed domains as zero errors' +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'bin/pos-cli-dns-migrate.js:92' + - 'lib/dns/apply.js:88' +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +Code review found that `bulkSummaryRow` in `bin/pos-cli-dns-migrate.js` (lines 92-104) builds its "errors" column by summing only `counts.error` and `counts.invalid`. `lib/dns/apply.js:88-91` sets `result.status = 'apply-failed'` when a domain's apply POST is accepted but the polled `last_operation_status` later reports a provisioning failure. + +Because `bulkSummaryRow` never counts `apply-failed` into applied/blocked/errors, a bulk migrate run's printed table row can show e.g. `domains: 2, applied: 1, blocked: 0, errors: 0` even though one domain actually failed to provision. The overall process still exits 1 (`exitCodeFor` does include `apply-failed`), but an operator scanning the per-instance summary table across a large cohort run has no visual signal of which instance(s) failed, or that anything failed at all for that row. + + +## Acceptance Criteria + +- [x] #1 bulkSummaryRow's error/failure count includes domains with status 'apply-failed' (either folded into the existing errors column or shown as a distinct visible count) +- [x] #2 A bulk migrate run containing an apply-failed domain shows a non-zero failure indicator in that instance's summary row +- [x] #3 Add a test with a mocked apply-failed result verifying the summary row reflects the failure + + +## Final Summary + + +Extracted the bulk per-instance counting logic into `summarizeBulkOutcome()` in lib/dns/cliHelpers.js, now including `apply-failed` in the errors count alongside error/invalid/hasErrors. bin/pos-cli-dns-migrate.js's bulkSummaryRow uses this shared helper. Added unit tests in cliHelpers.test.js covering apply-failed and independent counting of all four columns. + 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.20 - dns-transform-normalizeName-is-case-sensitive-against-domainName-breaking-www-strip-for-mixed-case-domains.md b/backlog/tasks/task-1.20 - dns-transform-normalizeName-is-case-sensitive-against-domainName-breaking-www-strip-for-mixed-case-domains.md new file mode 100644 index 00000000..80cf09c8 --- /dev/null +++ b/backlog/tasks/task-1.20 - dns-transform-normalizeName-is-case-sensitive-against-domainName-breaking-www-strip-for-mixed-case-domains.md @@ -0,0 +1,40 @@ +--- +id: TASK-1.20 +title: >- + dns transform: normalizeName is case-sensitive against domainName, breaking + www-strip for mixed-case domains +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'lib/dns/normalize.js:51' + - lib/dns/transform.js +parent_task_id: TASK-1 +priority: high +--- + +## Description + + +Code review found that `normalizeName()` in `lib/dns/normalize.js` (lines 47-51) lowercases the record name (`short = (name || '').trim().toLowerCase()...`) but then compares its suffix against the raw, un-lowercased `domainName` argument: `if (domainName && short.endsWith(\`.${domainName}\`))`. + +If a source domain's canonical name is ever mixed-case (e.g. `Example.com`, which can occur from a legacy v1 `_domains` fallback name rather than the v2 `attributes.domain_name`), `short` becomes `www.example.com` (lowercased) but is checked against `.Example.com` (not lowercased) — the suffix never matches, so the www record's name stays as the full `www.example.com` instead of the expected short form `www`. + +Downstream, `isWwwRedirectRecord()` in transform.js checks `record.name === 'www'`, so it fails to recognize the record, the auto-added www-redirect CNAME is NOT dropped, and it gets sent to the target as an explicit record. The target portal also auto-adds its own www-redirect from `enable_www_redirect`, producing a duplicate/conflicting record and likely a "destructive change" or duplicate-record rejection on apply. + + +## Acceptance Criteria + +- [x] #1 normalizeName's suffix comparison is case-insensitive with respect to domainName (e.g. lowercase domainName once alongside short before comparing) +- [x] #2 A www CNAME record for a mixed-case domain name (e.g. 'Example.com') is correctly normalized to the short form 'www' and recognized by isWwwRedirectRecord +- [x] #3 Add a unit test in the normalize/transform test suite covering a mixed-case domainName input + + +## Final Summary + + +lib/dns/normalize.js's normalizeName() now lowercases domainName before the apex-equality and suffix checks, so a mixed-case domain name (e.g. 'Example.com') correctly strips to the short form ('www') and matches apex. Added unit test in normalize.test.js covering mixed-case domainName inputs. + diff --git a/backlog/tasks/task-1.21 - dns-network-connection-failures-surface-as-raw-errors-instead-of-friendly-ServerError-messaging.md b/backlog/tasks/task-1.21 - dns-network-connection-failures-surface-as-raw-errors-instead-of-friendly-ServerError-messaging.md new file mode 100644 index 00000000..897c204d --- /dev/null +++ b/backlog/tasks/task-1.21 - dns-network-connection-failures-surface-as-raw-errors-instead-of-friendly-ServerError-messaging.md @@ -0,0 +1,39 @@ +--- +id: TASK-1.21 +title: >- + dns: network/connection failures surface as raw errors instead of friendly + ServerError messaging +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'lib/dns/portalClient.js:118' + - 'lib/apiRequest.js:51' + - lib/ServerError.js +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +Code review found that `DnsPortalClient.mapError` in `lib/dns/portalClient.js` (lines 131-146) only special-cases `error.name === 'StatusCodeError'` and returns any other error unchanged. `lib/apiRequest.js` (lines 51-57) throws a `RequestError` (with a bare message like `'fetch failed'`) when a request fails at the network level (DNS failure, connection refused, timeout). + +If the target/source Partner Portal host is unreachable during `resolvePortalContext`'s `client.listInstances()` call, that RequestError is rethrown verbatim, and the bin file's catch prints just "fetch failed" — no portal URL, no actionable guidance, and none of the `getNetworkErrorCode`-based friendly messaging (ECONNREFUSED/ENOTFOUND handling) that CLAUDE.md documents and that `lib/ServerError.js` already provides for the rest of pos-cli's network calls. + + +## Acceptance Criteria + +- [x] #1 DnsPortalClient (or resolvePortalContext) handles non-StatusCodeError network failures using the same friendly, host-naming error pattern documented in CLAUDE.md's Network Error Handling section (recursive getNetworkErrorCode walk over the cause chain) +- [x] #2 An unreachable portal host produces a user-friendly message identifying the portal URL and the nature of the failure (e.g. connection refused/not found/timeout), not a bare 'fetch failed' +- [ ] #3 Add a test simulating an unreachable portal host (e.g. mocking apiRequest to throw a RequestError) and asserting the friendly message is shown + + +## Final Summary + + +Updated the top-level catch block in all 5 dns bin files (compare, migrate, export, import, status) to check `ServerError.isNetworkError(error)` and route through `ServerError.handler(error)` when true, matching the convention used elsewhere in the codebase (e.g. pos-cli-env-add.js). Typed portal errors (PortalAuthError, PortalAccessError, DestructiveChangeError, ReadOnlyPortalError) are unaffected since their `.name` isn't 'StatusCodeError'/'RequestError'; only generic StatusCodeError and RequestError (network failures) now get the friendly getNetworkErrorCode-based messaging. No new automated test was added for this cross-cutting catch-block change (would require mocking process.exit/fetch at the bin-file level, which isn't set up in this codebase); verified by code inspection against ServerError.isNetworkError's behavior and existing portalClient.test.js coverage of error shapes. + diff --git a/backlog/tasks/task-1.22 - dns-export-dedupe-timestamp-path-resolution-logic-with-lib-dns-cliHelpers.js.md b/backlog/tasks/task-1.22 - dns-export-dedupe-timestamp-path-resolution-logic-with-lib-dns-cliHelpers.js.md new file mode 100644 index 00000000..522ba5e4 --- /dev/null +++ b/backlog/tasks/task-1.22 - dns-export-dedupe-timestamp-path-resolution-logic-with-lib-dns-cliHelpers.js.md @@ -0,0 +1,39 @@ +--- +id: TASK-1.22 +title: 'dns export: dedupe timestamp/path-resolution logic with lib/dns/cliHelpers.js' +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'bin/pos-cli-dns-export.js:13' + - lib/dns/cliHelpers.js +parent_task_id: TASK-1 +priority: low +--- + +## Description + + +Code review found that `bin/pos-cli-dns-export.js` reimplements logic that already exists in `lib/dns/cliHelpers.js`, which was specifically introduced as "shared helpers for the dns bin files": + +- `defaultFilename` (lines 13-14) re-derives the same timestamp format as cliHelpers.js's `timestamp()` (`new Date().toISOString().replace(/[:.]/g, '-')`) +- `resolveOutPath` (lines 16-22) re-implements the same existsSync/statSync-isDirectory branching as the non-bulk branch of cliHelpers.js's `backupPathFor()` +- The bulk `outDir` default (line 51) duplicates the timestamp format a second time + +If the timestamp format or the "existing directory" resolution rule ever needs to change (e.g. a Windows path fix, or a new naming convention), a maintainer updating cliHelpers.js won't notice this file's export filenames silently drifting out of sync with dns migrate's backup filenames. + + +## Acceptance Criteria + +- [x] #1 bin/pos-cli-dns-export.js imports and uses lib/dns/cliHelpers.js's timestamp()/backupPathFor() (or an equivalent shared helper) instead of reimplementing the same logic +- [x] #2 No behavior change: existing dns export tests (default filename, existing-directory resolution, bulk outDir default) continue to pass unchanged + + +## Final Summary + + +Removed the local defaultFilename/resolveOutPath functions from bin/pos-cli-dns-export.js; it now imports and uses lib/dns/cliHelpers.js's backupPathFor() (identical filename/directory-resolution behavior, verified byte-for-byte equivalent) and timestamp() for the bulk outDir default. No behavior change — full unit suite still passes. + diff --git a/backlog/tasks/task-1.23 - dns-dedupe-post-apply-target-status-collection-loop-between-migrate-and-import.md b/backlog/tasks/task-1.23 - dns-dedupe-post-apply-target-status-collection-loop-between-migrate-and-import.md new file mode 100644 index 00000000..2515b4af --- /dev/null +++ b/backlog/tasks/task-1.23 - dns-dedupe-post-apply-target-status-collection-loop-between-migrate-and-import.md @@ -0,0 +1,37 @@ +--- +id: TASK-1.23 +title: >- + dns: dedupe post-apply target-status collection loop between migrate and + import +status: Done +assignee: [] +created_date: '2026-07-24 09:01' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'bin/pos-cli-dns-migrate.js:72' + - 'bin/pos-cli-dns-import.js:86' +parent_task_id: TASK-1 +priority: low +--- + +## Description + + +Code review found that the exact same loop — collecting post-apply target domain statuses via `for (const result of results.filter(entry => entry.status === 'applied')) { targetStatuses.push(result.domainStatus || await client.getDomain(...).catch(() => null)) }` — appears verbatim (only variable names changed) in both `bin/pos-cli-dns-migrate.js` (lines 72-78) and `bin/pos-cli-dns-import.js` (lines 86-92). + +A future fix (e.g. retry logic, or additional invariant checks in the style of task-1.15) applied to one copy but not the other would leave `dns import` and `dns migrate` silently diverging in how cutover instructions are computed after what's documented as the same apply step. + + +## Acceptance Criteria + +- [x] #1 The post-apply target-status collection loop is extracted into a single shared helper (e.g. in lib/dns/cliHelpers.js or lib/dns/apply.js) used by both bin/pos-cli-dns-migrate.js and bin/pos-cli-dns-import.js +- [x] #2 No behavior change: existing migrate and import tests continue to pass unchanged + + +## Final Summary + + +Extracted the post-apply target-status collection loop into `collectAppliedTargetStatuses(client, targetUuid, results)` in lib/dns/apply.js, used identically by both bin/pos-cli-dns-migrate.js and bin/pos-cli-dns-import.js. Added unit tests in apply.test.js covering the domainStatus-reuse path, the --no-wait re-fetch path, and a failed re-fetch resolving to null. + diff --git a/backlog/tasks/task-1.24 - dns-compare-parallelize-sequential-source-target-fetch-in-single-pair-mode.md b/backlog/tasks/task-1.24 - dns-compare-parallelize-sequential-source-target-fetch-in-single-pair-mode.md new file mode 100644 index 00000000..8e359da2 --- /dev/null +++ b/backlog/tasks/task-1.24 - dns-compare-parallelize-sequential-source-target-fetch-in-single-pair-mode.md @@ -0,0 +1,34 @@ +--- +id: TASK-1.24 +title: 'dns compare: parallelize sequential source/target fetch in single-pair mode' +status: Done +assignee: [] +created_date: '2026-07-24 09:02' +updated_date: '2026-07-24 09:12' +labels: [] +dependencies: [] +references: + - 'bin/pos-cli-dns-compare.js:108' +parent_task_id: TASK-1 +priority: low +--- + +## Description + + +Code review found that in `bin/pos-cli-dns-compare.js`, single-pair compare (lines 108-123) does `const source = await loadSide(sourceEnv, ...)` then `const target = await loadSide(targetEnv, ...)` sequentially. Each `loadSide` call does its own `resolvePortalContext` + `fetchDomains` against a different portal — these are independent network round-trips. + +The bulk-mode branch a few lines above (lines 74-77) already parallelizes the same kind of fetch with `Promise.all`. Running the two sides sequentially in single-pair mode roughly doubles wall-clock latency for `pos-cli dns compare ` versus the bulk path, for no benefit, and leaves the file internally inconsistent about whether independent portal fetches should be parallelized. + + +## Acceptance Criteria + +- [x] #1 Single-pair dns compare fetches source and target sides concurrently via Promise.all, matching the bulk-mode code path in the same file +- [x] #2 No behavior change other than latency: existing single-pair compare tests continue to pass unchanged + + +## Final Summary + + +bin/pos-cli-dns-compare.js's single-pair branch now fetches source and target sides concurrently via Promise.all (matching the bulk-mode branch), instead of two sequential awaits. No behavior change other than latency — full unit suite still passes. + diff --git a/backlog/tasks/task-1.25 - Set-up-ESLint-typescript-eslint-checkJs-repo-wide-to-catch-missing-await-floating-promises.md b/backlog/tasks/task-1.25 - Set-up-ESLint-typescript-eslint-checkJs-repo-wide-to-catch-missing-await-floating-promises.md new file mode 100644 index 00000000..cb7c9f95 --- /dev/null +++ b/backlog/tasks/task-1.25 - Set-up-ESLint-typescript-eslint-checkJs-repo-wide-to-catch-missing-await-floating-promises.md @@ -0,0 +1,41 @@ +--- +id: TASK-1.25 +title: >- + Set up ESLint + typescript-eslint (checkJs) repo-wide to catch missing await / + floating promises +status: To Do +assignee: [] +created_date: '2026-07-24 10:32' +labels: [] +dependencies: [] +references: + - lib/dns/cliHelpers.js + - lib/dns/auth.js + - lib/dns/export.js + - lib/logger.js +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +While fixing code-review findings on the dns command group, several `logger.Error/Warn/Info(...)` calls were found missing `await` (lib/dns/cliHelpers.js, lib/dns/auth.js, lib/dns/export.js) — logger's methods are all async, and skipping `await` risks lost output ordering or a `logger.Error({exit:true})` call's `process.exit(1)` firing later than expected relative to surrounding code. + +This repo currently has **no ESLint setup at all** — no config file, no `eslint` devDependency (`npx eslint` only works by fetching a throwaway copy). A CLAUDE.md note documenting "always await logger calls" would not actually catch regressions automatically — it only works if a future session rereads and applies it, which is exactly the gap that caused the original miss. Real automatic enforcement needs a linter. + +There is no plain-ESLint core rule for "floating promise" detection — it requires type information. The standard approach for a JS (non-TS) codebase is `typescript-eslint` configured with `allowJs`/`checkJs` in a `tsconfig.json` (no compilation, purely for type-aware linting), enabling `@typescript-eslint/no-floating-promises` (and likely `@typescript-eslint/no-misused-promises`). This catches ANY unawaited call to a function that returns a promise — not just `logger.*` — repo-wide. + +The user explicitly chose the **full repo-wide** option over scoping to just `lib/dns/**`. Repo-wide is expected to surface many pre-existing violations: a repo-wide grep during this investigation found only ~110 of 292 `logger.*` calls are currently awaited (~38%), and there are likely other unawaited async calls beyond `logger` (fs/promises, network calls, etc.) once the type-aware rule runs. + +Packages were test-installed and then fully reverted per user instruction (do not install anything until this task is picked up): `eslint`, `typescript-eslint`, `typescript` as devDependencies, plus a draft `tsconfig.json` with `allowJs`/`checkJs`/`noEmit`/`strict: false` scoped to `bin/**`, `lib/**`, `mcp-min/**`, `scripts/**` (excluding `gui/**` prebuilt bundles, `test/**`, `node_modules`, `coverage/**`, `examples/**`). That config shape is a reasonable starting point but wasn't validated against the full codebase. + + +## Acceptance Criteria + +- [ ] #1 eslint, typescript-eslint, and typescript are added as devDependencies and an eslint.config.js (flat config) + tsconfig.json (allowJs/checkJs/noEmit, strict disabled to avoid unrelated type-error noise) are committed +- [ ] #2 @typescript-eslint/no-floating-promises (and no-misused-promises) is enabled and runs successfully via a new `npm run lint` script across bin/, lib/, mcp-min/, and scripts/ +- [ ] #3 Every violation surfaced by the initial repo-wide run is triaged: genuine missing-await bugs are fixed, and any call sites that are intentionally fire-and-forget are given a narrow, commented eslint-disable (not a blanket rule-level disable) +- [ ] #4 CI (or at least a documented local command) runs the new lint script so future PRs get automatic feedback on missing await +- [ ] #5 CLAUDE.md is updated with a short note on the new lint command and the always-await-promise-returning-calls convention it now enforces + diff --git a/backlog/tasks/task-1.26 - dns-compare-indexLiveRecords-doesnt-normalize-record-names-across-backend-naming-conventions.md b/backlog/tasks/task-1.26 - dns-compare-indexLiveRecords-doesnt-normalize-record-names-across-backend-naming-conventions.md new file mode 100644 index 00000000..5ff31ea9 --- /dev/null +++ b/backlog/tasks/task-1.26 - dns-compare-indexLiveRecords-doesnt-normalize-record-names-across-backend-naming-conventions.md @@ -0,0 +1,39 @@ +--- +id: TASK-1.26 +title: >- + dns compare: indexLiveRecords doesn't normalize record names across backend + naming conventions +status: Done +assignee: [] +created_date: '2026-07-24 11:11' +updated_date: '2026-07-24 11:17' +labels: [] +dependencies: [] +references: + - 'lib/dns/compare.js:34' +parent_task_id: TASK-1 +priority: high +--- + +## Description + + +Discovered via a real migration test (a customer domain exported from partners.platformos.com, migrated to a private-stack portal, then compared): `pos-cli dns compare` reported the domain's two SRV records (`_sip._tls`, `_sipfederationtls._tcp`) as both "only on source" and "only on target" under `details.extra_dns_records`, even though the records exist on both sides. + +Root cause: `lib/dns/compare.js`'s `indexLiveRecords()` built its comparison Map key from the raw `record.name`, lowercased only — unlike `recordKey()`/`indexIntentRecords()` (used for the "intent" config comparison), which already call `normalizeName(record.name, domain)` to strip the domain suffix. Different DNS-provider backends store the same record's name differently: the legacy stack (Route53) stores SRV record names fully domain-qualified (e.g. `_sip._tls.example.com`), while the private-stack (Cloudflare-backed) stores them short (`_sip._tls`). `indexLiveRecords` keyed these as two different map entries, so the record never matched between source and target and was reported as missing on both sides — pure noise. + +Worse: this false "missing" noise was masking a real, separate drift. Once names are normalized and the records actually match up, the SRV *values* differ: source has `100 1 443 sipdir.online.lync.com.` (full 4-field priority/weight/port/target), target has `1 443 sipdir.online.lync.com` (3 fields — priority dropped). The target's own *intent* config (what pos-cli's transform sent) has the correct 4-field value, so the drop happens between "accepted by the target portal" and "what's actually live" — likely a bug in the target private-stack's Cloudflare integration (Cloudflare models SRV `priority` as a field separate from `weight`/`port`/`target`; a read-back path that only reads the latter three would produce exactly this). That part is NOT a pos-cli issue and is out of scope for this task — flagged separately to whoever owns that private-stack's DNS provisioning code. + + +## Acceptance Criteria + +- [x] #1 indexLiveRecords normalizes record names the same way recordKey/indexIntentRecords already do (normalizeName(record.name, domain) || '@'), so a record stored fully-qualified on one side and short on the other matches correctly +- [x] #2 A record present on both sides with different live values now reports as a single 'records differ' advisory instead of two misleading 'only on source'/'only on target' advisories +- [x] #3 Add a regression test in compare.test.js reproducing the real-world scenario (SRV name fully-qualified on source, short on target, differing values) and asserting it surfaces as 'records differ', not 'only on' + + +## Final Summary + + +Fixed indexLiveRecords (lib/dns/compare.js) to accept the domain name and normalize record names via normalizeName() before building its comparison Map key, matching the existing pattern in recordKey()/indexIntentRecords(). Updated compareDomain's call site to pass the domain name. Added a regression test in compare.test.js reproducing the exact real-world scenario (Route53 fully-qualified SRV name vs Cloudflare short name, with a genuinely differing value) and verified against a real export file pair offline via `pos-cli dns compare --source-file --target-file` — the phantom 'only on source'/'only on target' pair collapsed into a single correct 'records differ' advisory revealing the real SRV-priority drift. Full unit suite (1044 tests) passes. + diff --git a/backlog/tasks/task-1.27 - dns-extract-the-migrate-import-apply-pipeline-and-bulk-cohort-runner-from-bins-into-lib-dns.md b/backlog/tasks/task-1.27 - dns-extract-the-migrate-import-apply-pipeline-and-bulk-cohort-runner-from-bins-into-lib-dns.md new file mode 100644 index 00000000..61c52c8a --- /dev/null +++ b/backlog/tasks/task-1.27 - dns-extract-the-migrate-import-apply-pipeline-and-bulk-cohort-runner-from-bins-into-lib-dns.md @@ -0,0 +1,45 @@ +--- +id: TASK-1.27 +title: >- + dns: extract the migrate/import apply pipeline and bulk cohort runner from + bins into lib/dns +status: To Do +assignee: [] +created_date: '2026-07-24 14:35' +labels: + - dns + - refactoring +dependencies: [] +references: + - bin/pos-cli-dns-migrate.js + - bin/pos-cli-dns-import.js + - bin/pos-cli-dns-compare.js + - lib/dns/cliHelpers.js + - lib/dns/mapping.js +parent_task_id: TASK-1 +priority: medium +--- + +## Description + + +Follow-up to the /simplify altitude review of the dns command group: the two multi-mode bins still contain multi-step orchestration that the repo's thin-bin convention (restated in lib/dns/cliHelpers.js) says belongs in lib/. + +Two duplications remain after the smaller helper extractions (portalFlags, dropValuePatterns, reportApplyResults, writeEnvelope, readEnvelope): + +1. The transform -> plan -> confirm -> apply -> collect statuses -> cutover pipeline is implemented twice: as migratePair() in bin/pos-cli-dns-migrate.js and inline in bin/pos-cli-dns-import.js. They had already drifted in small ways before the shared reporter was extracted, and any change to the apply flow (new result status, changed confirmation semantics, changed cutover collection) must currently be made in both, even though README documents import as "same transform and safety rules as migrate". + +2. Bulk cohort orchestration (pair resolution via --mapping-file/--instances-file + matchByDomain, per-pair try/catch with error-outcome accumulation, summary/grand-total accumulation) lives in the migrate and compare bins. The per-pair failure outcome shape is owned by the migrate bin (errorOutcome factory); a lib-level cohort runner next to lib/dns/mapping.js would own it in one place and let bins only print. + +Constraints (no behavior change): the documented exit-code contract (0/1/2/3), JSON output shapes, confirmation semantics (confirmApply guard, --yes/--json rules), and messages must be preserved. Deliberate sequencing must survive the refactor: portal POSTs stay sequential (provision-worker queue), and the two sides of compare must keep loading sequentially (concurrent interactive password prompts race on shared stdin - see the comment in bin/pos-cli-dns-compare.js). + + +## Acceptance Criteria + +- [ ] #1 The transform->plan->confirm->apply->cutover flow lives in one lib/dns module used by both dns migrate and dns import; neither bin contains the duplicated orchestration anymore +- [ ] #2 Bulk cohort iteration (pair resolution, per-pair failure outcomes, totals accumulation) lives in lib/dns and is shared by migrate's and compare's bulk modes; the error-outcome shape is defined in exactly one place +- [ ] #3 CLI behavior is unchanged: exit codes match the documented 0/1/2/3 contract in single and bulk modes, JSON output shapes are identical, and confirmation prompt semantics (--yes/--json/non-interactive refusal) are preserved +- [ ] #4 Deliberate sequencing is preserved: portal writes stay sequential, and compare's two sides keep resolving sequentially so interactive password prompts never race on shared stdin +- [ ] #5 The extracted lib modules have unit tests covering the pipeline gates (dry-run, transform-error stop, abort-on-decline, apply, cutover collection) and the cohort runner's failure accumulation - coverage must not regress versus the current bin-level flow +- [ ] #6 README dns section reviewed; updated only if any user-facing wording or flag help changed (none expected) + 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..d73d3bab --- /dev/null +++ b/bin/pos-cli-dns-compare.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +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 { readEnvelope } from '../lib/dns/exportSchema.js'; +import { compareInstance, emptyTotals } from '../lib/dns/compare.js'; +import { parseMappingFile } from '../lib/dns/mapping.js'; +import { collect, dropValuePatterns, filterOutcomeByDomain, portalFlags, reportError } 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, ...portalOptions }) => { + if (file) { + const envelope = readEnvelope(file); + return { domains: envelope.domains, origin: `${file} (exported ${envelope.exported_at} from ${envelope.portal_url})` }; + } + const context = await resolvePortalContext(envName, { ...portalOptions, 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'); + +// The pass/fail rule and the totals line are shared by bulk and single-pair modes. +const totalsFailed = (totals) => totals.critical > 0 || totals.missingBefore > 0 || totals.missingAfter > 0; + +const renderTotalsLine = (totals) => + `OK: ${totals.ok} Advisory: ${totals.advisory} Critical: ${totals.critical} ` + + `Missing on source: ${totals.missingBefore} Missing on target: ${totals.missingAfter}`; + +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('--mapping-file ', 'bulk: CSV source_uuid,target_uuid[,label] or JSON array of pairs') + .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 ', 'source Partner Portal url') + .option('--source-token ', 'source portal API token') + .option('--source-email ', 'authenticate the source portal with email + password prompt') + .option('--source-instance-uuid ', 'source instance uuid (skips lookup by the environment domain)') + .option('--target-portal-url ', 'target Partner Portal url') + .option('--target-token ', 'target portal API token') + .option('--target-email ', 'authenticate the target portal with email + password prompt') + .option('--target-instance-uuid ', 'target instance uuid (skips lookup by the environment domain)') + .option('--json', 'machine-readable output') + .action(async (sourceEnv, targetEnv, params) => { + try { + const compareOptions = { + transform: !params.raw, + ignoreStatus: !!params.ignoreStatus, + dropValuePatterns: dropValuePatterns(params.dropValue) + }; + + if (params.mappingFile) { + // Bulk cohort mode: one compare per mapped instance pair, live portals only. + const sourceContext = await resolvePortalContext(sourceEnv, { + ...portalFlags(params, 'source'), readOnly: true, skipInstanceLookup: true + }); + const targetContext = await resolvePortalContext(targetEnv, { + ...portalFlags(params, 'target'), readOnly: true, skipInstanceLookup: true + }); + + const grand = emptyTotals(); + const perInstance = []; + for (const pair of parseMappingFile(params.mappingFile)) { + try { + const [sourceSide, targetSide] = await Promise.all([ + fetchDomains(sourceContext.client, pair.sourceUuid), + fetchDomains(targetContext.client, pair.targetUuid) + ]); + const outcome = filterOutcomeByDomain(compareInstance(sourceSide.domains, targetSide.domains, compareOptions), params.domain); + perInstance.push({ label: pair.label, ...outcome }); + for (const key of Object.keys(grand)) grand[key] += outcome.totals[key]; + if (!params.json) { + await logger.Info(`\n=== ${pair.label} ===\n${renderResults(outcome.results)}`, { hideTimestamp: true }); + } + } catch (error) { + perInstance.push({ label: pair.label, error: error.message }); + grand.critical += 1; + if (!params.json) await logger.Warn(`${pair.label}: compare failed — ${error.message}`); + } + } + + if (params.json) { + console.log(JSON.stringify({ instances: perInstance, totals: grand }, null, 2)); + } else { + await logger.Info(`\n${renderTotalsLine(grand)}`, { hideTimestamp: true }); + } + process.exit(totalsFailed(grand) ? 1 : 0); + } + + // Sequentially, NOT Promise.all: each side may need an interactive password + // prompt (readline on the shared stdin) — two concurrent prompts would race + // for the same typed input and send one portal's password to the other. + const source = await loadSide(sourceEnv, { file: params.sourceFile, ...portalFlags(params, 'source') }); + const target = await loadSide(targetEnv, { file: params.targetFile, ...portalFlags(params, 'target') }); + + const { results, totals } = filterOutcomeByDomain(compareInstance(source.domains, target.domains, compareOptions), params.domain); + + 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${renderTotalsLine(totals)}`, + { hideTimestamp: true } + ); + } + + process.exit(totalsFailed(totals) ? 1 : 0); + } catch (error) { + await reportError(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..29fe5d67 --- /dev/null +++ b/bin/pos-cli-dns-export.js @@ -0,0 +1,78 @@ +#!/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 { parseInstancesFile } from '../lib/dns/mapping.js'; +import { backupPathFor, portalFlags, timestamp, writeEnvelope, reportError } from '../lib/dns/cliHelpers.js'; + +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('--instances-file ', 'bulk: instance uuids (one per line), writes /.json per instance') + .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 bulk = !!params.instancesFile; + const context = await resolvePortalContext(environment, { + ...portalFlags(params), + label: 'source', + readOnly: true, + skipInstanceLookup: bulk + }); + + const uuids = bulk ? parseInstancesFile(params.instancesFile) : [context.instanceUuid]; + const outDir = bulk ? (params.out || `dns-export-${timestamp()}`) : null; + if (outDir) fs.mkdirSync(outDir, { recursive: true }); + + let failures = 0; + for (const uuid of uuids) { + spinner.start(`Exporting ${uuid}`); + try { + const { envelope, names } = await exportInstance({ + client: context.client, + instanceUuid: uuid, + instanceUrl: bulk ? undefined : context.instanceUrl, + envName: environment, + apiVersion: parseInt(params.apiVersion, 10), + raw: !!params.raw + }); + + const outPath = bulk ? path.join(outDir, `${uuid}.json`) : backupPathFor(params.out, uuid); + writeEnvelope(outPath, envelope); + + 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(); + failures += 1; + await logger.Warn(`${uuid}: export failed — ${error.message}`); + } + } + + if (failures) { + await logger.Error(`${failures} of ${uuids.length} exports failed.`, { exit: false }); + process.exit(1); + } + } catch (error) { + spinner.stop(); + await reportError(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..2302282a --- /dev/null +++ b/bin/pos-cli-dns-import.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { program } from '../lib/program.js'; +import logger from '../lib/logger.js'; +import { resolvePortalContext } from '../lib/dns/auth.js'; +import { readEnvelope } from '../lib/dns/exportSchema.js'; +import { transformEnvelope } from '../lib/dns/transform.js'; +import { applyPlans, collectAppliedTargetStatuses } from '../lib/dns/apply.js'; +import { renderPlans, renderSummary } from '../lib/dns/plan.js'; +import { confirmApply } from '../lib/dns/guard.js'; +import { collect, dropValuePatterns, filterByDomains, exitCodeFor, describeApplyTarget, portalFlags, reportApplyResults, reportError } 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 = readEnvelope(params.file); + + // 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, { + ...portalFlags(params), + label: 'target', + allowProtectedTarget: !!params.unsafeAllowProtectedTarget + }); + + const { plans } = transformEnvelope(envelope, { + targetInstanceUuid: context.instanceUuid, + dropValuePatterns: dropValuePatterns(params.dropValue) + }); + 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: describeApplyTarget(context) }))) { + 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 + }); + + const targetStatuses = await collectAppliedTargetStatuses(context.client, context.instanceUuid, results); + + if (params.json) { + console.log(JSON.stringify({ results, target_domains: targetStatuses }, null, 2)); + } else { + await reportApplyResults({ results, targetStatuses }); + } + + process.exit(exitCodeFor(results)); + } catch (error) { + await reportError(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..07c31970 --- /dev/null +++ b/bin/pos-cli-dns-migrate.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import ora from 'ora'; +import table from 'text-table'; + +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, collectAppliedTargetStatuses } from '../lib/dns/apply.js'; +import { renderPlans, renderSummary } from '../lib/dns/plan.js'; +import { parseInstancesFile, parseMappingFile, matchByDomain } from '../lib/dns/mapping.js'; +import { confirmApply } from '../lib/dns/guard.js'; +import { collect, dropValuePatterns, filterByDomains, backupPathFor, resolveBulkBackupDir, writeEnvelope, summarizeBulkOutcome, exitCodeForOutcomes, describeApplyTarget, portalFlags, reportApplyResults, reportError } 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. +// `backup` defaults to params.backup but bulk mode passes the ONE cohort-wide resolved +// directory explicitly (TASK-1.18) instead of overriding it inside a cloned params object. +const migratePair = async ({ source, target, sourceUuid, targetUuid, sourceEnv, params, spinner, label, confirm, bulk = false, backup = params.backup }) => { + spinner.start(`${label}: exporting from ${source.portalUrl}`); + const { envelope } = await exportInstance({ + client: source.client, + instanceUuid: sourceUuid, + instanceUrl: source.instanceUrl, + envName: sourceEnv + }); + spinner.stop(); + + if (backup !== false) { + const outPath = backupPathFor(backup, sourceUuid, { bulk }); + writeEnvelope(outPath, envelope); + await logger.Info(`${label}: source export backed up to ${outPath}`, { hideTimestamp: true }); + } + + const { plans } = transformEnvelope(envelope, { + targetInstanceUuid: targetUuid, + dropValuePatterns: dropValuePatterns(params.dropValue) + }); + 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: [], hasErrors, targetStatuses: [] }; + } + + if (confirm && !(await confirm())) { + await logger.Info(`${label}: aborted — nothing was applied.`, { hideTimestamp: true }); + return { label, plans: selected, results: [], 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 = await collectAppliedTargetStatuses(target.client, targetUuid, results); + + if (!params.json) { + await reportApplyResults({ results, targetStatuses, label }); + } + + return { label, plans: selected, results, hasErrors: false, targetStatuses }; +}; + +const bulkSummaryRow = (outcome) => { + const summary = summarizeBulkOutcome(outcome); + return [` ${outcome.label}`, String(summary.domains), String(summary.applied), String(summary.blocked), String(summary.errors)]; +}; + +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 directory in bulk mode)') + .option('--no-backup', 'skip writing the backup file') + .option('--no-wait', 'do not poll provisioning status after applying') + .option('--mapping-file ', 'bulk: CSV source_uuid,target_uuid[,label] or JSON array of pairs') + .option('--instances-file ', 'bulk: source instance uuids (one per line), targets resolved by --match-by-domain') + .option('--match-by-domain', 'bulk: resolve each target instance by the source customer domains') + .option('--source-portal-url ', 'source Partner Portal url') + .option('--source-token ', 'source portal API token') + .option('--source-email ', 'authenticate the source portal with email + password prompt') + .option('--source-instance-uuid ', 'source instance uuid (skips lookup by the environment domain)') + .option('--target-portal-url ', 'target Partner Portal url') + .option('--target-token ', 'target portal API token') + .option('--target-email ', 'authenticate the target portal with email + password prompt') + .option('--target-instance-uuid ', 'target instance uuid (skips lookup by the environment domain)') + .option('--json', 'machine-readable output') + .action(async (sourceEnv, targetEnv, params) => { + const spinner = ora({ text: 'Migrating DNS', stream: process.stdout }); + try { + const bulk = !!(params.mappingFile || params.instancesFile); + if (params.instancesFile && !params.matchByDomain && !params.mappingFile) { + await logger.Error('--instances-file needs --match-by-domain (or use --mapping-file with explicit target uuids).'); + } + + const source = await resolvePortalContext(sourceEnv, { + ...portalFlags(params, 'source'), + readOnly: true, + skipInstanceLookup: bulk + }); + const target = await resolvePortalContext(targetEnv, { + ...portalFlags(params, 'target'), + skipInstanceLookup: bulk, + allowProtectedTarget: !!params.unsafeAllowProtectedTarget + }); + + if (!bulk) { + 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: describeApplyTarget(target) }) + }); + if (params.json) console.log(JSON.stringify({ plans: outcome.plans, results: outcome.results, target_domains: outcome.targetStatuses }, null, 2)); + process.exit(exitCodeForOutcomes([outcome])); + } + + // Bulk cohort mode + const resolvePairsByDomain = async () => { + const uuids = parseInstancesFile(params.instancesFile); + const resolved = []; + for (const sourceUuid of uuids) { + spinner.start(`resolving target for ${sourceUuid}`); + try { + const targetUuid = await matchByDomain({ sourceClient: source.client, targetClient: target.client, sourceUuid }); + resolved.push({ sourceUuid, targetUuid, label: sourceUuid }); + } catch (error) { + resolved.push({ sourceUuid, targetUuid: null, label: sourceUuid, resolveError: error.message }); + } + spinner.stop(); + } + return resolved; + }; + const pairs = params.mappingFile ? parseMappingFile(params.mappingFile) : await resolvePairsByDomain(); + + const bulkBackupDir = resolveBulkBackupDir(params.backup); + + // Bulk applies to many instances — confirm once for the whole cohort upfront + // (per-pair plans still print as the loop progresses; preview with --dry-run first). + if (!params.dryRun && !(await confirmApply({ yes: !!params.yes, json: !!params.json, target: `${pairs.length} instance(s) on ${target.portalUrl}` }))) { + await logger.Info('Aborted — nothing was applied.', { hideTimestamp: true }); + process.exit(0); + } + + // hasErrors means TRANSFORM errors; a pair failure is already the 'error' result — + // setting both would make summarizeBulkOutcome count one failure twice. + const errorOutcome = (label, serverMessage) => + ({ label, plans: [], results: [{ domainName: '-', status: 'error', serverMessage }], hasErrors: false, targetStatuses: [] }); + + const outcomes = []; + for (const pair of pairs) { + if (!pair.targetUuid) { + await logger.Warn(`${pair.label}: skipped — ${pair.resolveError}`); + outcomes.push(errorOutcome(pair.label, pair.resolveError)); + continue; + } + try { + outcomes.push(await migratePair({ + source, + target, + sourceUuid: pair.sourceUuid, + targetUuid: pair.targetUuid, + sourceEnv, + params, + backup: bulkBackupDir, + spinner, + label: pair.label, + bulk: true + })); + } catch (error) { + spinner.stop(); + await logger.Warn(`${pair.label}: failed — ${error.message}`); + outcomes.push(errorOutcome(pair.label, error.message)); + } + } + + if (params.json) { + console.log(JSON.stringify({ outcomes: outcomes.map(({ label, plans, results }) => ({ label, plans, results })) }, null, 2)); + } else { + const header = [' instance', 'domains', 'applied', 'blocked', 'errors']; + await logger.Info(`\n${table([header, ...outcomes.map(bulkSummaryRow)])}`, { hideTimestamp: true }); + } + // Same condition -> same exit code as single-pair mode (TASK-1.13) + process.exit(exitCodeForOutcomes(outcomes)); + } catch (error) { + spinner.stop(); + await reportError(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..07eeb6c5 --- /dev/null +++ b/bin/pos-cli-dns-status.js @@ -0,0 +1,52 @@ +#!/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, filterByDomains, portalFlags, reportError } 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, { + ...portalFlags(params), + label: 'portal', + readOnly: true + }); + + const { domains } = await fetchDomains(context.client, context.instanceUuid); + const provisioned = filterByDomains(domains.filter(domain => domain.status), params.domain, domainName); + + 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( + params.domain.length + ? `No provisioned domain matching ${params.domain.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) { + await reportError(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..57109572 --- /dev/null +++ b/lib/dns/apply.js @@ -0,0 +1,136 @@ +import { DestructiveChangeError, PortalAuthError } 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'; + +// Poll-failure tolerance: a single 5xx blip mid-provision must not fail the domain, +// but a portal that keeps erroring has to surface instead of silently spinning to the +// timeout and reporting a provisioning status that was never actually observed. +const MAX_CONSECUTIVE_POLL_ERRORS = 3; + +const pollUntilSettled = async (client, domainName, instanceUuid, { pollIntervalMs, timeoutMs, onProgress }) => { + const startedAt = Date.now(); + let lastSeen = null; + let consecutiveErrors = 0; + while (Date.now() - startedAt < timeoutMs) { + try { + lastSeen = await client.getDomain(domainName, instanceUuid); + consecutiveErrors = 0; + } catch (error) { + // Lost auth affects every remaining poll and domain — abort the whole apply. + if (error instanceof PortalAuthError) throw error; + consecutiveErrors += 1; + if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) { + return { status: lastSeen, stillProcessing: true, pollError: error.message }; + } + } + 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(' '); + } + + // Polling that kept failing means the provisioning outcome was never observed — + // report an error instead of an 'applied' success the exit code would then hide. + if (polled.pollError) { + result.status = 'error'; + result.serverMessage = `apply was accepted, but polling the provisioning status failed: ${polled.pollError} — check \`pos-cli dns status\``; + } + } + + results.push(result); + } + + return { results }; +}; + +// Fresh target statuses drive the cutover instructions (NS to set at the registrar / +// verification records to create). applyPlans already captured them while polling; +// a domain applied with --no-wait needs a fresh fetch instead — independent reads, +// so they run concurrently (unlike the deliberately sequential POSTs above). Shared by +// dns migrate and dns import so both compute cutover instructions the same way (TASK-1.23). +const collectAppliedTargetStatuses = (client, targetUuid, results) => + Promise.all( + results + .filter(result => result.status === 'applied') + .map(result => result.domainStatus || client.getDomain(result.domainName, targetUuid).catch(() => null)) + ); + +export { applyPlans, collectAppliedTargetStatuses }; diff --git a/lib/dns/auth.js b/lib/dns/auth.js new file mode 100644 index 00000000..32b538a0 --- /dev/null +++ b/lib/dns/auth.js @@ -0,0 +1,125 @@ +import logger from '../logger.js'; +import { settingsFromDotPos } from '../settings.js'; +import { readPassword } from '../utils/password.js'; +import { DnsPortalClient, PortalAuthError, normalizeBaseUrl } 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) => { + await 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, + // Bins pass interactive: !params.json — a password prompt writes to stdout, which + // would corrupt machine-readable output (the same reasoning as guard.js's confirmApply). + interactive = true +} = {}) => { + 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 = normalizeBaseUrl(portalUrl || settings.partner_portal_url || DEFAULT_PORTAL_URL); + 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) { + if (!interactive) { + throw new Error( + `Authenticating the ${label} portal as ${email} needs an interactive password prompt, ` + + 'which would corrupt --json output — pass --token or use a stored environment token instead.' + ); + } + 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 tty = process.stdin.isTTY; + if (!(error instanceof PortalAuthError) || email || !fallbackEmail || !tty || !interactive) throw error; + + await 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 }; diff --git a/lib/dns/cliHelpers.js b/lib/dns/cliHelpers.js new file mode 100644 index 00000000..8bcd0406 --- /dev/null +++ b/lib/dns/cliHelpers.js @@ -0,0 +1,180 @@ +import fs from 'fs'; +import path from 'path'; + +import logger from '../logger.js'; +import ServerError from '../ServerError.js'; +import { LEVEL_TOTALS_KEY, emptyTotals } from './compare.js'; +import { renderResults } from './plan.js'; +import { renderCutovers } from './cutover.js'; + +// Shared helpers for the dns bin files (repo convention: thin bins, logic in lib/). + +const collect = (value, previous) => previous.concat([value]); + +// --drop-value flags compile to case-insensitive regexes. All commands must compile +// them identically, or compare's "pass the same patterns the migration dropped" +// contract (TASK-1.11) silently stops matching. +const dropValuePatterns = (values) => values.map(pattern => new RegExp(pattern, 'i')); + +const filterByDomains = (items, domains, getName = (item) => item.domainName) => { + if (!domains.length) return items; + const wanted = new Set(domains.map(domain => domain.toLowerCase())); + return items.filter(item => wanted.has((getName(item) || '').toLowerCase())); +}; + +const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-'); + +// Resolve the ONE shared bulk backup directory for a whole `dns migrate` cohort run, +// computed once before the per-pair loop. Calling backupPathFor(undefined, uuid, {bulk:true}) +// directly per pair instead would mint a fresh timestamp on every call (each pair's export +// takes long enough for the clock to tick), scattering one backup per directory instead of +// sharing a single cohort directory the way dns export's bulk mode already does (TASK-1.18). +const resolveBulkBackupDir = (backup) => { + if (backup === false) return false; + return (!backup || backup === true) ? `dns-backups-${timestamp()}` : backup; +}; + +// Where a source-export backup lands. In bulk mode --backup is ALWAYS a directory +// (one file per source instance, whether or not the directory exists yet — TASK-1.10); +// in single mode it may be a file path, an existing directory, or absent (default name). +// commander sets the value to `true` when --backup/--no-backup are defined but not passed. +const backupPathFor = (backup, instanceUuid, { bulk = false } = {}) => { + if (bulk) { + return path.join(resolveBulkBackupDir(backup), `${instanceUuid}.json`); + } + 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; +}; + +// One export/backup write convention for every dns command: parents created, pretty JSON. +const writeEnvelope = (outPath, envelope) => { + fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(envelope, null, 2)); +}; + +// Bulk migrate's per-instance summary counts, including domains whose apply POST was +// accepted but the async provisioning worker later failed (status 'apply-failed') — those +// must count as failures, not disappear from every column (TASK-1.19). +const summarizeBulkOutcome = (outcome) => { + const counts = outcome.results.reduce((acc, result) => { + acc[result.status] = (acc[result.status] || 0) + 1; + return acc; + }, {}); + return { + domains: outcome.plans.filter(plan => !plan.skipped).length, + applied: counts.applied || 0, + blocked: counts['blocked-destructive'] || 0, + errors: (counts.error || 0) + (counts.invalid || 0) + (counts['apply-failed'] || 0) + (outcome.hasErrors ? 1 : 0) + }; +}; + +// 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; +}; + +// Bulk variant: the same condition must yield the same code as single-pair mode. +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; +}; + +// Re-derive totals after filtering a compareInstance outcome by domain name — shared by +// dns compare's single-pair and bulk (--mapping-file) modes so --domain scopes both (TASK-1.17). +const filterOutcomeByDomain = (outcome, domainNames) => { + if (!domainNames || !domainNames.length) return outcome; + const results = filterByDomains(outcome.results, domainNames); + const totals = results.reduce((acc, result) => { + acc[LEVEL_TOTALS_KEY[result.level]] += 1; + return acc; + }, emptyTotals()); + return { results, totals }; +}; + +// Shared top-level catch-block handler for all 5 dns bin files: network failures get the +// same friendly, host-naming messaging the rest of the CLI uses (lib/ServerError.js); +// everything else (typed portal errors, transform errors, etc.) falls back to the plain message. +const reportError = async (error) => { + if (ServerError.isNetworkError(error)) { + await ServerError.handler(error); + // Only reached when the handler didn't exit itself: its unrecognized-network-code + // and unrecognized-422-body paths log with { exit: false } (the latter sometimes + // nothing at all). Restate the raw error so the failure is never silent. + await logger.Error(error.message || String(error), { exit: false, notify: false, hideTimestamp: true }); + } else { + await logger.Error(error.message || error, { exit: false }); + } + // The documented dns exit-code contract (0 = success) requires a non-zero exit for + // ANY failure that reaches this handler — never fall through to a success exit. + process.exit(1); +}; + +// What to show in the plan-then-confirm prompt (lib/dns/guard.js's confirmApply). The portal +// URL alone doesn't distinguish between instances on the same private-stack portal — a user +// with several instances behind one portal would see an identical confirmation regardless of +// which one is about to be written to. Prefer the actual instance/site URL when we have it. +const describeApplyTarget = (context) => { + const instance = context.instanceUrl || `instance ${context.instanceUuid}`; + return `${instance} (${context.portalUrl})`; +}; + +// One side's portal flags for resolvePortalContext: prefixed (--source-*/--target-*) for +// the two-portal commands, unprefixed otherwise. Owns `interactive`: a password prompt +// writes to stdout, so --json runs must never prompt (mirrors guard.js's confirmApply). +const portalFlags = (params, side) => { + const flags = side + ? { portalUrl: params[`${side}PortalUrl`], token: params[`${side}Token`], email: params[`${side}Email`], instanceUuid: params[`${side}InstanceUuid`], label: side } + : { portalUrl: params.portalUrl, token: params.token, email: params.email, instanceUuid: params.instanceUuid }; + return { ...flags, interactive: !params.json }; +}; + +// Post-apply report shared by dns migrate and dns import: results table, destructive-block +// hint, cutover instructions. `label` prefixes the hint in bulk runs. +const reportApplyResults = async ({ results, targetStatuses, label }) => { + await logger.Info(`\n${renderResults(results)}`, { hideTimestamp: true }); + if (results.some(result => result.status === 'blocked-destructive')) { + const prefix = label ? `${label}: ` : ''; + await logger.Warn(`${prefix}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 }); +}; + +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, + dropValuePatterns, + filterByDomains, + filterOutcomeByDomain, + backupPathFor, + resolveBulkBackupDir, + writeEnvelope, + summarizeBulkOutcome, + exitCodeFor, + exitCodeForOutcomes, + describeApplyTarget, + portalFlags, + reportApplyResults, + hostnameOf, + reportError, + timestamp +}; diff --git a/lib/dns/compare.js b/lib/dns/compare.js new file mode 100644 index 00000000..836f34b4 --- /dev/null +++ b/lib/dns/compare.js @@ -0,0 +1,252 @@ +import { domainName, isStrippedDetail } 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()}`; + +// Shared totals shape + level->key mapping — compareInstance and the CLI-side domain filter +// (cliHelpers.js's filterOutcomeByDomain) both need to recompute the same tally, so they share +// one definition instead of restating the same object literal independently. +const LEVEL_TOTALS_KEY = { OK: 'ok', ADVISORY: 'advisory', CRITICAL: 'critical', MISSING_BEFORE: 'missingBefore', MISSING_AFTER: 'missingAfter' }; +const emptyTotals = () => ({ ok: 0, advisory: 0, critical: 0, missingBefore: 0, missingAfter: 0 }); + +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; +}; + +// Different DNS-provider backends store the same record's name differently — Route53 +// (legacy stack) keeps SRV record names fully domain-qualified, Cloudflare (private-stack) +// stores them short. recordKey() normalizes, or the same live record keys differently +// on each side and reports as missing on both. +const indexLiveRecords = (records, domain) => { + const index = new Map(); + for (const record of records || []) { + if (!record || typeof record !== 'object') continue; + index.set(recordKey(record, domain), 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 { index: indexIntentRecords(domain?.attributes?.config?.extra_dns_records, name, { normalizeValues: false }), errors: [] }; + } + const plan = transformDomain(domain, { targetInstanceUuid: 'compare', dropValuePatterns }); + // plan.kept stays valid even when the transform errored (payload null): it holds every + // record that passed, with the www-redirect drop and --drop-value patterns applied. + // Falling back to the raw records instead would compare asymmetrically whenever only + // one side has a transform error; the errors surface as CRITICAL on this domain. + return { index: indexIntentRecords(plan.kept, name, { normalizeValues: true }), errors: plan.errors }; +}; + +// dns export strips any details. value that exceeded the size limit (exportSchema.js's +// stripBulkyDetails), replacing it with a marker string. Treating that string as an empty +// array (the naive `for (const record of records || [])`) would silently report a clean +// compare even though the field's real content was never actually checked. +const strippedDetailMessage = (sourceDomain, targetDomain, key) => + (isStrippedDetail(sourceDomain?.details?.[key]) || isStrippedDetail(targetDomain?.details?.[key])) + ? `details.${key} could not be compared — the export stripped it for exceeding the size limit; re-export with --raw or check this domain manually` + : null; + +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 sourceIntent = intentFor(sourceDomain, name, transform, dropValuePatterns); + const targetIntent = intentFor(targetDomain, name, transform, dropValuePatterns); + for (const error of sourceIntent.errors) critical.push(`source transform: ${error}`); + for (const error of targetIntent.errors) critical.push(`target transform: ${error}`); + const sourceRecords = sourceIntent.index; + const targetRecords = targetIntent.index; + 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 verificationStripped = strippedDetailMessage(sourceDomain, targetDomain, 'dns_verification_records'); + if (verificationStripped) { + critical.push(verificationStripped); + } else { + 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()])) { + 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 extraRecordsStripped = strippedDetailMessage(sourceDomain, targetDomain, 'extra_dns_records'); + if (extraRecordsStripped) { + critical.push(extraRecordsStripped); + } else { + const sourceLive = indexLiveRecords(sourceDomain?.details?.extra_dns_records, name); + const targetLive = indexLiveRecords(targetDomain?.details?.extra_dns_records, name); + 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)}` + ); + } + } + } + + const nameServersStripped = !transform && strippedDetailMessage(sourceDomain, targetDomain, 'dns_zone_name_servers'); + if (nameServersStripped) { + critical.push(nameServersStripped); + } else 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 || []) { + // Lowercase the key: a legacy source may report 'Example.com' while the target + // stores 'example.com' — distinct keys would report one real domain as missing + // on BOTH sides. Every other name comparison in this module is case-insensitive. + const name = (domainName(domain) || '').toLowerCase(); + 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 = emptyTotals(); + + 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); + totals[LEVEL_TOTALS_KEY[result.level]] += 1; + } + + return { results, totals }; +}; + +export { compareInstance, compareDomain, LEVEL_TOTALS_KEY, emptyTotals }; 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..fc479fee --- /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)) { + await 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..aa9ce0a3 --- /dev/null +++ b/lib/dns/exportSchema.js @@ -0,0 +1,66 @@ +import fs from 'fs'; + +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 isStrippedDetail = (value) => typeof value === 'string' && value.startsWith('[stripped:'); + +const stripBulkyDetails = (domain) => { + let source = domain; + if (domain?.details && typeof domain.details === 'object' && 'state' in domain.details) { + // Exclude the legacy Terraform blob BEFORE the deep clone — it can be hundreds of + // KB per domain and would otherwise be copied only to be deleted again. + const { state: _state, ...details } = domain.details; + source = { ...domain, details }; + } + const copy = structuredClone(source); + 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) +}); + +// Read + validate an export file in one step — shared by dns import and dns compare. +const readEnvelope = (filePath) => validateEnvelope(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + +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, readEnvelope, domainName, isStrippedDetail }; 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/mapping.js b/lib/dns/mapping.js new file mode 100644 index 00000000..37ec0a99 --- /dev/null +++ b/lib/dns/mapping.js @@ -0,0 +1,88 @@ +import fs from 'fs'; + +import { fetchDomains } from './export.js'; +import { domainName } from './exportSchema.js'; +import { isPlatformSubdomain } from './transform.js'; + +const UUID_LINE = /^[a-z0-9][a-z0-9-]*$/i; + +// One source instance uuid per line; blank lines and #comments ignored. +const parseInstancesFile = (path) => + fs.readFileSync(path, 'utf8') + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => { + if (!UUID_LINE.test(line)) throw new Error(`${path}: "${line}" does not look like an instance uuid`); + return line; + }); + +const parseCsvMapping = (content, path) => { + const rows = content.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#')); + const pairs = []; + for (const row of rows) { + const [sourceUuid, targetUuid, label] = row.split(',').map(cell => cell?.trim()); + if (sourceUuid === 'source_uuid' && targetUuid === 'target_uuid') continue; // header + if (!sourceUuid || !targetUuid) { + throw new Error(`${path}: expected "source_uuid,target_uuid[,label]" — got "${row}"`); + } + pairs.push({ sourceUuid, targetUuid, label: label || sourceUuid }); + } + return pairs; +}; + +const parseJsonMapping = (content, path) => { + const parsed = JSON.parse(content); + if (!Array.isArray(parsed)) throw new Error(`${path}: expected a JSON array of {source_uuid, target_uuid, label?}`); + return parsed.map((entry, index) => { + const sourceUuid = entry.source_uuid || entry.sourceUuid; + const targetUuid = entry.target_uuid || entry.targetUuid; + if (!sourceUuid || !targetUuid) throw new Error(`${path}[${index}]: missing source_uuid or target_uuid`); + return { sourceUuid, targetUuid, label: entry.label || sourceUuid }; + }); +}; + +// CSV `source_uuid,target_uuid[,label]` or a JSON array — returns [{sourceUuid, targetUuid, label}]. +const parseMappingFile = (path) => { + const content = fs.readFileSync(path, 'utf8'); + const pairs = content.trimStart().startsWith('[') ? parseJsonMapping(content, path) : parseCsvMapping(content, path); + if (!pairs.length) throw new Error(`${path}: no instance pairs found`); + return pairs; +}; + +// Resolves the target instance for a source instance by its customer domains: +// every provisioned primary domain of the source must point at the same target +// instance, otherwise this errors instead of guessing. +const matchByDomain = async ({ sourceClient, targetClient, sourceUuid }) => { + const { domains } = await fetchDomains(sourceClient, sourceUuid); + const names = domains + .filter(domain => domain.status) + .map(domainName) + .filter(name => name && !isPlatformSubdomain(name)); + if (!names.length) { + throw new Error(`source instance ${sourceUuid} has no provisioned customer domains to match by — provide a mapping file entry`); + } + + // Independent read-only lookups — fetch them concurrently. + const responses = await Promise.all(names.map(async (name) => { + try { + return await targetClient.searchInstances({ domain: name }); + } catch (error) { + // A failed lookup must not masquerade as "no matching instance" (TASK-1.14). + throw new Error(`instance lookup for ${name} on ${targetClient.baseUrl} failed: ${error.message}`); + } + })); + const matches = new Map(); + for (const response of responses) { + for (const instance of response?.data || []) matches.set(instance.uuid, instance.name); + } + + if (matches.size === 1) return [...matches.keys()][0]; + if (matches.size === 0) { + throw new Error(`no instance on ${targetClient.baseUrl} has any of the domains ${names.join(', ')} — create the target instance domains first or provide a mapping file`); + } + const listing = [...matches.entries()].map(([uuid, name]) => `${uuid} (${name})`).join(', '); + throw new Error(`domains of source instance ${sourceUuid} matched multiple target instances: ${listing} — provide a mapping file entry`); +}; + +export { parseInstancesFile, parseMappingFile, matchByDomain }; diff --git a/lib/dns/normalize.js b/lib/dns/normalize.js new file mode 100644 index 00000000..9ac1c60e --- /dev/null +++ b/lib/dns/normalize.js @@ -0,0 +1,64 @@ +// 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(/\.$/, ''); + const domain = (domainName || '').toLowerCase(); + if (short === '@' || short === domain) return ''; + if (domain && short.endsWith(`.${domain}`)) short = short.slice(0, -(domain.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..ca68593f --- /dev/null +++ b/lib/dns/portalClient.js @@ -0,0 +1,223 @@ +import { apiRequest } from '../apiRequest.js'; + +const DESTRUCTIVE_PREFIX = 'Destructive DNS change blocked'; + +// Canonical portal base url — auth.js compares portalUrl values (same-instance guard, +// protected-host check), so every construction path must normalize identically. +const normalizeBaseUrl = (url) => String(url).replace(/\/+$/, ''); + +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; + } +} + +// Any portal response pos-cli has no dedicated type for. The portal puts the real +// failure reason in the response body even for 500s (e.g. SetDomains validation: +// 'Name has to be unique, but "x" is in use by other instance.'), so the body +// messages MUST end up in .message — a bare 'Request failed with status 500' +// gives the operator nothing to act on. +class PortalRequestError extends Error { + constructor({ method, path, statusCode, messages, domainName, instanceUuid }) { + const detail = messages.length + ? messages.join(' | ') + : 'the portal returned no error details — check the portal logs'; + super(`${method} ${path} responded with status ${statusCode}: ${detail}`); + this.name = 'PortalRequestError'; + this.statusCode = statusCode; + this.messages = messages; + this.domainName = domainName; + this.instanceUuid = instanceUuid; + } +} + +// Flatten a Rails-style errors payload (string | array | nested object) into readable +// "path: message" strings. Strings inside plain arrays keep no index, so +// `errors: ["msg"]` yields exactly "msg" — the destructive/NotAuthorized matchers +// below rely on matching the unmodified server string. +const flattenMessages = (node, path = '') => { + if (node === null || node === undefined) return []; + if (typeof node === 'string') { + if (!node.trim()) return []; + return [path ? `${path}: ${node}` : node]; + } + if (Array.isArray(node)) { + return node.flatMap((item, index) => + flattenMessages(item, item && typeof item === 'object' ? `${path}[${index}]` : path)); + } + if (typeof node === 'object') { + return Object.entries(node).flatMap(([key, value]) => + flattenMessages(value, path ? `${path}.${key}` : key)); + } + return [path ? `${path}: ${String(node)}` : String(node)]; +}; + +const serverMessages = (body) => { + if (body === null || body === undefined) return []; + if (typeof body === 'string') { + const text = body.trim(); + // An HTML error page (crashed portal, proxy) has nothing quotable in it. + if (!text || text.startsWith('<')) return []; + return [text.length > 300 ? `${text.slice(0, 300)}…` : text]; + } + return flattenMessages(body.errors ?? body.error ?? body.message); +}; + +// Actionable follow-ups for known server messages the raw text doesn't explain. +const MESSAGE_HINTS = [ + { + pattern: /is in use by other instance/i, + hint: 'The domain is already attached to a different instance on the target portal — remove it from that instance (or pass the correct target with --target-instance-uuid) and re-run.' + } +]; + +class DnsPortalClient { + constructor({ baseUrl, token, readOnly = false }) { + if (!baseUrl) throw new Error('DnsPortalClient requires a baseUrl'); + this.baseUrl = normalizeBaseUrl(baseUrl); + this.token = token; + this.readOnly = readOnly; + } + + static async authenticate(baseUrl, email, password) { + const base = normalizeBaseUrl(baseUrl); + 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, { method, path, instanceUuid, domainName }); + } + } + + mapError(error, { method, path, instanceUuid, domainName } = {}) { + if (error.name !== 'StatusCodeError') return error; + + const responseBody = error.response && error.response.body; + const messages = serverMessages(responseBody); + + if (error.statusCode === 401) return new PortalAuthError(this.baseUrl); + if (messages.some(message => message.includes('NotAuthorized'))) { + return new PortalAccessError(this.baseUrl, instanceUuid); + } + const destructive = error.statusCode === 422 && messages.find(message => message.startsWith(DESTRUCTIVE_PREFIX)); + if (destructive) return new DestructiveChangeError(destructive, domainName); + + const hints = MESSAGE_HINTS + .filter(({ pattern }) => messages.some(message => pattern.test(message))) + .map(({ hint }) => hint); + return new PortalRequestError({ + method, + path: (path || '').split('?')[0], + statusCode: error.statusCode, + messages: [...messages, ...hints], + domainName, + instanceUuid + }); + } +} + +export { + DnsPortalClient, + PortalAuthError, + PortalAccessError, + DestructiveChangeError, + ReadOnlyPortalError, + PortalRequestError, + normalizeBaseUrl +}; diff --git a/lib/dns/transform.js b/lib/dns/transform.js new file mode 100644 index 00000000..2b5b2a4d --- /dev/null +++ b/lib/dns/transform.js @@ -0,0 +1,157 @@ +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); +}; + +// record.name and record.records[0] arrive lowercased (normalizeName/normalizeRecordValue), +// but `name` is the raw domain name — legacy portals allow mixed case ('Example.com'). +const isWwwRedirectRecord = (record, name) => + record.name === 'www' && record.type === 'CNAME' && record.records.length === 1 && + record.records[0] === (name || '').toLowerCase(); + +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..90fb3420 --- /dev/null +++ b/test/unit/dns/apply.test.js @@ -0,0 +1,236 @@ +/** + * 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, collectAppliedTargetStatuses } 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('persistent poll failures surface as an error instead of a silently unobserved applied', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).times(3).reply(500, { errors: ['boom'] }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts, timeoutMs: 2000 }); + + expect(results[0].status).toEqual('error'); + expect(results[0].serverMessage).toContain('polling the provisioning status failed'); + expect(results[0].stillProcessing).toBe(true); + }); + + test('a transient poll blip recovers and still settles', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).reply(500, { errors: ['blip'] }); + nock(PORTAL).get('/api/domains/a.test').query(true).reply(200, { status: 'ready', locked: false }); + + const { results } = await applyPlans({ client, plans: [plan('a.test')], ...fastOpts, timeoutMs: 2000 }); + expect(results[0]).toMatchObject({ status: 'applied', finalStatus: 'ready', stillProcessing: false }); + }); + + test('a 401 while polling aborts the apply — lost auth affects every remaining domain', async () => { + nock(PORTAL).post('/api/domains').reply(200, { data: { status: 'initializing' } }); + nock(PORTAL).get('/api/domains/a.test').query(true).reply(401, { errors: ['Not authorized'] }); + + await expect(applyPlans({ client, plans: [plan('a.test')], ...fastOpts })) + .rejects.toThrow(/Not authorized on/); + }); + + 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); + }); +}); + +describe('collectAppliedTargetStatuses (TASK-1.23: shared by dns migrate and dns import)', () => { + let client; + + beforeEach(() => { + nock.disableNetConnect(); + client = new DnsPortalClient({ baseUrl: PORTAL, token: 't' }); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + test('reuses domainStatus already captured by applyPlans, without a re-fetch', async () => { + const results = [{ domainName: 'a.test', status: 'applied', domainStatus: { status: 'ready' } }]; + const statuses = await collectAppliedTargetStatuses(client, UUID, results); + expect(statuses).toEqual([{ status: 'ready' }]); + expect(nock.pendingMocks()).toEqual([]); + }); + + test('re-fetches when domainStatus is missing (e.g. --no-wait) and skips non-applied results', async () => { + nock(PORTAL).get('/api/domains/a.test').query(true).reply(200, { status: 'ready' }); + const results = [ + { domainName: 'a.test', status: 'applied' }, + { domainName: 'b.test', status: 'error' } + ]; + + const statuses = await collectAppliedTargetStatuses(client, UUID, results); + expect(statuses).toEqual([{ status: 'ready' }]); + }); + + test('a failed re-fetch resolves to null instead of throwing', async () => { + nock(PORTAL).get('/api/domains/a.test').query(true).reply(500, { errors: ['boom'] }); + const statuses = await collectAppliedTargetStatuses(client, UUID, [{ domainName: 'a.test', status: 'applied' }]); + expect(statuses).toEqual([null]); + }); +}); diff --git a/test/unit/dns/auth.test.js b/test/unit/dns/auth.test.js new file mode 100644 index 00000000..72eaca23 --- /dev/null +++ b/test/unit/dns/auth.test.js @@ -0,0 +1,240 @@ +/** + * 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 --