@hsuite/smart-engines-cli 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +5 -3
  2. package/dist/_lib/baas.d.ts +27 -4
  3. package/dist/_lib/baas.d.ts.map +1 -1
  4. package/dist/_lib/baas.js +76 -42
  5. package/dist/_lib/baas.js.map +1 -1
  6. package/dist/_lib/cluster-convergence.d.ts +214 -0
  7. package/dist/_lib/cluster-convergence.d.ts.map +1 -0
  8. package/dist/_lib/cluster-convergence.js +381 -0
  9. package/dist/_lib/cluster-convergence.js.map +1 -0
  10. package/dist/_lib/deploy-manifest.d.ts +32 -0
  11. package/dist/_lib/deploy-manifest.d.ts.map +1 -1
  12. package/dist/_lib/deploy-manifest.js +71 -0
  13. package/dist/_lib/deploy-manifest.js.map +1 -1
  14. package/dist/_lib/docker.d.ts +35 -23
  15. package/dist/_lib/docker.d.ts.map +1 -1
  16. package/dist/_lib/docker.js +58 -54
  17. package/dist/_lib/docker.js.map +1 -1
  18. package/dist/_lib/frontend-guard.d.ts +59 -0
  19. package/dist/_lib/frontend-guard.d.ts.map +1 -1
  20. package/dist/_lib/frontend-guard.js +148 -0
  21. package/dist/_lib/frontend-guard.js.map +1 -1
  22. package/dist/_lib/registry-fanout.d.ts +299 -9
  23. package/dist/_lib/registry-fanout.d.ts.map +1 -1
  24. package/dist/_lib/registry-fanout.js +557 -27
  25. package/dist/_lib/registry-fanout.js.map +1 -1
  26. package/dist/_lib/subscription-client.d.ts +14 -0
  27. package/dist/_lib/subscription-client.d.ts.map +1 -1
  28. package/dist/_lib/subscription-client.js +13 -0
  29. package/dist/_lib/subscription-client.js.map +1 -1
  30. package/dist/commands/deploy.d.ts +28 -4
  31. package/dist/commands/deploy.d.ts.map +1 -1
  32. package/dist/commands/deploy.js +163 -20
  33. package/dist/commands/deploy.js.map +1 -1
  34. package/dist/commands/redeploy.d.ts +10 -0
  35. package/dist/commands/redeploy.d.ts.map +1 -1
  36. package/dist/commands/redeploy.js +146 -15
  37. package/dist/commands/redeploy.js.map +1 -1
  38. package/dist/commands/status.d.ts +4 -3
  39. package/dist/commands/status.d.ts.map +1 -1
  40. package/dist/commands/status.js +15 -3
  41. package/dist/commands/status.js.map +1 -1
  42. package/dist/commands/topup.d.ts +55 -0
  43. package/dist/commands/topup.d.ts.map +1 -0
  44. package/dist/commands/topup.js +180 -0
  45. package/dist/commands/topup.js.map +1 -0
  46. package/dist/commands/update.d.ts +33 -0
  47. package/dist/commands/update.d.ts.map +1 -1
  48. package/dist/commands/update.js +38 -2
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/verify.d.ts.map +1 -1
  51. package/dist/commands/verify.js +24 -1
  52. package/dist/commands/verify.js.map +1 -1
  53. package/dist/index.js +2 -0
  54. package/dist/index.js.map +1 -1
  55. package/package.json +1 -1
@@ -33,13 +33,20 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_PROBE_TIMEOUT_MS = void 0;
37
+ exports.withRequestTimeout = withRequestTimeout;
36
38
  exports.parseAuthChallenge = parseAuthChallenge;
37
39
  exports.resolveClusterEndpoints = resolveClusterEndpoints;
38
40
  exports.defaultTransport = defaultTransport;
39
41
  exports.ensureImageOnClusters = ensureImageOnClusters;
40
42
  exports.resolveRegistryDigest = resolveRegistryDigest;
43
+ exports.probeServedDigests = probeServedDigests;
44
+ exports.registryHostOf = registryHostOf;
41
45
  exports.resolveAttestationDigest = resolveAttestationDigest;
46
+ exports.assertTagFreeOnMesh = assertTagFreeOnMesh;
42
47
  exports.fanOutPushedImage = fanOutPushedImage;
48
+ exports.resolvePushOrigin = resolvePushOrigin;
49
+ exports.requirePushOrigin = requirePushOrigin;
43
50
  /**
44
51
  * Synchronous Registry-v2 fan-out: copy one already-pushed image to EVERY
45
52
  * federated Harbor cluster, then prove it landed on all of them.
@@ -86,6 +93,7 @@ exports.fanOutPushedImage = fanOutPushedImage;
86
93
  * never logged.
87
94
  */
88
95
  const https = __importStar(require("https"));
96
+ const net = __importStar(require("net"));
89
97
  const buffer_1 = require("buffer");
90
98
  const dns_1 = require("dns");
91
99
  /** Media types that identify a multi-arch manifest list / image index (unsupported). */
@@ -117,6 +125,28 @@ const DEPLOYER_PARITY_MANIFEST_ACCEPT = [
117
125
  'application/vnd.docker.distribution.manifest.v2+json',
118
126
  'application/vnd.docker.distribution.manifest.list.v2+json',
119
127
  ].join(', ');
