@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.
- package/README.md +5 -3
- package/dist/_lib/baas.d.ts +27 -4
- package/dist/_lib/baas.d.ts.map +1 -1
- package/dist/_lib/baas.js +76 -42
- package/dist/_lib/baas.js.map +1 -1
- package/dist/_lib/cluster-convergence.d.ts +214 -0
- package/dist/_lib/cluster-convergence.d.ts.map +1 -0
- package/dist/_lib/cluster-convergence.js +381 -0
- package/dist/_lib/cluster-convergence.js.map +1 -0
- package/dist/_lib/deploy-manifest.d.ts +32 -0
- package/dist/_lib/deploy-manifest.d.ts.map +1 -1
- package/dist/_lib/deploy-manifest.js +71 -0
- package/dist/_lib/deploy-manifest.js.map +1 -1
- package/dist/_lib/docker.d.ts +35 -23
- package/dist/_lib/docker.d.ts.map +1 -1
- package/dist/_lib/docker.js +58 -54
- package/dist/_lib/docker.js.map +1 -1
- package/dist/_lib/frontend-guard.d.ts +59 -0
- package/dist/_lib/frontend-guard.d.ts.map +1 -1
- package/dist/_lib/frontend-guard.js +148 -0
- package/dist/_lib/frontend-guard.js.map +1 -1
- package/dist/_lib/registry-fanout.d.ts +299 -9
- package/dist/_lib/registry-fanout.d.ts.map +1 -1
- package/dist/_lib/registry-fanout.js +557 -27
- package/dist/_lib/registry-fanout.js.map +1 -1
- package/dist/_lib/subscription-client.d.ts +14 -0
- package/dist/_lib/subscription-client.d.ts.map +1 -1
- package/dist/_lib/subscription-client.js +13 -0
- package/dist/_lib/subscription-client.js.map +1 -1
- package/dist/commands/deploy.d.ts +28 -4
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +163 -20
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/redeploy.d.ts +10 -0
- package/dist/commands/redeploy.d.ts.map +1 -1
- package/dist/commands/redeploy.js +146 -15
- package/dist/commands/redeploy.js.map +1 -1
- package/dist/commands/status.d.ts +4 -3
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +15 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/topup.d.ts +55 -0
- package/dist/commands/topup.d.ts.map +1 -0
- package/dist/commands/topup.js +180 -0
- package/dist/commands/topup.js.map +1 -0
- package/dist/commands/update.d.ts +33 -0
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +38 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +24 -1
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
|
473
|
-
|
|
569
|
+
const host = registryHostOf(opts.registry.server);
|
|
570
|
+
const ref = `${opts.registry.repository}:${opts.tag}`;
|
|
571
|
+
let answers = [];
|
|
474
572
|
try {
|
|
475
|
-
|
|
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
|
|
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 (
|
|
490
|
-
log(` attest: could not resolve the registry-served digest for ${
|
|
491
|
-
'attesting the
|
|
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
|
-
|
|
496
|
-
|
|
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
|
|
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
|