@tutti-os/nexight-devops 0.5.2 → 0.5.4
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 -4
- package/dist/cli.js +137 -8
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ nexight-devops ppe-lane status --all
|
|
|
48
48
|
nexight-devops release prepare
|
|
49
49
|
nexight-devops release hotfix
|
|
50
50
|
nexight-devops release approve
|
|
51
|
+
nexight-devops release rollback
|
|
51
52
|
nexight-devops release rollback tutti-server
|
|
52
53
|
```
|
|
53
54
|
|
|
@@ -64,11 +65,11 @@ nexight-devops release rollback tutti-server
|
|
|
64
65
|
- `admin computer log-recall` exposes the lower-level task operations for troubleshooting and recovery.
|
|
65
66
|
- `datafinder` validates access, reads dashboards/reports, runs trusted local JavaScript queries, and exposes fixed reliability queries.
|
|
66
67
|
- `diagnose shared-agent` runs a bounded, read-only CloudWatch playbook that correlates room descriptor results, Owner lease observations, client version, and HTTP failures. It verifies the production AWS account before querying and supports stable `--json` output.
|
|
67
|
-
- `ppe-lane deploy` deploys selected backend repositories and the optional `zk-web/canvas` staging artifact to a PPE lane.
|
|
68
|
-
- `ppe-lane destroy` checks existing lane instances and triggers destroy workflows after a lane-name confirmation.
|
|
69
|
-
- `ppe-lane status` queries AWS Cloud Map for one lane, or all lanes with `--all`.
|
|
68
|
+
- `ppe-lane deploy` deploys selected backend repositories, including `tutti-server`, and the optional `zk-web/canvas` staging artifact to a PPE lane.
|
|
69
|
+
- `ppe-lane destroy` checks existing lane instances and triggers destroy workflows after a lane-name confirmation. `tutti-server` must be selected explicitly for destruction.
|
|
70
|
+
- `ppe-lane status` queries AWS Cloud Map for one lane, or all lanes with `--all`, including `tutti-server`.
|
|
70
71
|
- `release prepare`, `release hotfix`, and `release approve` run the controlled release lifecycle through GitHub Actions and approval issues.
|
|
71
|
-
- `release rollback tutti-server`
|
|
72
|
+
- `release rollback` lets you choose `tutti-server` interactively; `release rollback tutti-server` selects it directly. The command reads the current production image and up to ten recent Helm deployments directly when local read access to the production cluster is available. Otherwise it retrieves the verified list through GitHub Actions. The selected image is always revalidated and restored by GitHub Actions, followed by health checks. Use `--revision <number> --yes` with the explicit repository for scripted selection and confirmation.
|
|
72
73
|
|
|
73
74
|
## Controlled Release
|
|
74
75
|
|
package/dist/cli.js
CHANGED
|
@@ -4018,6 +4018,17 @@ var ppeLaneCanvasTarget = {
|
|
|
4018
4018
|
|
|
4019
4019
|
// src/constants/ppe-lane-repos.ts
|
|
4020
4020
|
var ppeLaneRepos = [
|
|
4021
|
+
{
|
|
4022
|
+
key: "tutti-server",
|
|
4023
|
+
label: "tutti-server",
|
|
4024
|
+
repoFullName: "tutti-lab/tutti-server",
|
|
4025
|
+
deployWorkflowFile: "deploy-lane.yml",
|
|
4026
|
+
destroyWorkflowFile: "destroy-lane.yml",
|
|
4027
|
+
defaultRef: "main",
|
|
4028
|
+
defaultDestroySelection: false,
|
|
4029
|
+
cloudMapServiceId: "srv-izinli73rwxpf2dr",
|
|
4030
|
+
serviceName: "tutti-server"
|
|
4031
|
+
},
|
|
4021
4032
|
{
|
|
4022
4033
|
key: "zk-agent-engine",
|
|
4023
4034
|
label: "zk-agent-engine",
|
|
@@ -4463,7 +4474,11 @@ var runPpeLaneDestroy = async () => {
|
|
|
4463
4474
|
const lane = validatePpeLaneName(await ui.input({ message: t("ppeLane.destroy.enterLane") }));
|
|
4464
4475
|
const selectedKeys = await ui.checkbox({
|
|
4465
4476
|
message: t("ppeLane.destroy.selectRepos"),
|
|
4466
|
-
choices: ppeLaneRepos.map((repo) => ({
|
|
4477
|
+
choices: ppeLaneRepos.map((repo) => ({
|
|
4478
|
+
value: repo.key,
|
|
4479
|
+
name: repo.label,
|
|
4480
|
+
checked: repo.defaultDestroySelection !== false
|
|
4481
|
+
}))
|
|
4467
4482
|
});
|
|
4468
4483
|
if (selectedKeys.length === 0) {
|
|
4469
4484
|
throw new CliError(t("ppeLane.destroy.atLeastOneRepo"), 1);
|
|
@@ -5085,7 +5100,103 @@ var registerReleasePrepareCommand = (program) => {
|
|
|
5085
5100
|
import { mkdtemp as mkdtemp3, readFile as readFile3, rm as rm2 } from "fs/promises";
|
|
5086
5101
|
import { tmpdir } from "os";
|
|
5087
5102
|
import { join as join5 } from "path";
|
|
5088
|
-
|
|
5103
|
+
|
|
5104
|
+
// src/constants/rollback-targets.ts
|
|
5105
|
+
var tuttiServerRollbackTarget = {
|
|
5106
|
+
key: "tutti-server",
|
|
5107
|
+
label: "tutti-server",
|
|
5108
|
+
repoFullName: "tutti-lab/tutti-server"
|
|
5109
|
+
};
|
|
5110
|
+
var rollbackTargets = [tuttiServerRollbackTarget];
|
|
5111
|
+
|
|
5112
|
+
// src/commands/release/rollback-local.ts
|
|
5113
|
+
var CLUSTER = "arn:aws:eks:us-east-1:250509935467:cluster/nexight-ojo-us-east-1";
|
|
5114
|
+
var REPOSITORY = "250509935467.dkr.ecr.us-east-1.amazonaws.com/nexight/tutti/tutti-server";
|
|
5115
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
5116
|
+
var MANIFEST_IMAGE = new RegExp(`image:\\s*["']?${REPOSITORY.replaceAll(".", "\\.")}@(sha256:[0-9a-f]{64})`, "g");
|
|
5117
|
+
var read = async (command, args) => {
|
|
5118
|
+
const result = await runCommand(command, args);
|
|
5119
|
+
if (result.exitCode !== 0) {
|
|
5120
|
+
throw new CliError(result.stderr.trim() || `${command} read failed`, 1);
|
|
5121
|
+
}
|
|
5122
|
+
return result.stdout.trim();
|
|
5123
|
+
};
|
|
5124
|
+
var digestFromManifest = (manifest) => {
|
|
5125
|
+
const matches = [...manifest.matchAll(MANIFEST_IMAGE)].map((match) => match[1]);
|
|
5126
|
+
const digests = new Set(matches);
|
|
5127
|
+
if (digests.size !== 1) {
|
|
5128
|
+
throw new CliError("Helm revision has no unambiguous Tutti image", 1);
|
|
5129
|
+
}
|
|
5130
|
+
return [...digests][0];
|
|
5131
|
+
};
|
|
5132
|
+
var buildLocalRollbackCatalog = (history, deployment, manifests) => {
|
|
5133
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
5134
|
+
throw new CliError("Production Helm history is empty", 1);
|
|
5135
|
+
}
|
|
5136
|
+
const sorted = [...history].sort((left, right) => right.revision - left.revision);
|
|
5137
|
+
const latest = sorted[0];
|
|
5138
|
+
if (!latest || !["deployed", "failed"].includes(latest.status)) {
|
|
5139
|
+
throw new CliError("Latest Helm revision needs inspection", 1);
|
|
5140
|
+
}
|
|
5141
|
+
const resource = deployment;
|
|
5142
|
+
const images = resource?.spec?.template?.spec?.containers?.filter((container) => container.name === "tutti-server").map((container) => container.image);
|
|
5143
|
+
if (images?.length !== 1 || !images[0]?.startsWith(`${REPOSITORY}@`)) {
|
|
5144
|
+
throw new CliError("Production deployment has no unambiguous Tutti image", 1);
|
|
5145
|
+
}
|
|
5146
|
+
const currentDigest = images[0].slice(REPOSITORY.length + 1);
|
|
5147
|
+
if (!DIGEST.test(currentDigest)) {
|
|
5148
|
+
throw new CliError("Production deployment image is not digest-pinned", 1);
|
|
5149
|
+
}
|
|
5150
|
+
const entries = [latest, ...sorted.filter(
|
|
5151
|
+
(item) => item.revision !== latest.revision && ["deployed", "superseded"].includes(item.status)
|
|
5152
|
+
)].slice(0, 10);
|
|
5153
|
+
const candidates = entries.map((item) => {
|
|
5154
|
+
const manifest = manifests.get(item.revision);
|
|
5155
|
+
if (!manifest) {
|
|
5156
|
+
throw new CliError(`Missing manifest for Helm revision ${item.revision}`, 1);
|
|
5157
|
+
}
|
|
5158
|
+
const digest = digestFromManifest(manifest);
|
|
5159
|
+
return {
|
|
5160
|
+
revision: item.revision,
|
|
5161
|
+
deployedAt: item.updated,
|
|
5162
|
+
digest,
|
|
5163
|
+
version: `sha256:${digest.slice(7, 19)}`,
|
|
5164
|
+
current: digest === currentDigest
|
|
5165
|
+
};
|
|
5166
|
+
});
|
|
5167
|
+
if (candidates[0]?.digest !== currentDigest) {
|
|
5168
|
+
throw new CliError("Current image does not match the latest Helm revision", 1);
|
|
5169
|
+
}
|
|
5170
|
+
return { environment: "production", currentDigest, currentRevision: latest.revision, candidates };
|
|
5171
|
+
};
|
|
5172
|
+
var fetchLocalRollbackCatalog = async () => {
|
|
5173
|
+
const cluster = await read("kubectl", ["config", "view", "--minify", "-o", "jsonpath={.clusters[0].name}"]);
|
|
5174
|
+
if (cluster !== CLUSTER) {
|
|
5175
|
+
throw new CliError("Current kubectl context is not the production cluster", 1);
|
|
5176
|
+
}
|
|
5177
|
+
const [historyRaw, deploymentRaw] = await Promise.all([
|
|
5178
|
+
read("helm", ["history", "tutti-server", "-n", "tutti", "--max", "100", "-o", "json"]),
|
|
5179
|
+
read("kubectl", ["get", "deployment", "tutti-server", "-n", "tutti", "-o", "json"])
|
|
5180
|
+
]);
|
|
5181
|
+
const history = JSON.parse(historyRaw);
|
|
5182
|
+
const deployment = JSON.parse(deploymentRaw);
|
|
5183
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
5184
|
+
throw new CliError("Production Helm history is empty", 1);
|
|
5185
|
+
}
|
|
5186
|
+
const sorted = [...history].sort((left, right) => right.revision - left.revision);
|
|
5187
|
+
const latest = sorted[0];
|
|
5188
|
+
const entries = [latest, ...sorted.filter(
|
|
5189
|
+
(item) => item.revision !== latest.revision && ["deployed", "superseded"].includes(item.status)
|
|
5190
|
+
)].slice(0, 10);
|
|
5191
|
+
const manifests = new Map(await Promise.all(entries.map(async (item) => [
|
|
5192
|
+
item.revision,
|
|
5193
|
+
await read("helm", ["get", "manifest", "tutti-server", "-n", "tutti", "--revision", String(item.revision)])
|
|
5194
|
+
])));
|
|
5195
|
+
return buildLocalRollbackCatalog(history, deployment, manifests);
|
|
5196
|
+
};
|
|
5197
|
+
|
|
5198
|
+
// src/commands/release/rollback.ts
|
|
5199
|
+
var REPO = tuttiServerRollbackTarget.repoFullName;
|
|
5089
5200
|
var WORKFLOW = "rollback.yml";
|
|
5090
5201
|
var SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
5091
5202
|
var SINGAPORE_TIME = new Intl.DateTimeFormat("sv-SE", {
|
|
@@ -5106,7 +5217,7 @@ var displayChoiceTime = (raw) => {
|
|
|
5106
5217
|
const date = new Date(raw);
|
|
5107
5218
|
return Number.isNaN(date.getTime()) ? raw : SINGAPORE_CHOICE_TIME.format(date);
|
|
5108
5219
|
};
|
|
5109
|
-
var buildRollbackChoices = (catalog) => catalog.candidates.filter((candidate) => !candidate.current).sort((left, right) => Date.parse(right.deployedAt) - Date.parse(left.deployedAt)).map((candidate) => ({
|
|
5220
|
+
var buildRollbackChoices = (catalog) => catalog.candidates.filter((candidate) => !candidate.current && candidate.digest !== catalog.currentDigest).sort((left, right) => Date.parse(right.deployedAt) - Date.parse(left.deployedAt)).map((candidate) => ({
|
|
5110
5221
|
name: `${displayChoiceTime(candidate.deployedAt)} SGT #${candidate.revision} ${candidate.version}`,
|
|
5111
5222
|
value: candidate.revision
|
|
5112
5223
|
}));
|
|
@@ -5194,11 +5305,22 @@ var fetchCatalog = async (runId) => {
|
|
|
5194
5305
|
await rm2(dir, { recursive: true, force: true });
|
|
5195
5306
|
}
|
|
5196
5307
|
};
|
|
5308
|
+
var readCatalog = async () => {
|
|
5309
|
+
const progress = ui.spinner("\u76F4\u63A5\u8BFB\u53D6\u751F\u4EA7 Helm \u5386\u53F2...").start();
|
|
5310
|
+
try {
|
|
5311
|
+
return await fetchLocalRollbackCatalog();
|
|
5312
|
+
} catch {
|
|
5313
|
+
} finally {
|
|
5314
|
+
progress.stop();
|
|
5315
|
+
}
|
|
5316
|
+
logger.warn("\u672C\u673A\u65E0\u6CD5\u5B8C\u6574\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2\uFF0C\u6539\u7528 GitHub Actions \u67E5\u8BE2\u3002");
|
|
5317
|
+
const listRun = await dispatch("list");
|
|
5318
|
+
return fetchCatalog(listRun.runId);
|
|
5319
|
+
};
|
|
5197
5320
|
var runTuttiRollback = async (options) => {
|
|
5198
5321
|
await ensurePrerequisites({ needsGh: true });
|
|
5199
5322
|
await ensureNoActiveDeployment();
|
|
5200
|
-
const
|
|
5201
|
-
const catalog = await fetchCatalog(listRun.runId);
|
|
5323
|
+
const catalog = await readCatalog();
|
|
5202
5324
|
const choices = buildRollbackChoices(catalog);
|
|
5203
5325
|
if (choices.length === 0) {
|
|
5204
5326
|
throw new CliError("No previous retained production image is available to restore", 1);
|
|
@@ -5215,7 +5337,9 @@ var runTuttiRollback = async (options) => {
|
|
|
5215
5337
|
pageSize: choices.length
|
|
5216
5338
|
});
|
|
5217
5339
|
}
|
|
5218
|
-
const selected = catalog.candidates.find(
|
|
5340
|
+
const selected = catalog.candidates.find(
|
|
5341
|
+
(candidate) => candidate.revision === revision && !candidate.current && candidate.digest !== catalog.currentDigest
|
|
5342
|
+
);
|
|
5219
5343
|
if (!selected) {
|
|
5220
5344
|
throw new CliError(`Revision ${revision} is not an eligible recent deployment`, 1);
|
|
5221
5345
|
}
|
|
@@ -5224,6 +5348,7 @@ var runTuttiRollback = async (options) => {
|
|
|
5224
5348
|
logger.info(` ${catalog.currentDigest}`);
|
|
5225
5349
|
logger.info(`Target: Helm revision ${selected.revision} / ${displayTime(selected.deployedAt)} / ${selected.version}`);
|
|
5226
5350
|
logger.info(` ${selected.digest}`);
|
|
5351
|
+
logger.info("GitHub Actions will recheck the live deployment and target image before changing production.");
|
|
5227
5352
|
if (!options.yes && !await ui.confirm({ message: "Restore this image to production?", default: false })) {
|
|
5228
5353
|
logger.warn("Rollback cancelled");
|
|
5229
5354
|
return;
|
|
@@ -5239,8 +5364,12 @@ var runTuttiRollback = async (options) => {
|
|
|
5239
5364
|
logger.info(run.htmlUrl);
|
|
5240
5365
|
};
|
|
5241
5366
|
var registerReleaseRollbackCommand = (program) => {
|
|
5242
|
-
program.command("rollback").description("Restore a recent production image").argument("
|
|
5243
|
-
|
|
5367
|
+
program.command("rollback").description("Restore a recent production image").argument("[repository]", "Rollback target; omit to choose interactively").option("--revision <number>", "Select a recent Helm revision without the chooser").option("--yes", "Skip the final confirmation").action(async (repository, options) => {
|
|
5368
|
+
const selected = repository ?? await ui.select({
|
|
5369
|
+
message: "Select a repository to roll back",
|
|
5370
|
+
choices: rollbackTargets.map((target) => ({ name: target.label, value: target.key }))
|
|
5371
|
+
});
|
|
5372
|
+
if (selected !== tuttiServerRollbackTarget.key) {
|
|
5244
5373
|
throw new CliError("Only tutti-server is currently supported", 1);
|
|
5245
5374
|
}
|
|
5246
5375
|
await runTuttiRollback(options);
|