128
+ /** Default per-request wall-clock budget for registry / cluster probes. */
129
+ exports.DEFAULT_PROBE_TIMEOUT_MS = 10_000;
130
+ /**
131
+ * Reject after `ms` instead of waiting on a request that may never settle.
132
+ *
133
+ * {@link defaultTransport} builds a bare `https.request` with no socket timeout,
134
+ * so a peer that completes the TCP handshake and then black-holes the response
135
+ * hangs its promise forever. Every probe that fans out across the cluster set
136
+ * wraps its requests in this.
137
+ */
138
+ function withRequestTimeout(p, ms, what) {
139
+ return new Promise((resolveValue, reject) => {
140
+ const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms: ${what}`)), ms);
141
+ p.then((v) => {
142
+ clearTimeout(timer);
143
+ resolveValue(v);
144
+ }, (e) => {
145
+ clearTimeout(timer);
146
+ reject(e);
147
+ });
148
+ });
149
+ }
120
150
  /**
121
151
  * Parse a `Bearer realm="...",service="...",scope="..."` challenge. Pure.
122
152
  *
@@ -430,19 +460,42 @@ function appendDigestQuery(path, digest) {
430
460
  * deployer still re-verifies).
431
461
  */
432
462
  async function resolveRegistryDigest(opts) {
463
+ const answers = await probeServedDigests(opts);
464
+ // Legacy first-responder read. Correct ONLY while the mesh agrees about the
465
+ // tag; {@link resolveAttestationDigest} is the caller that has to reason
466
+ // about disagreement (#2787) and uses {@link probeServedDigests} directly.
467
+ for (const a of answers) {
468
+ if (a.digest)
469
+ return a.digest;
470
+ }
471
+ return null;
472
+ }
473
+ /**
474
+ * Ask EVERY cluster endpoint what it serves for `<repo>:<tag>` and return all
475
+ * the answers, in endpoint order.
476
+ *
477
+ * The federated registry name is round-robin over N INDEPENDENT Harbors, so
478
+ * "what does the registry serve for this tag" is a set of answers, not one
479
+ * answer. In the window between a `docker push` (which lands on exactly ONE
480
+ * cluster) and the fan-out, a re-used tag legitimately resolves to the NEW
481
+ * artifact on the origin and the PREVIOUS artifact on its peers. Collapsing
482
+ * that set to "whoever answered first" is what signed a stale digest in #2787.
483
+ */
484
+ async function probeServedDigests(opts) {
433
485
  const transport = opts.transport ?? defaultTransport(opts.host);
434
486
  const endpoints = await resolveClusterEndpoints(opts.host, opts.resolve4).catch(() => [opts.host]);
435
- // Any single endpoint is authoritative for the tag→digest mapping (the
436
- // fan-out proves every cluster ends up serving that same digest). Try each
437
- // until one answers, so a single-cluster transient miss doesn't fail-soft.
438
487
  const client = makeClient(transport, opts.username, opts.password);
439
488
  const pullScope = `repository:${opts.repository}:pull`;
440
489
  const manifestPath = `/v2/${opts.repository}/manifests/${opts.tag}`;
441
- for (const ip of endpoints.length > 0 ? endpoints : [opts.host]) {
490
+ const timeoutMs = opts.requestTimeoutMs ?? exports.DEFAULT_PROBE_TIMEOUT_MS;
491
+ // PARALLEL + per-request timeout: one wedged endpoint must not stall the
492
+ // others, and it must not stall the deploy at all. `Promise.all` preserves
493
+ // endpoint order, which the origin lookup and the error messages rely on.
494
+ return Promise.all((endpoints.length > 0 ? endpoints : [opts.host]).map(async (ip) => {
442
495
  for (const method of ['HEAD', 'GET']) {
443
496
  let res;
444
497
  try {
445
- res = await client.send({ ip, method, path: manifestPath, headers: { Accept: DEPLOYER_PARITY_MANIFEST_ACCEPT } }, pullScope);
498
+ res = await withRequestTimeout(client.send({ ip, method, path: manifestPath, headers: { Accept: DEPLOYER_PARITY_MANIFEST_ACCEPT } }, pullScope), timeoutMs, `${method} ${manifestPath} on ${ip}`);
446
499
  }
447
500
  catch {
448
501
  continue;
@@ -450,29 +503,74 @@ async function resolveRegistryDigest(opts) {
450
503
  if (res.status !== 200)
451
504
  continue;
452
505
  const served = header(res, 'docker-content-digest');
453
- if (served && /^sha256:[0-9a-f]{64}$/.test(served))
454
- return served;
506
+ if (served && /^sha256:[0-9a-f]{64}$/.test(served)) {
507
+ return { ip, digest: served };
508
+ }
455
509
  }
456
- }
457
- return null;
510
+ return { ip, digest: null };
511
+ }));
512
+ }
513
+ /**
514
+ * Normalise a `registry.server` into the bare DNS name the endpoint resolvers
515
+ * and the push probe work with: scheme stripped, port stripped. One definition —
516
+ * this pair of regexes had accumulated four copies across three files, and a
517
+ * drift between them would silently change which host the fan-out resolves
518
+ * versus which one the push is pinned to.
519
+ */
520
+ function registryHostOf(server) {
521
+ return server.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
458
522
  }
459
523
  /**
460
524
  * Decide the digest to ATTEST (and to feed the fan-out): the digest Harbor
461
- * SERVES for `<repo>:<tag>`, resolved the same way the deployer does. Falls back
462
- * to the local `docker push` summary digest ONLY if the registry can't be probed
463
- * (offline / dev registry / transient) — a fail-SOFT, never a fail-open, because
464
- * the deployer independently re-verifies the served digest against the attested
465
- * one before it pins the pod.
466
- *
467
- * See {@link resolveRegistryDigest} for why the served digest not the push
468
- * summary is authoritative for the #1445 strict-equality gate (issue #1589).
525
+ * SERVES for `<repo>:<tag>`, resolved the same way the deployer does.
526
+ *
527
+ * ## Which Harbor, though (#2787)
528
+ *
529
+ * `harbor.<domain>` is round-robin over N INDEPENDENT Harbors and a `docker
530
+ * push` lands on exactly ONE of them. Between the push and the fan-out, a
531
+ * re-used tag therefore resolves to the NEW artifact on the push origin and to
532
+ * the PREVIOUS artifact on its peers both are "the registry-served digest".
533
+ * This function used to take whichever endpoint answered first, which on
534
+ * testnet signed `d889ff2a…` (a peer's stale `v12`) for a run that had just
535
+ * pushed `d6ae4a80…`. The deployer that received that attestation refused the
536
+ * image — correctly, it cannot tell a stale attestation from a swapped one —
537
+ * and stayed pinned to the old release while its two peers moved on.
538
+ *
539
+ * So:
540
+ * - **`originHost` set** (a pinned push landed there) → the result is ALWAYS
541
+ * {@link ResolveAttestationDigestOpts.pushDigest}, the digest `buildAndPush`
542
+ * read back from the origin registry after the upload. The origin's live
543
+ * answer is only a cross-check: if it answers something else we THROW (the
544
+ * tag moved underneath us and either value would be a guess), and if it does
545
+ * not answer at all we still attest `pushDigest` rather than fall through to
546
+ * the peers — the silent-origin case is exactly when the peers unanimously
547
+ * serve the PREVIOUS artifact. Peers serving something else are logged and
548
+ * left to the fan-out, which copies this manifest to them and re-verifies
549
+ * (throwing if it can't).
550
+ * - **no `originHost`** (`--skip-push` / unpinned push) → no endpoint is
551
+ * privileged, so the endpoints that answer MUST agree. A split mesh THROWS,
552
+ * naming every endpoint and its digest — #2766 is a live condition here and
553
+ * a coin flip is not an attestation. A served digest may only OVERRIDE
554
+ * `pushDigest` when the WHOLE endpoint set answered; a lone answer from a
555
+ * partially-reachable mesh also THROWS, because the fan-out would then copy
556
+ * that one endpoint's bytes everywhere and make it mesh truth.
557
+ *
558
+ * Falls back to `pushDigest` without complaint when NO endpoint can be probed
559
+ * (offline / dev registry / transient) — a fail-SOFT, never a fail-open: the
560
+ * deployer independently re-verifies the served digest against the attested one
561
+ * before it pins the pod.
562
+ *
563
+ * See {@link resolveRegistryDigest} for why a registry-served digest — not the
564
+ * `docker push` summary — is authoritative for the #1445 strict-equality gate
565
+ * (issue #1589).
469
566
  */
470
567
  async function resolveAttestationDigest(opts) {
471
568
  const log = opts.log ?? (() => undefined);
472
- const host = opts.registry.server.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
473
- let served = null;
569
+ const host = registryHostOf(opts.registry.server);
570
+ const ref = `${opts.registry.repository}:${opts.tag}`;
571
+ let answers = [];
474
572
  try {
475
- served = await resolveRegistryDigest({
573
+ answers = await probeServedDigests({
476
574
  host,
477
575
  repository: opts.registry.repository,
478
576
  tag: opts.tag,
@@ -480,23 +578,159 @@ async function resolveAttestationDigest(opts) {
480
578
  password: opts.registry.password,
481
579
  transport: opts.transport,
482
580
  resolve4: opts.resolve4,
581
+ ...(opts.requestTimeoutMs !== undefined ? { requestTimeoutMs: opts.requestTimeoutMs } : {}),
483
582
  });
484
583
  }
485
584
  catch (err) {
486
585
  log(` attest: registry digest probe threw (${err.message}); ` +
487
- 'attesting the local push digest');
586
+ 'attesting the pushed digest');
587
+ }
588
+ const originIp = opts.originHost
589
+ ? await resolveOriginIp(opts.originHost, opts.resolve4).catch(() => null)
590
+ : null;
591
+ const originAnswer = originIp ? answers.find((a) => a.ip === originIp) : undefined;
592
+ // ── Pinned push: only the origin can confirm, and a peer can never override.
593
+ //
594
+ // This branch ALWAYS returns `pushDigest` — the value `buildAndPush` resolved
595
+ // from the origin registry after the upload. The origin's answer is a
596
+ // cross-check (disagreement ⇒ throw), not an alternative source, and a peer's
597
+ // answer is never a candidate at all. Falling back to "mesh agreement" when
598
+ // the origin happens not to answer would re-open the bug through a second
599
+ // door: on a re-used tag the peers unanimously serve the PREVIOUS artifact
600
+ // until the fan-out runs, which is precisely the digest #2787 signed.
601
+ if (opts.originHost) {
602
+ if (originAnswer?.digest && originAnswer.digest !== opts.pushDigest) {
603
+ throw new Error(`attest: the push origin ${opts.originHost} (${originAnswer.ip}) serves ` +
604
+ `${originAnswer.digest} for ${ref}, but this run pushed ${opts.pushDigest}. ` +
605
+ 'The tag moved at the origin between the push and the attestation — refusing to ' +
606
+ 'sign a digest this run did not produce (#2787).');
607
+ }
608
+ if (!originAnswer?.digest) {
609
+ log(` attest: push origin ${opts.originHost} did not answer for ${ref}; attesting the ` +
610
+ "digest the push produced there (never a peer's answer for a re-used tag, #2787)");
611
+ }
612
+ for (const d of answers.filter((a) => a.digest && a.digest !== opts.pushDigest)) {
613
+ log(` attest: peer ${d.ip} still serves ${d.digest} for ${ref} while this run pushed ` +
614
+ `${opts.pushDigest} at ${opts.originHost} — attesting the ORIGIN digest; the ` +
615
+ 'fan-out converges the peers next and re-verifies every one of them (#2787)');
616
+ }
617
+ return opts.pushDigest;
618
+ }
619
+ // ── No trusted origin: the endpoints that answer must agree. ────────────
620
+ const answered = answers.filter((a) => a.digest !== null);
621
+ const distinct = Array.from(new Set(answered.map((a) => a.digest)));
622
+ if (distinct.length > 1) {
623
+ throw new Error(`attest: the cluster registries disagree about ${ref} — ` +
624
+ answered.map((a) => `${a.ip}=${a.digest}`).join(', ') +
625
+ `; this run pushed ${opts.pushDigest}. Refusing to sign one cluster's answer at ` +
626
+ 'random (#2787/#2766). Re-run the push so a single origin is authoritative, or ' +
627
+ 'converge the tag across the mesh first.');
488
628
  }
489
- if (!served) {
490
- log(` attest: could not resolve the registry-served digest for ${opts.registry.repository}:${opts.tag}; ` +
491
- 'attesting the local push digest (the deployer re-verifies)');
629
+ if (distinct.length === 0) {
630
+ log(` attest: could not resolve the registry-served digest for ${ref}; ` +
631
+ 'attesting the pushed digest (the deployer re-verifies)');
492
632
  return opts.pushDigest;
493
633
  }
634
+ const served = distinct[0];
494
635
  if (served !== opts.pushDigest) {
495
- log(` attest: registry serves ${served} for ${opts.registry.repository}:${opts.tag} ` +
496
- `(local push reported ${opts.pushDigest}); attesting the registry-served digest`);
636
+ // A served digest may only OVERRIDE what this run pushed when the whole
637
+ // endpoint set corroborates it (#1589's containerd re-serialization case,
638
+ // and any single-target registry). If some endpoints stayed silent, "the
639
+ // endpoints that answer agree" is just "whoever happened to be reachable" —
640
+ // and the fan-out then makes that lone answer mesh truth by copying its
641
+ // bytes everywhere, past the point where the deployer's strict-equality
642
+ // re-check could still catch it.
643
+ if (answered.length < answers.length) {
644
+ throw new Error(`attest: only ${answered.length} of ${answers.length} cluster registries answered for ` +
645
+ `${ref} (` +
646
+ answered.map((a) => `${a.ip}=${a.digest}`).join(', ') +
647
+ `), and that answer disagrees with the ${opts.pushDigest} this run pushed. ` +
648
+ 'Refusing to sign an uncorroborated digest from a partially-reachable mesh — ' +
649
+ 're-run once every cluster registry is reachable (#2787).');
650
+ }
651
+ log(` attest: registry serves ${served} for ${ref} ` +
652
+ `(push reported ${opts.pushDigest}); attesting the registry-served digest`);
497
653
  }
498
654
  return served;
499
655
  }
656
+ /**
657
+ * Resolve the pinned push-origin hostname to the ONE cluster-ingress address it
658
+ * names. `resolvePushOrigin` already refused to return a name that resolves to
659
+ * anything other than exactly one address (its guard 3), so a multi-address
660
+ * answer here means DNS changed underneath us — treat it as "no origin" rather
661
+ * than picking one.
662
+ */
663
+ async function resolveOriginIp(originHost, resolve4) {
664
+ const ips = await (resolve4 ?? ((h) => dns_1.promises.resolve4(h)))(originHost);
665
+ return ips.length === 1 ? ips[0] : null;
666
+ }
667
+ /**
668
+ * #2766 — BEFORE a `docker push`, prove the candidate tag is unoccupied on
669
+ * EVERY cluster Harbor of the mesh. Tag immutability is enforced per-cluster,
670
+ * so it cannot prevent divergence BETWEEN registries: a tag that already
671
+ * exists on any one of them (at any digest — the about-to-be-pushed build has
672
+ * a fresh, unknowable digest) makes the deploy 412 mid-fan-out at best, and at
673
+ * worst leaves the tag permanently inconsistent across the mesh (sn1/sn2 at
674
+ * one digest, sn3 at another, all immutable — unrecoverable without operator
675
+ * action). This check moves that failure BEFORE the build/push, where the
676
+ * operator just picks a fresh tag, and lists the divergence it found.
677
+ *
678
+ * Semantics:
679
+ * - 0/1 endpoint, or DNS that does not fan out → `null` (single-target
680
+ * registry; a single origin's own immutability already protects it).
681
+ * - tag absent EVERYWHERE → the occupancy (all `digest: null`) — free to push.
682
+ * - tag present on ANY endpoint at ANY digest → THROWS, one line per cluster
683
+ * (`ip → sha256:… | absent`). A single-origin pre-check must never be
684
+ * treated as authoritative anywhere in this path — the exact check that
685
+ * produced the v10 incident.
686
+ *
687
+ * Fail-open is deliberate on TRANSPORT errors per endpoint? NO — a cluster we
688
+ * cannot interrogate is a cluster we cannot vouch for, so a non-404/200 answer
689
+ * is fatal too. Only the not-a-mesh case (above) returns `null`.
690
+ */
691
+ async function assertTagFreeOnMesh(opts) {
692
+ const log = opts.log ?? (() => undefined);
693
+ const host = registryHostOf(opts.registry.server);
694
+ let endpoints;
695
+ try {
696
+ endpoints = await resolveClusterEndpoints(host, opts.resolve4);
697
+ }
698
+ catch (err) {
699
+ log(` pre-check: ${host} did not resolve (${err.message}); single-target, nothing to pre-check`);
700
+ return null;
701
+ }
702
+ if (endpoints.length <= 1) {
703
+ log(` pre-check: ${host} resolves to ${endpoints.length} endpoint(s); single-target, nothing to pre-check`);
704
+ return null;
705
+ }
706
+ const transport = opts.transport ?? defaultTransport(host);
707
+ const client = makeClient(transport, opts.registry.username, opts.registry.password);
708
+ const scope = `repository:${opts.registry.repository}:pull,push`;
709
+ const manifestPath = `/v2/${opts.registry.repository}/manifests/${opts.tag}`;
710
+ const perEndpoint = await Promise.all(endpoints.map(async (ip) => {
711
+ const res = await client.send({ ip, method: 'HEAD', path: manifestPath, headers: { Accept: MANIFEST_ACCEPT } }, scope);
712
+ if (res.status === 200) {
713
+ const served = header(res, 'docker-content-digest');
714
+ if (served)
715
+ return { ip, digest: served };
716
+ // 200 with no digest: the registry is broken, not the tag's absence.
717
+ throw new Error(`pre-check: ${ip} served HTTP 200 for ${opts.registry.repository}:${opts.tag} with no Docker-Content-Digest — cannot vouch for the mesh`);
718
+ }
719
+ if (res.status === 404)
720
+ return { ip, digest: null };
721
+ throw new Error(`pre-check: ${ip} answered HTTP ${res.status} for ${opts.registry.repository}:${opts.tag} — cannot vouch for the mesh`);
722
+ }));
723
+ const occupied = perEndpoint.filter((e) => e.digest !== null);
724
+ if (occupied.length > 0) {
725
+ const lines = perEndpoint.map((e) => ` ${e.ip} → ${e.digest ?? 'absent'}`);
726
+ throw new Error(`tag ${opts.tag} is already occupied on the registry mesh (${host}) — a fresh build ` +
727
+ 'cannot take it over: per-cluster immutability would 412 mid-fan-out and can leave ' +
728
+ 'the tag diverged across clusters permanently (#2766). Pick a new tag. Mesh state:\n' +
729
+ lines.join('\n'));
730
+ }
731
+ log(` pre-check: ${opts.tag} is free on all ${endpoints.length} cluster registries`);
732
+ return { host, tag: opts.tag, perEndpoint };
733
+ }
500
734
  /**
501
735
  * The deploy-time entrypoint: take an image just pushed to the federated host
502
736
  * (landed on ONE cluster) and guarantee it is present on EVERY cluster Harbor.
@@ -513,7 +747,7 @@ async function resolveAttestationDigest(opts) {
513
747
  */
514
748
  async function fanOutPushedImage(opts) {
515
749
  const log = opts.log ?? (() => undefined);
516
- const host = opts.registry.server.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
750
+ const host = registryHostOf(opts.registry.server);
517
751
  let endpoints;
518
752
  try {
519
753
  endpoints = await resolveClusterEndpoints(host, opts.resolve4);
@@ -542,4 +776,300 @@ async function fanOutPushedImage(opts) {
542
776
  }
543
777
  return report;
544
778
  }
779
+ /**
780
+ * Is `candidate` a sub-domain of `reference`'s PARENT zone? `harbor.a.b.c` →
781
+ * parent `a.b.c`, so `harbor-sn1.a.b.c` passes and `harbor-sn1.evil.example`
782
+ * does not. Matches on the dot boundary, so a look-alike suffix
783
+ * (`evil-a.b.c` against parent `a.b.c`) is rejected. A single-label reference
784
+ * has no parent zone and admits nothing.
785
+ */
786
+ function isSameDnsZone(candidate, reference) {
787
+ const labels = reference.toLowerCase().split('.');
788
+ if (labels.length < 2)
789
+ return false;
790
+ const parent = labels.slice(1).join('.');
791
+ return candidate.toLowerCase().endsWith(`.${parent}`);
792
+ }
793
+ /**
794
+ * Resolve the SINGLE-ORIGIN hostname a `docker push` must be aimed at, by
795
+ * asking the mesh which name it wants upload traffic on. Returns `null` when
796
+ * there is nothing to pin (single-target registry, no server-side pin, or a pin
797
+ * that fails validation) — the caller then pushes at the round-robin name, i.e.
798
+ * exactly today's behaviour.
799
+ *
800
+ * ## Why the push must be pinned at all (#1679)
801
+ *
802
+ * `harbor.<domain>` is public-DNS round-robin over three INDEPENDENT Harbors.
803
+ * A chunked blob upload (`POST` init → `PATCH` → `PUT` finalize) is stateful:
804
+ * the `_state` token is HMAC'd by the serving registry and the uploaded bytes
805
+ * sit on that cluster's own ReadWriteOnce PVC. When the client reconnects
806
+ * between requests and round-robin lands the next hop on a different cluster,
807
+ * the upload dies (`blob upload invalid` / `invalid secret`). Sharing
808
+ * `REGISTRY_HTTP_SECRET` and the token CA across the mesh (PR #2763) fixes the
809
+ * HMAC and the bearer token, but NOT the blob bytes — those are still local to
810
+ * one cluster. The push has to stay on one origin end to end; the fan-out then
811
+ * distributes the finished artifact ({@link fanOutPushedImage}).
812
+ *
813
+ * ## Why we ASK instead of deriving the name
814
+ *
815
+ * Each cluster's registry is already pinned server-side (`REGISTRY_HTTP_HOST`)
816
+ * and advertises that name in the `Location` of an upload-init. The server
817
+ * honours it; docker 29.5.2 does not — it keeps the host it DIALLED and reuses
818
+ * only the path+query of the `Location`, so the server-side pin never reaches
819
+ * the wire. Reading the advertised host and dialling it ourselves is therefore
820
+ * the client-side half of a pin the server already publishes. Deriving
821
+ * `harbor-sn1` from `harbor` by string surgery would instead bake a testnet DNS
822
+ * convention into a customer-facing CLI, and silently mis-aim any other mesh.
823
+ *
824
+ * ## Validation
825
+ *
826
+ * The advertised value is server-controlled, and the CLI hands it to
827
+ * `docker login` / `docker push` — so it gates the robot credential and the
828
+ * customer's image. Three guards, all fail-CLOSED (return `null`, never throw;
829
+ * the caller then pushes unpinned, exactly as it would have anyway):
830
+ *
831
+ * 1. Not an IP literal — no cert covers one here, and "does this address
832
+ * resolve to itself" is not an identity check.
833
+ * 2. Inside `host`'s own parent DNS zone. A registry may only nominate a name
834
+ * the OPERATOR already controls, never an arbitrary internet name.
835
+ * 3. Resolves to exactly ONE address, and that address is the very cluster
836
+ * that advertised it.
837
+ *
838
+ * RESIDUAL RISK (accepted, documented): validation and use are two different
839
+ * DNS lookups — ours via `dns.resolve4`, then docker's own, moments later. A
840
+ * DNS rebind between them is not detectable from here, because docker resolves
841
+ * the hostname itself and takes no IP pin. Guard 2 is what makes that
842
+ * uninteresting: a rebind would need control of the operator's own zone, and an
843
+ * attacker holding that already owns `harbor.<domain>` itself. Closing the
844
+ * window completely means not shelling out to `docker` for the push at all
845
+ * (doing the upload over the Registry-v2 transport in this file, which does pin
846
+ * IP + SNI) — a much larger change, tracked separately.
847
+ *
848
+ * The probe opens one blob-upload session per attempt and cancels it
849
+ * (`DELETE`), so it leaves no half-open upload behind.
850
+ */
851
+ async function resolvePushOrigin(opts) {
852
+ const log = opts.log ?? (() => undefined);
853
+ const resolve4 = opts.resolve4 ?? ((h) => dns_1.promises.resolve4(h));
854
+ let endpoints;
855
+ if (opts.endpoints) {
856
+ endpoints = opts.endpoints;
857
+ }
858
+ else {
859
+ try {
860
+ endpoints = await resolveClusterEndpoints(opts.host, resolve4);
861
+ }
862
+ catch (err) {
863
+ log(` push-pin: ${opts.host} did not resolve (${err.message}); pushing unpinned`);
864
+ return null;
865
+ }
866
+ }
867
+ if (endpoints.length <= 1) {
868
+ // A single-target registry (dev / local / a mesh behind one VIP) cannot
869
+ // split a push across clusters, so there is nothing to pin.
870
+ return null;
871
+ }
872
+ const transport = opts.transport ?? defaultTransport(opts.host);
873
+ const client = makeClient(transport, opts.username, opts.password);
874
+ const pushScope = `repository:${opts.repository}:pull,push`;
875
+ const uploadInitPath = `/v2/${opts.repository}/blobs/uploads/`;
876
+ for (const ip of endpoints) {
877
+ // BACKSTOP — this function's contract is "never rejects: return null and
878
+ // let the caller push unpinned", and NEITHER caller wraps it in try/catch.
879
+ // The targeted catches inside give better messages; this is what makes the
880
+ // contract hold structurally rather than by enumeration, including for
881
+ // edits made later. `continue`/`return` still work normally from inside.
882
+ try {
883
+ // Unreachable, or a non-2xx: this cluster tells us nothing, try the next.
884
+ // A throw here is handled by the BACKSTOP above — deliberately NOT caught
885
+ // locally as well, so the backstop is load-bearing rather than decorative
886
+ // (a second handler here made it impossible for any test to notice its
887
+ // removal).
888
+ const res = await client.send({ ip, method: 'POST', path: uploadInitPath, headers: { 'Content-Length': '0' } }, pushScope);
889
+ if (res.status < 200 || res.status >= 300)
890
+ continue;
891
+ const location = header(res, 'location');
892
+ if (!location)
893
+ continue;
894
+ // Parse the `Location` ONCE, defensively. It is server-controlled, so it may
895
+ // be absent, relative, or simply unparseable (`https://`, embedded
896
+ // whitespace, a control character). Everything below reads this parse — no
897
+ // second `new URL` and no prefix-shape check standing in for a real parse,
898
+ // which is how an "unparseable but http-prefixed" value used to throw out of
899
+ // this function while a request argument was being BUILT, past the trailing
900
+ // `.catch`, past both callers, turning a graceful fall-back into a hard
901
+ // deploy abort.
902
+ // A throw here (`https://`, embedded whitespace, a control character —
903
+ // all server-controlled) is caught by the BACKSTOP, which moves to the
904
+ // next cluster and ultimately returns null. Not caught locally as well:
905
+ // a second handler would be unkillable by any test and could rot.
906
+ const parsed = /^https?:\/\//i.test(location) ? new URL(location) : null;
907
+ // Cancel the probe session — best effort, never fatal, but LOGGED: the
908
+ // "leaves no half-open upload behind" promise must not fail invisibly. Only
909
+ // a request-target we actually derived is sent; nothing is spliced verbatim.
910
+ const cancelPath = parsed
911
+ ? parsed.pathname + parsed.search
912
+ : location.startsWith('/')
913
+ ? location
914
+ : null;
915
+ if (cancelPath) {
916
+ await client
917
+ .send({ ip, method: 'DELETE', path: cancelPath }, pushScope)
918
+ .catch((err) => log(` push-pin: could not cancel the probe upload session on ${ip} ` +
919
+ `(${err?.message ?? err}) — it will expire on its own`));
920
+ }
921
+ // A relative (or unparseable) `Location` means there is no absolute host to
922
+ // adopt — this registry advertises no server-side pin.
923
+ if (!parsed)
924
+ continue;
925
+ const advertised = parsed.host; // host[:port] — what docker will be given
926
+ const hostname = parsed.hostname; // no port — what DNS resolves
927
+ // Compare on the HOSTNAME: `parsed.host` may carry a port while `opts.host`
928
+ // never does, so `harbor.<domain>:8443` would otherwise read as a distinct
929
+ // origin and aim the push straight back at the round-robin name. (Not
930
+ // separately testable — the round-robin name always has ≥2 A-records, so
931
+ // guard 3 below would refuse it anyway. Correct here regardless of that.)
932
+ if (!hostname || hostname.toLowerCase() === opts.host.toLowerCase()) {
933
+ // The registry echoes the round-robin name → no pin configured.
934
+ continue;
935
+ }
936
+ if (net.isIP(hostname) !== 0) {
937
+ // A bare IP can carry no valid cert for this mesh (the Harbor certs are
938
+ // keyed on names), and "does this address resolve back to itself" is not
939
+ // a meaningful identity check. Rejected explicitly rather than left to
940
+ // whatever a resolver does with an IP-shaped name.
941
+ log(` push-pin: ignoring ${advertised} — an IP literal is not a usable push origin`);
942
+ continue;
943
+ }
944
+ // GUARD 1 — the nomination must stay inside the operator's OWN DNS zone.
945
+ // Without this a compromised cluster could nominate any name on the
946
+ // internet, point it at its own IP just long enough to pass guard 2, then
947
+ // re-point it (short TTL) before docker resolves — sending the robot
948
+ // credential and the customer image to attacker infrastructure. See the
949
+ // residual-risk note in the docblock.
950
+ if (!isSameDnsZone(hostname, opts.host)) {
951
+ log(` push-pin: REFUSING ${advertised} — outside ${opts.host}'s own DNS zone; ` +
952
+ 'a registry may only nominate a name the operator already controls');
953
+ continue;
954
+ }
955
+ // GUARD 2 — the advertised name MUST resolve to exactly the one cluster
956
+ // that advertised it. A multi-answer name is not a single origin: docker
957
+ // re-resolves independently and could dial a different address in the same
958
+ // answer set, reinstating the very cross-cluster hop this pin exists to stop.
959
+ let advertisedIps;
960
+ try {
961
+ advertisedIps = await resolve4(hostname);
962
+ }
963
+ catch (err) {
964
+ log(` push-pin: ignoring ${advertised} — it does not resolve (${err.message})`);
965
+ continue;
966
+ }
967
+ if (advertisedIps.length !== 1 || advertisedIps[0] !== ip) {
968
+ log(` push-pin: REFUSING ${advertised} — it resolves to ${advertisedIps.join(', ') || 'nothing'}, ` +
969
+ `not to exactly ${ip} (the cluster that advertised it)`);
970
+ continue;
971
+ }
972
+ log(` push-pin: ${opts.host} → ${advertised} (single origin ${ip}) for this push`);
973
+ return advertised;
974
+ }
975
+ catch (err) {
976
+ log(` push-pin: probe of ${ip} failed (${err?.message ?? String(err)})`);
977
+ }
978
+ }
979
+ log(` push-pin: no cluster behind ${opts.host} advertised a usable single-origin host; ` +
980
+ 'pushing unpinned (a mid-upload cluster hop may fail — #1679)');
981
+ return null;
982
+ }
983
+ /**
984
+ * The pin every push goes through (#2887). Wraps {@link resolvePushOrigin} and
985
+ * turns its fail-OPEN `null` into a hard failure **when the registry name is
986
+ * federated** — i.e. when it resolves to more than one origin.
987
+ *
988
+ * ## Why the fail-open had to go
989
+ *
990
+ * `resolvePushOrigin` returning `null` means "I could not pin this push". Its
991
+ * callers then pushed at the round-robin name, which is documented as
992
+ * "exactly today's behaviour" — but on a federated mesh today's behaviour does
993
+ * not work. Measured on testnet 2026-08-27 against
994
+ * `harbor.testnet.hsuite.network` (three A records, three INDEPENDENT Harbors,
995
+ * each signing its own bearer tokens and holding blob-upload sessions on its
996
+ * own ReadWriteOnce PVC): `Login Succeeded`, then some layers reporting
997
+ * `Layer already exists` while others sat on `Waiting`, then
998
+ * `error from registry: unauthorized to access repository: …, action: push`
999
+ * — on all 6 retries. The identical push aimed at `harbor-sn2.…` succeeded on
1000
+ * the first attempt, then again at sn1 and sn3.
1001
+ *
1002
+ * So the unpinned fall-back is not a degraded push, it is a push that cannot
1003
+ * succeed, and taking it silently costs a full image build plus six retries
1004
+ * before failing with an error message that blames permissions. Refusing up
1005
+ * front — naming the mesh, its origins, and the server-side knob that fixes it
1006
+ * — is strictly better information for the same outcome.
1007
+ *
1008
+ * ## What is still a legitimate no-pin (returns `null`, no throw)
1009
+ *
1010
+ * - ONE origin. A dev / local / single-VIP registry cannot split a push
1011
+ * across clusters, so there is nothing to pin and nothing to fail.
1012
+ * - DNS that cannot enumerate the name at all. `dns.resolve4` (this module's
1013
+ * resolver) ignores `/etc/hosts` while docker's own resolution does not, so
1014
+ * an unresolvable name here can still be a perfectly working single-origin
1015
+ * registry on the developer's machine. Unproven federation is not proven
1016
+ * federation; we log it and let the push proceed.
1017
+ *
1018
+ * RESIDUAL RISK (accepted, documented): the A-record count is the ONLY signal
1019
+ * we have for "is this mesh federated". A resolver that answers with fewer
1020
+ * records than the mesh really has — a spoofed or on-path answer, or simply a
1021
+ * health-pruned view at query time — makes a three-Harbor mesh look
1022
+ * single-origin, and the push falls back to unpinned. That is not a
1023
+ * credential-disclosure risk (the three validation guards in
1024
+ * {@link resolvePushOrigin} are untouched, and the fall-back still targets the
1025
+ * operator's own round-robin name, resolved independently by docker); the cost
1026
+ * is reinstating the failure mode above — a wasted build and six confusing
1027
+ * retries. Removing it needs a source of mesh cardinality that is not DNS.
1028
+ *
1029
+ * Callers that genuinely want no pin have `--skip-push` (nothing is uploaded,
1030
+ * so there is no upload session to keep on one origin).
1031
+ */
1032
+ async function requirePushOrigin(opts) {
1033
+ const log = opts.log ?? (() => undefined);
1034
+ const resolve4 = opts.resolve4 ?? ((h) => dns_1.promises.resolve4(h));
1035
+ // ONE DNS answer decides both "is this federated?" and "which origins do we
1036
+ // probe?" — resolving twice lets the two disagree, and a narrowing answer
1037
+ // between them is exactly how a mesh could look single-origin to one check
1038
+ // and federated to the other.
1039
+ let endpoints;
1040
+ try {
1041
+ endpoints = opts.endpoints ?? (await resolveClusterEndpoints(opts.host, resolve4));
1042
+ }
1043
+ catch (err) {
1044
+ // RETURN, do not delegate. Handing the un-enumerated `opts` to
1045
+ // `resolvePushOrigin` would let IT run a second, independent lookup — and a
1046
+ // resolver that fails once then answers with the full federated set would
1047
+ // leave the decision below reading a stale "not federated" while the nested
1048
+ // call had just proved the opposite. That is the exact disagreement the
1049
+ // single-answer rule above exists to prevent, so the failure path must
1050
+ // honour it too. Nothing is lost: the probe needs the IP list, so a
1051
+ // delegated call could only have re-derived what we just failed to get.
1052
+ log(` push-pin: ${opts.host} did not resolve via DNS (${err.message}); ` +
1053
+ 'cannot tell whether this registry is federated — pushing unpinned');
1054
+ return null;
1055
+ }
1056
+ const origin = await resolvePushOrigin({ ...opts, endpoints });
1057
+ if (origin)
1058
+ return origin;
1059
+ if (endpoints.length > 1) {
1060
+ throw new Error(`cannot pin the docker push for ${opts.repository}: ${opts.host} resolves to ` +
1061
+ `${endpoints.length} independent registries (${endpoints.join(', ')}) and none of them ` +
1062
+ 'advertised a usable single-origin host to push at.\n' +
1063
+ ' An UNPINNED push across a federated Harbor mesh cannot succeed (#1679): every ' +
1064
+ 'request re-resolves the round-robin name, each cluster signs its own bearer tokens, ' +
1065
+ 'and the uploaded blob bytes live on the originating cluster\'s own ReadWriteOnce PVC — ' +
1066
+ 'so the layers scatter and the push dies mid-upload (`blob upload invalid`, or ' +
1067
+ '`unauthorized to access repository: …, action: push`) on every retry.\n' +
1068
+ ' Refusing to start a push that cannot finish. Fix the mesh: each cluster\'s registry ' +
1069
+ 'must advertise its OWN single-A name via REGISTRY_HTTP_HOST (k8s/harbor/values-snN.yaml ' +
1070
+ '→ registry.registry.extraEnvVars; asserted by charts/smart-engines/tests/helm-template.bats). ' +
1071
+ 'Or re-run with --skip-push if the image is already on the mesh.');
1072
+ }
1073
+ return null;
1074
+ }
545
1075
  //# sourceMappingURL=registry-fanout.js.map