@tutti-os/nexight-devops 0.6.0 → 0.6.2
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 +6 -1
- package/dist/cli.js +238 -68
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,6 +48,10 @@ nexight-devops release hotfix
|
|
|
48
48
|
nexight-devops release approve
|
|
49
49
|
nexight-devops release rollback
|
|
50
50
|
nexight-devops release rollback tutti-server
|
|
51
|
+
nexight-devops release rollback platform-web
|
|
52
|
+
nexight-devops release rollback platform-web --previous --yes --reason "entry failure"
|
|
53
|
+
nexight-devops release rollback platform-web --version-id <uuid> --yes
|
|
54
|
+
nexight-devops release rollback platform-web --probe
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
### Function overview
|
|
@@ -66,7 +70,8 @@ nexight-devops release rollback tutti-server
|
|
|
66
70
|
- `ppe-lane destroy` checks existing lane instances and triggers destroy workflows after a lane-name confirmation. `tutti-server` must be selected explicitly for destruction.
|
|
67
71
|
- `ppe-lane status` queries AWS Cloud Map for one lane, or all lanes with `--all`, including `tutti-server`.
|
|
68
72
|
- `release prepare`, `release hotfix`, and `release approve` run the controlled release lifecycle through GitHub Actions and approval issues.
|
|
69
|
-
- `release rollback` lets you choose `tutti-server`
|
|
73
|
+
- `release rollback` lets you choose `tutti-server` or `platform-web` interactively. For `tutti-server`, the command reads the current production image and up to ten recent Helm deployments directly when local cluster read access is available, otherwise through GitHub Actions. The selected image is revalidated and restored by GitHub Actions, followed by health checks. Use `--revision <number> --yes` for scripted selection.
|
|
74
|
+
- `release rollback platform-web` restores a retained, previously deployed Cloudflare Worker version through Tutti's lightweight `platform-web-rollback.yml`. It does not build, test, install packages, upload assets, or roll back ingress/storage. The interactive flow binds your confirmation to the displayed deployment ID; if production changes, execution refuses the stale selection. `--previous --yes` starts only one workflow and selects the latest eligible previous version from live history under the writer lock, excluding versions withdrawn by earlier recorded rollbacks. `--version-id <uuid> --yes` explicitly selects a previously deployed version, including a withdrawn version. `--yes` skips confirmation only. `--reason` records up to 200 UTF-8 bytes; missing historical SHA/run mappings remain unknown. Results distinguish deployment switching, public entry/assets, version/content identity, and unverified Room behavior, and include queue/setup, switch, verification, and total timings. `--probe` measures the same read-only production checks without changing traffic. The CLI needs GitHub access; Cloudflare credentials remain in the production environment's GitHub Secrets.
|
|
70
75
|
|
|
71
76
|
## Controlled Release
|
|
72
77
|
|
package/dist/cli.js
CHANGED
|
@@ -6,17 +6,6 @@ import { resolve as resolve4 } from "path";
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "url";
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
|
|
9
|
-
// src/constants/admin.ts
|
|
10
|
-
var DEFAULT_ADMIN_API_ORIGIN = "https://tutti.sh";
|
|
11
|
-
|
|
12
|
-
// src/lib/admin-computer.ts
|
|
13
|
-
import { randomUUID } from "crypto";
|
|
14
|
-
import { createWriteStream } from "fs";
|
|
15
|
-
import { link, mkdir, rename, stat, unlink } from "fs/promises";
|
|
16
|
-
import { basename, dirname, join, resolve } from "path";
|
|
17
|
-
import { Readable } from "stream";
|
|
18
|
-
import { pipeline } from "stream/promises";
|
|
19
|
-
|
|
20
9
|
// src/lib/errors.ts
|
|
21
10
|
var CliError = class extends Error {
|
|
22
11
|
exitCode;
|
|
@@ -45,7 +34,36 @@ var toExitCode = (error) => {
|
|
|
45
34
|
return 1;
|
|
46
35
|
};
|
|
47
36
|
|
|
37
|
+
// src/lib/ppe-lane.ts
|
|
38
|
+
var PPE_LANE_MAX_LENGTH = 38;
|
|
39
|
+
var PPE_LANE_PATTERN = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
|
|
40
|
+
var validatePpeLaneName = (value) => {
|
|
41
|
+
const lane = value.trim();
|
|
42
|
+
if (!lane) {
|
|
43
|
+
throw new CliError("PPE lane name is required", 1);
|
|
44
|
+
}
|
|
45
|
+
if (lane.length > PPE_LANE_MAX_LENGTH) {
|
|
46
|
+
throw new CliError(`PPE lane name must be ${PPE_LANE_MAX_LENGTH} characters or fewer`, 1);
|
|
47
|
+
}
|
|
48
|
+
if (!PPE_LANE_PATTERN.test(lane)) {
|
|
49
|
+
throw new CliError(
|
|
50
|
+
"PPE lane name must use lowercase letters, digits, or hyphens, and start and end with a letter or digit",
|
|
51
|
+
1
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return lane;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/constants/admin.ts
|
|
58
|
+
var DEFAULT_ADMIN_API_ORIGIN = "https://tutti.sh";
|
|
59
|
+
|
|
48
60
|
// src/lib/admin-computer.ts
|
|
61
|
+
import { randomUUID } from "crypto";
|
|
62
|
+
import { createWriteStream } from "fs";
|
|
63
|
+
import { link, mkdir, rename, stat, unlink } from "fs/promises";
|
|
64
|
+
import { basename, dirname, join, resolve } from "path";
|
|
65
|
+
import { Readable } from "stream";
|
|
66
|
+
import { pipeline } from "stream/promises";
|
|
49
67
|
var TUTTI_COMPUTER_API_BASE_PATH = "/api/desktop/v2/admin/tutti-v2/computers";
|
|
50
68
|
var TUTTI_COMPUTER_CONNECTION_WIRE_VALUE = {
|
|
51
69
|
online: 1,
|
|
@@ -127,6 +145,12 @@ var parseTuttiComputer = (value) => {
|
|
|
127
145
|
const owner = objectValue(root.owner);
|
|
128
146
|
return {
|
|
129
147
|
computerId: stringField(root, "computerId", "computer_id"),
|
|
148
|
+
...field(root, "canCollect", "can_collect") !== void 0 ? {
|
|
149
|
+
canCollect: booleanField(root, "canCollect", "can_collect"),
|
|
150
|
+
collectionBlockedReason: stringField(root, "collectionBlockedReason", "collection_blocked_reason"),
|
|
151
|
+
computerKind: stringField(root, "computerKind", "computer_kind"),
|
|
152
|
+
computerVersion: stringField(root, "computerVersion", "computer_version")
|
|
153
|
+
} : {},
|
|
130
154
|
displayName: stringField(root, "displayName", "display_name"),
|
|
131
155
|
reportedName: stringField(root, "reportedName", "reported_name"),
|
|
132
156
|
owner: {
|
|
@@ -356,6 +380,9 @@ var AdminApi = class {
|
|
|
356
380
|
constructor(runtime, fetchImpl = fetch) {
|
|
357
381
|
this.runtime = runtime;
|
|
358
382
|
this.fetchImpl = fetchImpl;
|
|
383
|
+
if (runtime.ppeLane !== void 0) {
|
|
384
|
+
this.runtime = { ...runtime, ppeLane: validatePpeLaneName(runtime.ppeLane) };
|
|
385
|
+
}
|
|
359
386
|
}
|
|
360
387
|
async request(method, path, body) {
|
|
361
388
|
const headers = {
|
|
@@ -364,6 +391,9 @@ var AdminApi = class {
|
|
|
364
391
|
if (this.runtime.token) {
|
|
365
392
|
headers.authorization = `Bearer ${this.runtime.token}`;
|
|
366
393
|
}
|
|
394
|
+
if (this.runtime.ppeLane) {
|
|
395
|
+
headers["x-zk-ppe-lane"] = this.runtime.ppeLane;
|
|
396
|
+
}
|
|
367
397
|
let payload;
|
|
368
398
|
if (body !== void 0) {
|
|
369
399
|
headers["content-type"] = "application/json";
|
|
@@ -372,9 +402,22 @@ var AdminApi = class {
|
|
|
372
402
|
const response = await this.fetchImpl(`${this.runtime.apiOrigin}${path}`, {
|
|
373
403
|
method,
|
|
374
404
|
headers,
|
|
375
|
-
body: payload
|
|
405
|
+
body: payload,
|
|
406
|
+
...this.runtime.ppeLane ? { redirect: "error" } : {}
|
|
376
407
|
});
|
|
377
408
|
const parsed = await parseResponseBody(response);
|
|
409
|
+
if (this.runtime.ppeLane) {
|
|
410
|
+
const hit = response.headers.get("x-zk-ppe-lane-hit");
|
|
411
|
+
const fallback = response.headers.get("x-tutti-server-route-fallback");
|
|
412
|
+
if (hit !== this.runtime.ppeLane || fallback) {
|
|
413
|
+
const detail = adminApiErrorDetail(parsed);
|
|
414
|
+
throw new AdminApiError(
|
|
415
|
+
`PPE \u8DEF\u7531\u4E0D\u5339\u914D: \u8BF7\u6C42 ${this.runtime.ppeLane}\uFF0C\u5B9E\u9645 ${hit || "\u672A\u786E\u8BA4\u6CF3\u9053\uFF08\u6B63\u5F0F\u73AF\u5883\u56DE\u843D\u6216\u7F3A\u5C11\u547D\u4E2D\u54CD\u5E94\u5934\uFF09"}${fallback ? `\uFF0CServer \u56DE\u843D\u539F\u56E0 ${fallback}` : ""}; ${method} ${path}; HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
|
|
416
|
+
response.status,
|
|
417
|
+
parsed
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
378
421
|
if (!response.ok) {
|
|
379
422
|
const detail = adminApiErrorDetail(parsed);
|
|
380
423
|
throw new AdminApiError(
|
|
@@ -658,7 +701,7 @@ var logger = {
|
|
|
658
701
|
};
|
|
659
702
|
|
|
660
703
|
// src/commands/admin/shared.ts
|
|
661
|
-
var addAdminConnectionOptions = (command) => command.option("--admin-url <url>", "management API URL or admin.tutti.sh page URL").option("--json", "print the response as JSON without CLI decorations");
|
|
704
|
+
var addAdminConnectionOptions = (command) => command.option("--admin-url <url>", "management API URL or admin.tutti.sh page URL").option("--ppe-lane <lane>", "route every management request to this exact PPE lane").option("--json", "print the response as JSON without CLI decorations");
|
|
662
705
|
var resolveAdminContext = async (options = {}, tokenOverride) => {
|
|
663
706
|
const config = await loadConfig();
|
|
664
707
|
const apiOrigin = normalizeAdminApiOrigin(options.adminUrl ?? config.admin?.apiOrigin);
|
|
@@ -667,7 +710,7 @@ var resolveAdminContext = async (options = {}, tokenOverride) => {
|
|
|
667
710
|
throw new CliError("\u7BA1\u7406\u540E\u53F0 Token \u672A\u914D\u7F6E\uFF0C\u8BF7\u5148\u8FD0\u884C nexight-devops admin auth login", 1);
|
|
668
711
|
}
|
|
669
712
|
return {
|
|
670
|
-
api: new AdminApi({ apiOrigin, token }),
|
|
713
|
+
api: new AdminApi({ apiOrigin, token, ppeLane: options.ppeLane }),
|
|
671
714
|
apiOrigin,
|
|
672
715
|
config,
|
|
673
716
|
token
|
|
@@ -852,6 +895,14 @@ var resolveRuntimeLogRecallSelection = (input2, now = /* @__PURE__ */ new Date()
|
|
|
852
895
|
var isUUID = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
853
896
|
value
|
|
854
897
|
);
|
|
898
|
+
var assertComputerCollectable = (computer) => {
|
|
899
|
+
if (computer.canCollect === false) {
|
|
900
|
+
throw new CliError(`Computer ${computer.computerId} \u65E0\u6CD5\u91C7\u96C6\u65E5\u5FD7: ${computer.collectionBlockedReason || "\u670D\u52A1\u7AEF\u672A\u63D0\u4F9B\u963B\u585E\u539F\u56E0"}`, 1);
|
|
901
|
+
}
|
|
902
|
+
if (computer.canCollect === void 0 && computer.connectionStatus !== "online") {
|
|
903
|
+
throw new CliError(`Computer ${computer.computerId} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u53EC\u56DE\u65E5\u5FD7`, 1);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
855
906
|
var computerNameMatches = (computer, selector) => {
|
|
856
907
|
const expected = selector.trim().toLowerCase();
|
|
857
908
|
return [computer.displayName, computer.reportedName].map((value) => value.trim().toLowerCase()).includes(expected);
|
|
@@ -863,9 +914,7 @@ var resolveOnlineTuttiComputer = async (api, selector) => {
|
|
|
863
914
|
}
|
|
864
915
|
if (isUUID(normalized)) {
|
|
865
916
|
const computer = await api.getTuttiComputer(normalized);
|
|
866
|
-
|
|
867
|
-
throw new CliError(`Computer ${normalized} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u53EC\u56DE\u65E5\u5FD7`, 1);
|
|
868
|
-
}
|
|
917
|
+
assertComputerCollectable(computer);
|
|
869
918
|
return computer;
|
|
870
919
|
}
|
|
871
920
|
const matches = [];
|
|
@@ -977,6 +1026,7 @@ var recallComputerRuntimeLogs = async (input2, dependencies) => {
|
|
|
977
1026
|
const selector = input2.ownerQuery ? `\u7528\u6237\u540D/\u90AE\u7BB1 ${input2.ownerQuery}` : `Computer ${input2.computer}`;
|
|
978
1027
|
progress(`\u6B63\u5728\u89E3\u6790\u552F\u4E00\u5728\u7EBF Computer: ${selector}`);
|
|
979
1028
|
const computer = await resolveOnlineTuttiComputerForLogs(dependencies.api, input2);
|
|
1029
|
+
assertComputerCollectable(computer);
|
|
980
1030
|
progress(
|
|
981
1031
|
`\u5DF2\u5339\u914D ${computer.displayName || computer.reportedName} (${computer.computerId})\uFF0Cdesktopd ${computer.desktopdVersion || "\u672A\u77E5"}`
|
|
982
1032
|
);
|
|
@@ -3142,6 +3192,9 @@ var matchesWorkflowRunCorrelation = (run, correlation) => {
|
|
|
3142
3192
|
if (correlation.actorLogin && run.actor?.login !== correlation.actorLogin) {
|
|
3143
3193
|
return false;
|
|
3144
3194
|
}
|
|
3195
|
+
if (correlation.runName && run.display_title !== correlation.runName) {
|
|
3196
|
+
return false;
|
|
3197
|
+
}
|
|
3145
3198
|
if (!isRunCreatedAtOrAfter(run.created_at, correlation.createdAt)) {
|
|
3146
3199
|
return false;
|
|
3147
3200
|
}
|
|
@@ -3280,26 +3333,6 @@ var pollWorkflowRun = async (input2) => {
|
|
|
3280
3333
|
throw new CliError(`Timed out waiting for workflow run in ${input2.repoFullName}`, 1);
|
|
3281
3334
|
};
|
|
3282
3335
|
|
|
3283
|
-
// src/lib/ppe-lane.ts
|
|
3284
|
-
var PPE_LANE_MAX_LENGTH = 38;
|
|
3285
|
-
var PPE_LANE_PATTERN = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
|
|
3286
|
-
var validatePpeLaneName = (value) => {
|
|
3287
|
-
const lane = value.trim();
|
|
3288
|
-
if (!lane) {
|
|
3289
|
-
throw new CliError("PPE lane name is required", 1);
|
|
3290
|
-
}
|
|
3291
|
-
if (lane.length > PPE_LANE_MAX_LENGTH) {
|
|
3292
|
-
throw new CliError(`PPE lane name must be ${PPE_LANE_MAX_LENGTH} characters or fewer`, 1);
|
|
3293
|
-
}
|
|
3294
|
-
if (!PPE_LANE_PATTERN.test(lane)) {
|
|
3295
|
-
throw new CliError(
|
|
3296
|
-
"PPE lane name must use lowercase letters, digits, or hyphens, and start and end with a letter or digit",
|
|
3297
|
-
1
|
|
3298
|
-
);
|
|
3299
|
-
}
|
|
3300
|
-
return lane;
|
|
3301
|
-
};
|
|
3302
|
-
|
|
3303
3336
|
// src/lib/prerequisites.ts
|
|
3304
3337
|
var ensureCommandInstalled = async (command, installHint) => {
|
|
3305
3338
|
const result = await runCommand(command, ["--version"]);
|
|
@@ -4148,9 +4181,9 @@ var registerReleasePrepareCommand = (program) => {
|
|
|
4148
4181
|
};
|
|
4149
4182
|
|
|
4150
4183
|
// src/commands/release/rollback.ts
|
|
4151
|
-
import { mkdtemp as
|
|
4152
|
-
import { tmpdir } from "os";
|
|
4153
|
-
import { join as
|
|
4184
|
+
import { mkdtemp as mkdtemp3, readFile as readFile3, rm as rm2 } from "fs/promises";
|
|
4185
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
4186
|
+
import { join as join6 } from "path";
|
|
4154
4187
|
|
|
4155
4188
|
// src/constants/rollback-targets.ts
|
|
4156
4189
|
var tuttiServerRollbackTarget = {
|
|
@@ -4158,7 +4191,12 @@ var tuttiServerRollbackTarget = {
|
|
|
4158
4191
|
label: "tutti-server",
|
|
4159
4192
|
repoFullName: "tutti-lab/tutti-server"
|
|
4160
4193
|
};
|
|
4161
|
-
var
|
|
4194
|
+
var platformWebRollbackTarget = {
|
|
4195
|
+
key: "platform-web",
|
|
4196
|
+
label: "platform-web",
|
|
4197
|
+
repoFullName: "tutti-lab/tutti"
|
|
4198
|
+
};
|
|
4199
|
+
var rollbackTargets = [tuttiServerRollbackTarget, platformWebRollbackTarget];
|
|
4162
4200
|
|
|
4163
4201
|
// src/commands/release/rollback-local.ts
|
|
4164
4202
|
var CLUSTER = "arn:aws:eks:us-east-1:250509935467:cluster/nexight-ojo-us-east-1";
|
|
@@ -4246,9 +4284,137 @@ var fetchLocalRollbackCatalog = async () => {
|
|
|
4246
4284
|
return buildLocalRollbackCatalog(history, deployment, manifests);
|
|
4247
4285
|
};
|
|
4248
4286
|
|
|
4287
|
+
// src/commands/release/rollback-web.ts
|
|
4288
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4289
|
+
import { mkdtemp as mkdtemp2, readFile as readFile2, rm } from "fs/promises";
|
|
4290
|
+
import { tmpdir } from "os";
|
|
4291
|
+
import { join as join5 } from "path";
|
|
4292
|
+
var REPO = platformWebRollbackTarget.repoFullName;
|
|
4293
|
+
var WORKFLOW = "platform-web-rollback.yml";
|
|
4294
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
4295
|
+
var SHA = /^[0-9a-f]{40}$/;
|
|
4296
|
+
var choiceTime = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Singapore", dateStyle: "short", timeStyle: "short" });
|
|
4297
|
+
var validDeployment = (value) => UUID.test(value?.id) && Array.isArray(value.versions) && value.versions.length > 0 && value.versions.every((item) => UUID.test(item.version_id) && Number.isFinite(item.percentage) && item.percentage > 0 && item.percentage <= 100) && Math.abs(value.versions.reduce((sum, item) => sum + item.percentage, 0) - 100) < 1e-3;
|
|
4298
|
+
var parseWebRollbackCatalog = (raw) => {
|
|
4299
|
+
const data = JSON.parse(raw);
|
|
4300
|
+
if (data.schema !== "tutti.web.rollback.catalog.v1" || data.environment !== "production" || !validDeployment(data.currentDeployment) || !Array.isArray(data.candidates) || data.candidates.length > 100) {
|
|
4301
|
+
throw new CliError("Invalid Platform Web rollback catalog", 1);
|
|
4302
|
+
}
|
|
4303
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4304
|
+
const active = new Set(data.currentDeployment.versions.map((item) => item.version_id));
|
|
4305
|
+
for (const candidate of data.candidates) {
|
|
4306
|
+
if (!UUID.test(candidate.versionId) || !UUID.test(candidate.deploymentId) || !Number.isFinite(Date.parse(candidate.deployedAt)) || typeof candidate.current !== "boolean" || candidate.current !== active.has(candidate.versionId) || typeof candidate.withdrawn !== "boolean" || typeof candidate.fullTraffic !== "boolean" || !(candidate.sha === null || SHA.test(candidate.sha)) || !(candidate.runId === null || /^\d+$/.test(candidate.runId)) || seen.has(candidate.versionId)) {
|
|
4307
|
+
throw new CliError("Invalid Platform Web deployment in rollback catalog", 1);
|
|
4308
|
+
}
|
|
4309
|
+
seen.add(candidate.versionId);
|
|
4310
|
+
}
|
|
4311
|
+
return data;
|
|
4312
|
+
};
|
|
4313
|
+
var buildWebRollbackChoices = (catalog) => catalog.candidates.filter((candidate) => !candidate.current && !candidate.withdrawn && candidate.fullTraffic).map((candidate) => ({
|
|
4314
|
+
name: `${choiceTime.format(new Date(candidate.deployedAt))} SGT ${candidate.versionId.slice(0, 8)} ${candidate.sha?.slice(0, 12) ?? "SHA unknown"}${candidate.runId ? ` run ${candidate.runId}` : ""}`,
|
|
4315
|
+
value: candidate.versionId
|
|
4316
|
+
}));
|
|
4317
|
+
var gh = async (args) => {
|
|
4318
|
+
const result = await runCommand("gh", args);
|
|
4319
|
+
if (result.exitCode !== 0) throw new CliError(result.stderr.trim() || result.stdout.trim() || "GitHub command failed", 1);
|
|
4320
|
+
return result.stdout;
|
|
4321
|
+
};
|
|
4322
|
+
var dispatch = async (operation, inputs = {}) => {
|
|
4323
|
+
const operationId = randomUUID3();
|
|
4324
|
+
const requestedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4325
|
+
const knownRunIds = await listWorkflowRunIds({ repoFullName: REPO, workflowFile: WORKFLOW, ref: "main" });
|
|
4326
|
+
const fields = { operation, operation_id: operationId, requested_at: requestedAt, ...inputs };
|
|
4327
|
+
await gh(["workflow", "run", WORKFLOW, "--repo", REPO, "--ref", "main", ...Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`])]);
|
|
4328
|
+
const progress = ui.spinner(operation === "execute" ? "\u7B49\u5F85 Platform Web \u56DE\u6EDA\u548C\u5165\u53E3\u9A8C\u8BC1..." : "\u8BFB\u53D6 Platform Web \u751F\u4EA7\u72B6\u6001...").start();
|
|
4329
|
+
try {
|
|
4330
|
+
const run = await pollWorkflowRun({
|
|
4331
|
+
knownRunIds,
|
|
4332
|
+
repoFullName: REPO,
|
|
4333
|
+
workflowFile: WORKFLOW,
|
|
4334
|
+
ref: "main",
|
|
4335
|
+
intervalMs: 1e3,
|
|
4336
|
+
maxAttempts: 600,
|
|
4337
|
+
correlation: { runName: `Platform Web Rollback / ${operation} / ${operationId}`, createdAt: requestedAt },
|
|
4338
|
+
onUpdate: (update) => {
|
|
4339
|
+
if ("htmlUrl" in update) progress.text = update.htmlUrl;
|
|
4340
|
+
}
|
|
4341
|
+
});
|
|
4342
|
+
const directory = await mkdtemp2(join5(tmpdir(), "platform-web-rollback-"));
|
|
4343
|
+
try {
|
|
4344
|
+
try {
|
|
4345
|
+
await gh(["run", "download", String(run.runId), "--repo", REPO, "--name", "platform-web-rollback", "--dir", directory]);
|
|
4346
|
+
} catch (error) {
|
|
4347
|
+
throw new CliError(`Could not retrieve operation outcome; inspect actual production state at ${run.htmlUrl} before retrying: ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
4348
|
+
}
|
|
4349
|
+
const result = JSON.parse(await readFile2(join5(directory, "rollback-result.json"), "utf8"));
|
|
4350
|
+
if (result.schema !== "tutti.web.rollback.result.v1" || result.operationId !== operationId || !["success", "failure", "pending_verification"].includes(result.status) || !["unchanged", "switched", "unknown"].includes(result.deployment) || !result.verification || !result.timings || Object.values(result.timings).some((value) => !Number.isFinite(value) || value < 0) || result.actual && !validDeployment(result.actual)) {
|
|
4351
|
+
throw new CliError(`Invalid rollback result: ${run.htmlUrl}`, 1);
|
|
4352
|
+
}
|
|
4353
|
+
if (run.conclusion !== "success" || result.status !== "success") {
|
|
4354
|
+
const state = result.actual ? `${result.actual.id} / ${JSON.stringify(result.actual.versions)}` : "unknown";
|
|
4355
|
+
throw new CliError(`Platform Web operation failed; deployment ${result.deployment}
|
|
4356
|
+
Actual production: ${state}
|
|
4357
|
+
${result.error ?? run.failureMessage ?? run.conclusion}
|
|
4358
|
+
${run.htmlUrl}`, 1);
|
|
4359
|
+
}
|
|
4360
|
+
if (operation === "execute" && (result.deployment !== "switched" || !result.actual || !UUID.test(result.targetVersionId ?? "") || result.actual.versions.length !== 1 || result.actual.versions[0]?.version_id !== result.targetVersionId || result.actual.versions[0]?.percentage !== 100)) {
|
|
4361
|
+
throw new CliError(`Rollback did not confirm target version at 100%: ${run.htmlUrl}`, 1);
|
|
4362
|
+
}
|
|
4363
|
+
const catalog = operation === "list" ? parseWebRollbackCatalog(await readFile2(join5(directory, "rollback-catalog.json"), "utf8")) : void 0;
|
|
4364
|
+
return { run, result, catalog };
|
|
4365
|
+
} catch (error) {
|
|
4366
|
+
if (error instanceof CliError) throw error;
|
|
4367
|
+
throw new CliError(`Could not read actual operation state; inspect ${run.htmlUrl} before retrying: ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
4368
|
+
} finally {
|
|
4369
|
+
await rm(directory, { recursive: true, force: true });
|
|
4370
|
+
}
|
|
4371
|
+
} finally {
|
|
4372
|
+
progress.stop();
|
|
4373
|
+
}
|
|
4374
|
+
};
|
|
4375
|
+
var runPlatformWebRollback = async (options) => {
|
|
4376
|
+
if (options.previous && options.versionId || options.probe && (options.previous || options.versionId)) throw new CliError("Choose only one of --previous, --version-id, or --probe", 1);
|
|
4377
|
+
if (options.versionId && !UUID.test(options.versionId)) throw new CliError("--version-id must be a Worker version UUID", 1);
|
|
4378
|
+
if (options.reason && Buffer.byteLength(options.reason) > 200) throw new CliError("--reason must be at most 200 UTF-8 bytes", 1);
|
|
4379
|
+
await ensurePrerequisites({ needsGh: true });
|
|
4380
|
+
let inputs = { reason: options.reason ?? "operator rollback" };
|
|
4381
|
+
if (options.probe) {
|
|
4382
|
+
const { run: run2, result: result2 } = await dispatch("probe");
|
|
4383
|
+
logger.info(`Read-only production probe: ${JSON.stringify(result2.timings)}`);
|
|
4384
|
+
logger.info(`Verification: ${JSON.stringify(result2.verification)}`);
|
|
4385
|
+
logger.info(run2.htmlUrl);
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4388
|
+
if (options.yes && (options.previous || options.versionId)) {
|
|
4389
|
+
inputs = { ...inputs, previous: String(Boolean(options.previous)), version_id: options.versionId ?? "" };
|
|
4390
|
+
} else {
|
|
4391
|
+
const { catalog } = await dispatch("list", options.versionId ? { version_id: options.versionId } : {});
|
|
4392
|
+
if (!catalog) throw new CliError("Missing Platform Web rollback catalog", 1);
|
|
4393
|
+
const choices = buildWebRollbackChoices(catalog);
|
|
4394
|
+
if (!options.versionId && choices.length === 0) throw new CliError("No eligible recent production Web version is available", 1);
|
|
4395
|
+
const versionId = options.versionId ?? (options.previous ? choices[0]?.value : await ui.select({ message: "Select a production Web version to restore", choices, pageSize: Math.min(choices.length, 15) }));
|
|
4396
|
+
const selected = catalog.candidates.find((candidate) => candidate.versionId === versionId && !candidate.current);
|
|
4397
|
+
if (!selected) throw new CliError("No eligible recent production Web version is available", 1);
|
|
4398
|
+
logger.info(`Current deployment: ${catalog.currentDeployment.id}`);
|
|
4399
|
+
logger.info(`Target version: ${selected.versionId} / SHA ${selected.sha ?? "unknown"}`);
|
|
4400
|
+
if (selected.runId) logger.info(`Published by: https://github.com/${REPO}/actions/runs/${selected.runId}`);
|
|
4401
|
+
if (selected.withdrawn) logger.warn("This version was previously withdrawn by a rollback.");
|
|
4402
|
+
if (!options.yes && !await ui.confirm({ message: "Restore this Web version to production?", default: false })) {
|
|
4403
|
+
logger.warn("Rollback cancelled");
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4406
|
+
inputs = { ...inputs, previous: "false", version_id: selected.versionId, expected_deployment_id: catalog.currentDeployment.id };
|
|
4407
|
+
}
|
|
4408
|
+
const { run, result } = await dispatch("execute", inputs);
|
|
4409
|
+
logger.success(`Platform Web switched to ${result.targetVersionId} at 100% traffic`);
|
|
4410
|
+
logger.info(`Verification: ${JSON.stringify(result.verification)}`);
|
|
4411
|
+
logger.info(`Timing (ms): ${JSON.stringify(result.timings)}`);
|
|
4412
|
+
logger.info(run.htmlUrl);
|
|
4413
|
+
};
|
|
4414
|
+
|
|
4249
4415
|
// src/commands/release/rollback.ts
|
|
4250
|
-
var
|
|
4251
|
-
var
|
|
4416
|
+
var REPO2 = tuttiServerRollbackTarget.repoFullName;
|
|
4417
|
+
var WORKFLOW2 = "rollback.yml";
|
|
4252
4418
|
var SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
4253
4419
|
var SINGAPORE_TIME = new Intl.DateTimeFormat("sv-SE", {
|
|
4254
4420
|
timeZone: "Asia/Singapore",
|
|
@@ -4272,7 +4438,7 @@ var buildRollbackChoices = (catalog) => catalog.candidates.filter((candidate) =>
|
|
|
4272
4438
|
name: `${displayChoiceTime(candidate.deployedAt)} SGT #${candidate.revision} ${candidate.version}`,
|
|
4273
4439
|
value: candidate.revision
|
|
4274
4440
|
}));
|
|
4275
|
-
var
|
|
4441
|
+
var gh2 = async (args) => {
|
|
4276
4442
|
const result = await runCommand("gh", args);
|
|
4277
4443
|
if (result.exitCode !== 0) {
|
|
4278
4444
|
throw new CliError(result.stderr.trim() || result.stdout.trim() || `gh ${args[0]} failed`, 1);
|
|
@@ -4292,8 +4458,8 @@ var parseRollbackCatalog = (raw) => {
|
|
|
4292
4458
|
return data;
|
|
4293
4459
|
};
|
|
4294
4460
|
var listActiveDeployments = async () => {
|
|
4295
|
-
const runs = await Promise.all(["deploy.yml",
|
|
4296
|
-
const raw = await
|
|
4461
|
+
const runs = await Promise.all(["deploy.yml", WORKFLOW2].map(async (workflow) => {
|
|
4462
|
+
const raw = await gh2(["api", `repos/${REPO2}/actions/workflows/${workflow}/runs?per_page=30`]);
|
|
4297
4463
|
return JSON.parse(raw).workflow_runs;
|
|
4298
4464
|
}));
|
|
4299
4465
|
return runs.flat().filter(
|
|
@@ -4306,19 +4472,19 @@ var ensureNoActiveDeployment = async () => {
|
|
|
4306
4472
|
throw new CliError(`Production deployment is already running: ${active[0]?.html_url}`, 1);
|
|
4307
4473
|
}
|
|
4308
4474
|
};
|
|
4309
|
-
var
|
|
4310
|
-
const knownRunIds = await listWorkflowRunIds({ repoFullName:
|
|
4311
|
-
const args = ["workflow", "run",
|
|
4475
|
+
var dispatch2 = async (operation, inputs = {}) => {
|
|
4476
|
+
const knownRunIds = await listWorkflowRunIds({ repoFullName: REPO2, workflowFile: WORKFLOW2, ref: "main" });
|
|
4477
|
+
const args = ["workflow", "run", WORKFLOW2, "--repo", REPO2, "--ref", "main", "-f", `operation=${operation}`];
|
|
4312
4478
|
for (const [key, value] of Object.entries(inputs)) {
|
|
4313
4479
|
args.push("-f", `${key}=${value}`);
|
|
4314
4480
|
}
|
|
4315
|
-
await
|
|
4481
|
+
await gh2(args);
|
|
4316
4482
|
const progress = ui.spinner(operation === "list" ? "\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2..." : "\u7B49\u5F85\u751F\u4EA7\u56DE\u6EDA\u548C\u5065\u5EB7\u68C0\u67E5...").start();
|
|
4317
4483
|
try {
|
|
4318
4484
|
const run = await pollWorkflowRun({
|
|
4319
4485
|
knownRunIds,
|
|
4320
|
-
repoFullName:
|
|
4321
|
-
workflowFile:
|
|
4486
|
+
repoFullName: REPO2,
|
|
4487
|
+
workflowFile: WORKFLOW2,
|
|
4322
4488
|
ref: "main",
|
|
4323
4489
|
maxAttempts: operation === "execute" ? 420 : 180,
|
|
4324
4490
|
onUpdate: (update) => {
|
|
@@ -4328,15 +4494,15 @@ var dispatch = async (operation, inputs = {}) => {
|
|
|
4328
4494
|
if (run.conclusion !== "success") {
|
|
4329
4495
|
let state = "";
|
|
4330
4496
|
if (operation === "execute") {
|
|
4331
|
-
const dir = await
|
|
4497
|
+
const dir = await mkdtemp3(join6(tmpdir2(), "tutti-rollback-state-"));
|
|
4332
4498
|
try {
|
|
4333
|
-
await
|
|
4499
|
+
await gh2(["run", "download", String(run.runId), "--repo", REPO2, "--name", "rollback-state", "--dir", dir]);
|
|
4334
4500
|
state = `
|
|
4335
|
-
${await
|
|
4501
|
+
${await readFile3(join6(dir, "rollback-state.txt"), "utf8")}`;
|
|
4336
4502
|
} catch {
|
|
4337
4503
|
state = "\nActual production state is available in the workflow logs.";
|
|
4338
4504
|
} finally {
|
|
4339
|
-
await
|
|
4505
|
+
await rm2(dir, { recursive: true, force: true });
|
|
4340
4506
|
}
|
|
4341
4507
|
}
|
|
4342
4508
|
throw new CliError(`Workflow failed: ${run.failureMessage ?? run.conclusion}
|
|
@@ -4348,12 +4514,12 @@ ${run.htmlUrl}${state}`, 1);
|
|
|
4348
4514
|
}
|
|
4349
4515
|
};
|
|
4350
4516
|
var fetchCatalog = async (runId) => {
|
|
4351
|
-
const dir = await
|
|
4517
|
+
const dir = await mkdtemp3(join6(tmpdir2(), "tutti-rollback-"));
|
|
4352
4518
|
try {
|
|
4353
|
-
await
|
|
4354
|
-
return parseRollbackCatalog(await
|
|
4519
|
+
await gh2(["run", "download", String(runId), "--repo", REPO2, "--name", "rollback-catalog", "--dir", dir]);
|
|
4520
|
+
return parseRollbackCatalog(await readFile3(join6(dir, "rollback-catalog.json"), "utf8"));
|
|
4355
4521
|
} finally {
|
|
4356
|
-
await
|
|
4522
|
+
await rm2(dir, { recursive: true, force: true });
|
|
4357
4523
|
}
|
|
4358
4524
|
};
|
|
4359
4525
|
var readCatalog = async () => {
|
|
@@ -4365,7 +4531,7 @@ var readCatalog = async () => {
|
|
|
4365
4531
|
progress.stop();
|
|
4366
4532
|
}
|
|
4367
4533
|
logger.warn("\u672C\u673A\u65E0\u6CD5\u5B8C\u6574\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2\uFF0C\u6539\u7528 GitHub Actions \u67E5\u8BE2\u3002");
|
|
4368
|
-
const listRun = await
|
|
4534
|
+
const listRun = await dispatch2("list");
|
|
4369
4535
|
return fetchCatalog(listRun.runId);
|
|
4370
4536
|
};
|
|
4371
4537
|
var runTuttiRollback = async (options) => {
|
|
@@ -4405,7 +4571,7 @@ var runTuttiRollback = async (options) => {
|
|
|
4405
4571
|
return;
|
|
4406
4572
|
}
|
|
4407
4573
|
await ensureNoActiveDeployment();
|
|
4408
|
-
const run = await
|
|
4574
|
+
const run = await dispatch2("execute", {
|
|
4409
4575
|
revision: String(selected.revision),
|
|
4410
4576
|
digest: selected.digest,
|
|
4411
4577
|
expected_current_digest: catalog.currentDigest,
|
|
@@ -4415,14 +4581,18 @@ var runTuttiRollback = async (options) => {
|
|
|
4415
4581
|
logger.info(run.htmlUrl);
|
|
4416
4582
|
};
|
|
4417
4583
|
var registerReleaseRollbackCommand = (program) => {
|
|
4418
|
-
program.command("rollback").description("Restore a recent production
|
|
4584
|
+
program.command("rollback").description("Restore a recent production deployment").argument("[repository]", "Rollback target; omit to choose interactively").option("--revision <number>", "Select a recent Helm revision without the chooser").option("--previous", "Restore the latest eligible previous Platform Web version").option("--version-id <uuid>", "Select a previously deployed Platform Web Worker version").option("--reason <text>", "Record a Platform Web rollback reason").option("--probe", "Measure a read-only Platform Web production probe").option("--yes", "Skip the final confirmation").action(async (repository, options) => {
|
|
4419
4585
|
const selected = repository ?? await ui.select({
|
|
4420
4586
|
message: "Select a repository to roll back",
|
|
4421
4587
|
choices: rollbackTargets.map((target) => ({ name: target.label, value: target.key }))
|
|
4422
4588
|
});
|
|
4423
|
-
if (selected
|
|
4424
|
-
throw new CliError("
|
|
4589
|
+
if (selected === platformWebRollbackTarget.key) {
|
|
4590
|
+
if (options.revision) throw new CliError("--revision applies only to tutti-server", 1);
|
|
4591
|
+
await runPlatformWebRollback(options);
|
|
4592
|
+
return;
|
|
4425
4593
|
}
|
|
4594
|
+
if (selected !== tuttiServerRollbackTarget.key) throw new CliError(`Unsupported rollback target: ${selected}`, 1);
|
|
4595
|
+
if (options.previous || options.versionId || options.reason || options.probe) throw new CliError("--previous, --version-id, --reason and --probe apply only to platform-web", 1);
|
|
4426
4596
|
await runTuttiRollback(options);
|
|
4427
4597
|
});
|
|
4428
4598
|
};
|
|
@@ -4452,7 +4622,7 @@ var readCliVersion = () => {
|
|
|
4452
4622
|
|
|
4453
4623
|
// src/lib/version-check.ts
|
|
4454
4624
|
import { realpath } from "fs/promises";
|
|
4455
|
-
import { join as
|
|
4625
|
+
import { join as join7, resolve as resolve3, sep } from "path";
|
|
4456
4626
|
var PACKAGE_NAME = "@tutti-os/nexight-devops";
|
|
4457
4627
|
var AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
4458
4628
|
var DEFAULT_LOCALE = "zh-CN";
|
|
@@ -4521,7 +4691,7 @@ var isInsidePackageRoot = (entryPath, root) => {
|
|
|
4521
4691
|
if (!entryPath || !root) {
|
|
4522
4692
|
return false;
|
|
4523
4693
|
}
|
|
4524
|
-
const packageRoot = resolve3(
|
|
4694
|
+
const packageRoot = resolve3(join7(root, "@tutti-os", "nexight-devops"));
|
|
4525
4695
|
const targetPath = resolve3(entryPath);
|
|
4526
4696
|
return targetPath === packageRoot || targetPath.startsWith(`${packageRoot}${sep}`);
|
|
4527
4697
|
};
|
|
@@ -4531,7 +4701,7 @@ var isInsideBunGlobalBin = (entryPath, bunGlobalBin) => {
|
|
|
4531
4701
|
}
|
|
4532
4702
|
const targetPath = resolve3(entryPath);
|
|
4533
4703
|
const binPath = resolve3(bunGlobalBin);
|
|
4534
|
-
const expectedEntryPath = resolve3(
|
|
4704
|
+
const expectedEntryPath = resolve3(join7(binPath, "nexight-devops"));
|
|
4535
4705
|
return targetPath === expectedEntryPath;
|
|
4536
4706
|
};
|
|
4537
4707
|
var readGlobalPath = async (command, args) => {
|