@umbra-privacy/qos-ceremony 0.1.2 → 0.1.3
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/dist-cli/cli.js +107 -27
- package/package.json +1 -1
package/dist-cli/cli.js
CHANGED
|
@@ -31567,7 +31567,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31567
31567
|
};
|
|
31568
31568
|
var DescribeTargetGroupsCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetGroups", DescribeTargetGroups$) {
|
|
31569
31569
|
};
|
|
31570
|
-
var
|
|
31570
|
+
var DescribeTargetHealthCommand2 = class extends command5(_ep05, _mw05, "DescribeTargetHealth", DescribeTargetHealth$) {
|
|
31571
31571
|
};
|
|
31572
31572
|
var DescribeTrustStoreAssociationsCommand = class extends command5(_ep05, _mw05, "DescribeTrustStoreAssociations", DescribeTrustStoreAssociations$) {
|
|
31573
31573
|
};
|
|
@@ -31743,7 +31743,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31743
31743
|
var checkState$1 = async (client, input) => {
|
|
31744
31744
|
let reason;
|
|
31745
31745
|
try {
|
|
31746
|
-
let result = await client.send(new
|
|
31746
|
+
let result = await client.send(new DescribeTargetHealthCommand2(input));
|
|
31747
31747
|
reason = result;
|
|
31748
31748
|
try {
|
|
31749
31749
|
const returnComparator = () => {
|
|
@@ -31782,7 +31782,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31782
31782
|
var checkState = async (client, input) => {
|
|
31783
31783
|
let reason;
|
|
31784
31784
|
try {
|
|
31785
|
-
let result = await client.send(new
|
|
31785
|
+
let result = await client.send(new DescribeTargetHealthCommand2(input));
|
|
31786
31786
|
reason = result;
|
|
31787
31787
|
try {
|
|
31788
31788
|
const returnComparator = () => {
|
|
@@ -31846,7 +31846,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
31846
31846
|
DescribeTagsCommand: DescribeTagsCommand2,
|
|
31847
31847
|
DescribeTargetGroupAttributesCommand,
|
|
31848
31848
|
DescribeTargetGroupsCommand: DescribeTargetGroupsCommand2,
|
|
31849
|
-
DescribeTargetHealthCommand,
|
|
31849
|
+
DescribeTargetHealthCommand: DescribeTargetHealthCommand2,
|
|
31850
31850
|
DescribeTrustStoreAssociationsCommand,
|
|
31851
31851
|
DescribeTrustStoreRevocationsCommand,
|
|
31852
31852
|
DescribeTrustStoresCommand,
|
|
@@ -32185,7 +32185,7 @@ var require_dist_cjs16 = __commonJS({
|
|
|
32185
32185
|
exports2.DescribeTargetGroupsInput$ = DescribeTargetGroupsInput$;
|
|
32186
32186
|
exports2.DescribeTargetGroupsOutput$ = DescribeTargetGroupsOutput$;
|
|
32187
32187
|
exports2.DescribeTargetHealth$ = DescribeTargetHealth$;
|
|
32188
|
-
exports2.DescribeTargetHealthCommand =
|
|
32188
|
+
exports2.DescribeTargetHealthCommand = DescribeTargetHealthCommand2;
|
|
32189
32189
|
exports2.DescribeTargetHealthInput$ = DescribeTargetHealthInput$;
|
|
32190
32190
|
exports2.DescribeTargetHealthInputIncludeEnum = DescribeTargetHealthInputIncludeEnum;
|
|
32191
32191
|
exports2.DescribeTargetHealthOutput$ = DescribeTargetHealthOutput$;
|
|
@@ -32823,10 +32823,24 @@ var Workdir = class {
|
|
|
32823
32823
|
};
|
|
32824
32824
|
|
|
32825
32825
|
// ../qos-core/src/api.ts
|
|
32826
|
+
function enforceHttps(url) {
|
|
32827
|
+
if (!url) throw new Error("coordinator base URL is required");
|
|
32828
|
+
let u;
|
|
32829
|
+
try {
|
|
32830
|
+
u = new URL(url);
|
|
32831
|
+
} catch {
|
|
32832
|
+
throw new Error(`invalid coordinator base URL: ${url}`);
|
|
32833
|
+
}
|
|
32834
|
+
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
32835
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
|
|
32836
|
+
throw new Error(`coordinator base URL must use https (got ${u.protocol}//${u.hostname})`);
|
|
32837
|
+
}
|
|
32838
|
+
return url;
|
|
32839
|
+
}
|
|
32826
32840
|
var CeremonyApi = class {
|
|
32827
32841
|
constructor(baseUrl) {
|
|
32828
32842
|
this.baseUrl = baseUrl;
|
|
32829
|
-
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
32843
|
+
this.baseUrl = enforceHttps(baseUrl.replace(/\/$/, ""));
|
|
32830
32844
|
}
|
|
32831
32845
|
base(ns, nonce) {
|
|
32832
32846
|
return `${this.baseUrl}/ceremony/${encodeURIComponent(ns)}/${nonce}`;
|
|
@@ -32843,6 +32857,13 @@ var CeremonyApi = class {
|
|
|
32843
32857
|
if (!r5.ok) throw new Error(`getArtifact ${path}: ${r5.status} ${await r5.text()}`);
|
|
32844
32858
|
return Buffer.from(await r5.text(), "base64");
|
|
32845
32859
|
}
|
|
32860
|
+
/** Fetch a namespace-shared artifact (`quorum_key.pub`, `shares/<alias>.share`) — nonce-free,
|
|
32861
|
+
* invariant across a service's colors. Same base64 encoding as getArtifact. */
|
|
32862
|
+
async getSharedArtifact(ns, path) {
|
|
32863
|
+
const r5 = await fetch(`${this.baseUrl}/shared/${encodeURIComponent(ns)}/${path}`);
|
|
32864
|
+
if (!r5.ok) throw new Error(`getSharedArtifact ${path}: ${r5.status} ${await r5.text()}`);
|
|
32865
|
+
return Buffer.from(await r5.text(), "base64");
|
|
32866
|
+
}
|
|
32846
32867
|
/** Registered operator pubkeys (alias → hex) for the ceremony's service. The
|
|
32847
32868
|
* registry is the source of member pubkeys (no per-ceremony upload). */
|
|
32848
32869
|
async getMembers(ns, nonce) {
|
|
@@ -33041,15 +33062,28 @@ function resolve(o3) {
|
|
|
33041
33062
|
|
|
33042
33063
|
// src/artifacts.ts
|
|
33043
33064
|
var import_node_fs4 = require("node:fs");
|
|
33065
|
+
var SAFE_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
33066
|
+
function assertSafeAlias(alias) {
|
|
33067
|
+
if (!SAFE_ALIAS.test(alias)) {
|
|
33068
|
+
throw new Error(
|
|
33069
|
+
`refusing coordinator-supplied member alias '${alias}' \u2014 not a safe filename (expected ${SAFE_ALIAS}); a malicious coordinator must not drive a local path`
|
|
33070
|
+
);
|
|
33071
|
+
}
|
|
33072
|
+
}
|
|
33044
33073
|
async function pullArtifact(ctx, wd, apiPath, localRel) {
|
|
33045
33074
|
const bytes = await ctx.api.getArtifact(ctx.namespace, ctx.nonce, apiPath);
|
|
33046
33075
|
return wd.writeBytes(localRel, bytes);
|
|
33047
33076
|
}
|
|
33077
|
+
async function pullShared(ctx, wd, sharedPath, localRel) {
|
|
33078
|
+
const bytes = await ctx.api.getSharedArtifact(ctx.namespace, sharedPath);
|
|
33079
|
+
return wd.writeBytes(localRel, bytes);
|
|
33080
|
+
}
|
|
33048
33081
|
async function buildSetDirs(ctx, wd, sets) {
|
|
33049
33082
|
const members = await ctx.api.getMembers(ctx.namespace, ctx.nonce);
|
|
33050
33083
|
const build = (name, set) => {
|
|
33051
33084
|
const dir = wd.dir(name);
|
|
33052
33085
|
for (const alias of set.members) {
|
|
33086
|
+
assertSafeAlias(alias);
|
|
33053
33087
|
const hex = members[alias];
|
|
33054
33088
|
if (!hex)
|
|
33055
33089
|
throw new Error(`operator '${alias}' has no registered pubkey (admin register-operator)`);
|
|
@@ -33067,7 +33101,7 @@ async function buildSetDirs(ctx, wd, sets) {
|
|
|
33067
33101
|
async function pullGenesisDir(ctx, wd) {
|
|
33068
33102
|
const dir = wd.dir("namespace");
|
|
33069
33103
|
await pullArtifact(ctx, wd, "genesis/genesis_output", "namespace/genesis_output");
|
|
33070
|
-
await
|
|
33104
|
+
await pullShared(ctx, wd, "quorum_key.pub", "namespace/quorum_key.pub").catch(() => "");
|
|
33071
33105
|
await pullArtifact(
|
|
33072
33106
|
ctx,
|
|
33073
33107
|
wd,
|
|
@@ -33117,7 +33151,7 @@ async function approve(o3) {
|
|
|
33117
33151
|
try {
|
|
33118
33152
|
const manifestPath = await pullArtifact(ctx, wd, "manifest/manifest", "manifest");
|
|
33119
33153
|
const pivotHashPath = await pullArtifact(ctx, wd, "manifest/pivot.hash", "pivot.hash");
|
|
33120
|
-
const quorumKeyPath = await
|
|
33154
|
+
const quorumKeyPath = await pullShared(ctx, wd, "quorum_key.pub", "quorum_key.pub");
|
|
33121
33155
|
const { manifestSetDir, shareSetDir, patchSetDir } = await buildSetDirs(ctx, wd, state2.sets);
|
|
33122
33156
|
const approvalsDir = wd.dir("approvals");
|
|
33123
33157
|
const json = ctx.qos.display(manifestPath, "manifest");
|
|
@@ -33185,7 +33219,7 @@ async function provision(o3) {
|
|
|
33185
33219
|
"manifest/manifest_envelope",
|
|
33186
33220
|
"manifest_envelope"
|
|
33187
33221
|
);
|
|
33188
|
-
const sharePath = await
|
|
33222
|
+
const sharePath = await pullShared(ctx, wd, `shares/${ctx.alias}.share`, `${ctx.alias}.share`);
|
|
33189
33223
|
const { manifestSetDir } = await buildSetDirs(ctx, wd, state2.sets);
|
|
33190
33224
|
const ephWrappedSharePath = wd.path(`${ctx.alias}.eph_share`);
|
|
33191
33225
|
const approvalPath = wd.path(`${ctx.alias}.attestation.approval`);
|
|
@@ -33247,14 +33281,14 @@ function buildWeightedForward(tgs, weights) {
|
|
|
33247
33281
|
}
|
|
33248
33282
|
}
|
|
33249
33283
|
if (tgs.length === 0) throw new Error("shift-traffic: no target groups to forward to");
|
|
33250
|
-
|
|
33251
|
-
|
|
33252
|
-
|
|
33253
|
-
|
|
33254
|
-
|
|
33255
|
-
|
|
33256
|
-
|
|
33257
|
-
];
|
|
33284
|
+
const resolved = tgs.map((t) => ({ TargetGroupArn: t.arn, Weight: weights[t.nonce] ?? 0 }));
|
|
33285
|
+
const total = resolved.reduce((s, t) => s + (t.Weight ?? 0), 0);
|
|
33286
|
+
if (total <= 0) {
|
|
33287
|
+
throw new Error(
|
|
33288
|
+
"shift-traffic: refusing \u2014 the resulting target-group weights sum to 0, which black-holes ALL client traffic (503). Give at least one color a positive weight."
|
|
33289
|
+
);
|
|
33290
|
+
}
|
|
33291
|
+
return [{ Type: "forward", ForwardConfig: { TargetGroups: resolved } }];
|
|
33258
33292
|
}
|
|
33259
33293
|
async function discoverColorTargetGroups(elbv2, service, stack, role) {
|
|
33260
33294
|
const all = [];
|
|
@@ -33296,13 +33330,22 @@ async function discoverColorTargetGroups(elbv2, service, stack, role) {
|
|
|
33296
33330
|
}
|
|
33297
33331
|
async function findListenerForTargetGroups(elbv2, lbArn, tgArns) {
|
|
33298
33332
|
const r5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeListenersCommand({ LoadBalancerArn: lbArn }));
|
|
33333
|
+
const matches = [];
|
|
33299
33334
|
for (const l3 of r5.Listeners ?? []) {
|
|
33300
33335
|
const forwards = (l3.DefaultActions ?? []).flatMap((a5) => a5.ForwardConfig?.TargetGroups ?? []);
|
|
33301
|
-
if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn))) {
|
|
33302
|
-
|
|
33336
|
+
if (forwards.some((t) => t.TargetGroupArn && tgArns.has(t.TargetGroupArn)) && l3.ListenerArn) {
|
|
33337
|
+
matches.push(l3.ListenerArn);
|
|
33303
33338
|
}
|
|
33304
33339
|
}
|
|
33305
|
-
|
|
33340
|
+
if (matches.length === 0) {
|
|
33341
|
+
throw new Error("shift-traffic: no listener forwards to the discovered target groups");
|
|
33342
|
+
}
|
|
33343
|
+
if (matches.length > 1) {
|
|
33344
|
+
throw new Error(
|
|
33345
|
+
`shift-traffic: ${matches.length} listeners forward to these target groups \u2014 refusing to guess which to modify`
|
|
33346
|
+
);
|
|
33347
|
+
}
|
|
33348
|
+
return matches[0];
|
|
33306
33349
|
}
|
|
33307
33350
|
async function shiftTraffic(opts) {
|
|
33308
33351
|
const role = opts.listener ?? "app";
|
|
@@ -33314,8 +33357,25 @@ async function shiftTraffic(opts) {
|
|
|
33314
33357
|
new Set(tgs.map((t) => t.arn))
|
|
33315
33358
|
);
|
|
33316
33359
|
const actions = buildWeightedForward(tgs, opts.weights);
|
|
33360
|
+
if (opts.dryRun) return { listenerArn, actions, applied: false };
|
|
33361
|
+
if (!opts.force) {
|
|
33362
|
+
const noHealthy = [];
|
|
33363
|
+
for (const t of tgs) {
|
|
33364
|
+
if ((opts.weights[t.nonce] ?? 0) <= 0) continue;
|
|
33365
|
+
const h5 = await elbv2.send(new import_client_elastic_load_balancing_v2.DescribeTargetHealthCommand({ TargetGroupArn: t.arn }));
|
|
33366
|
+
const healthy = (h5.TargetHealthDescriptions ?? []).filter(
|
|
33367
|
+
(d5) => d5.TargetHealth?.State === "healthy"
|
|
33368
|
+
).length;
|
|
33369
|
+
if (healthy === 0) noHealthy.push(t.nonce);
|
|
33370
|
+
}
|
|
33371
|
+
if (noHealthy.length > 0) {
|
|
33372
|
+
throw new Error(
|
|
33373
|
+
`shift-traffic: refusing cutover \u2014 color(s) [${noHealthy.join(", ")}] have 0 healthy targets; routing traffic to them would 503 every client. Wait for health, or pass --force.`
|
|
33374
|
+
);
|
|
33375
|
+
}
|
|
33376
|
+
}
|
|
33317
33377
|
await elbv2.send(new import_client_elastic_load_balancing_v2.ModifyListenerCommand({ ListenerArn: listenerArn, DefaultActions: actions }));
|
|
33318
|
-
return { listenerArn, actions };
|
|
33378
|
+
return { listenerArn, actions, applied: true };
|
|
33319
33379
|
}
|
|
33320
33380
|
async function shiftTrafficCommand(o3) {
|
|
33321
33381
|
const service = o3.service ?? process.env.CEREMONY_SERVICE;
|
|
@@ -33326,16 +33386,36 @@ async function shiftTrafficCommand(o3) {
|
|
|
33326
33386
|
const weights = {};
|
|
33327
33387
|
for (const [k5, v] of Object.entries(parsed)) weights[Number(k5)] = v;
|
|
33328
33388
|
const listener = o3.listener ?? "app";
|
|
33329
|
-
const
|
|
33389
|
+
const plan = await shiftTraffic({
|
|
33390
|
+
service,
|
|
33391
|
+
stack,
|
|
33392
|
+
region: o3.region,
|
|
33393
|
+
listener,
|
|
33394
|
+
weights,
|
|
33395
|
+
dryRun: true
|
|
33396
|
+
});
|
|
33397
|
+
const summary = (plan.actions[0].ForwardConfig?.TargetGroups ?? []).map((t) => `${t.TargetGroupArn?.split("/").slice(-2, -1)[0] ?? t.TargetGroupArn}=${t.Weight}`).join(" ");
|
|
33398
|
+
console.log(`shift-traffic PLAN: ${service}/${stack} ${listener} listener \u2192 ${summary}`);
|
|
33399
|
+
console.log(` listener: ${plan.listenerArn}`);
|
|
33400
|
+
if (o3.dryRun) {
|
|
33401
|
+
console.log(" (dry-run \u2014 no change applied)");
|
|
33402
|
+
return;
|
|
33403
|
+
}
|
|
33404
|
+
if (!o3.yes) {
|
|
33405
|
+
throw new Error(
|
|
33406
|
+
"shift-traffic: refusing to apply a live traffic cutover without --yes (review the PLAN above, then re-run with --yes; or use --dry-run)"
|
|
33407
|
+
);
|
|
33408
|
+
}
|
|
33409
|
+
const applied = await shiftTraffic({
|
|
33330
33410
|
service,
|
|
33331
33411
|
stack,
|
|
33332
33412
|
region: o3.region,
|
|
33333
33413
|
listener,
|
|
33334
|
-
weights
|
|
33414
|
+
weights,
|
|
33415
|
+
force: o3.force
|
|
33335
33416
|
});
|
|
33336
|
-
|
|
33337
|
-
console.log(`
|
|
33338
|
-
console.log(` listener: ${listenerArn}`);
|
|
33417
|
+
console.log(`shift-traffic APPLIED \u2192 ${summary}`);
|
|
33418
|
+
console.log(` listener: ${applied.listenerArn}`);
|
|
33339
33419
|
}
|
|
33340
33420
|
|
|
33341
33421
|
// src/cli.ts
|
|
@@ -33370,7 +33450,7 @@ withCommon(
|
|
|
33370
33450
|
program2.command("status").description("show ceremony phase + blockers (read-only)").option("--api <url>", "coordinator API base URL (env CEREMONY_API)").option("--namespace <ns>", "ceremony namespace (env CEREMONY_NAMESPACE)").option("--nonce <n>", "manifest nonce (env CEREMONY_NONCE)").action(run(status));
|
|
33371
33451
|
program2.command("shift-traffic").description(
|
|
33372
33452
|
"[ops] blue-green weighted traffic cutover across a service's colors (needs AWS creds)"
|
|
33373
|
-
).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).action(run(shiftTrafficCommand));
|
|
33453
|
+
).option("--service <name>", "service name (env CEREMONY_SERVICE)").option("--stack <stack>", "stack (env CEREMONY_STACK)").option("--region <r>", "AWS region").option("--listener <app|control>", "which listener carries the split (default app)").option("--weights <json>", `nonce->weight map, e.g. '{"2026070601":0,"2026071501":100}'`).option("--dry-run", "print the planned weighted forward and exit without applying").option("--yes", "confirm applying the live traffic cutover (required unless --dry-run)").option("--force", "skip the target-health precheck (allow shifting to a 0-healthy color)").action(run(shiftTrafficCommand));
|
|
33374
33454
|
program2.parseAsync(process.argv).catch((e5) => {
|
|
33375
33455
|
console.error(e5);
|
|
33376
33456
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umbra-privacy/qos-ceremony",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "QOS TEE operator ceremony CLI — keygen/share/approve/provision + shift-traffic over the coordinator API. All crypto runs in qos_client (embedded for common platforms); your private key never leaves the device. Ships NO coordinator code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qos",
|