@tutti-os/nexight-devops 0.5.1 → 0.5.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/README.md +1 -1
- package/dist/cli.js +131 -14
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ nexight-devops release rollback tutti-server
|
|
|
68
68
|
- `ppe-lane destroy` checks existing lane instances and triggers destroy workflows after a lane-name confirmation.
|
|
69
69
|
- `ppe-lane status` queries AWS Cloud Map for one lane, or all lanes with `--all`.
|
|
70
70
|
- `release prepare`, `release hotfix`, and `release approve` run the controlled release lifecycle through GitHub Actions and approval issues.
|
|
71
|
-
- `release rollback tutti-server`
|
|
71
|
+
- `release rollback tutti-server` 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` for scripted selection and confirmation.
|
|
72
72
|
|
|
73
73
|
## Controlled Release
|
|
74
74
|
|
package/dist/cli.js
CHANGED
|
@@ -5085,6 +5085,94 @@ var registerReleasePrepareCommand = (program) => {
|
|
|
5085
5085
|
import { mkdtemp as mkdtemp3, readFile as readFile3, rm as rm2 } from "fs/promises";
|
|
5086
5086
|
import { tmpdir } from "os";
|
|
5087
5087
|
import { join as join5 } from "path";
|
|
5088
|
+
|
|
5089
|
+
// src/commands/release/rollback-local.ts
|
|
5090
|
+
var CLUSTER = "arn:aws:eks:us-east-1:250509935467:cluster/nexight-ojo-us-east-1";
|
|
5091
|
+
var REPOSITORY = "250509935467.dkr.ecr.us-east-1.amazonaws.com/nexight/tutti/tutti-server";
|
|
5092
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
5093
|
+
var MANIFEST_IMAGE = new RegExp(`image:\\s*["']?${REPOSITORY.replaceAll(".", "\\.")}@(sha256:[0-9a-f]{64})`, "g");
|
|
5094
|
+
var read = async (command, args) => {
|
|
5095
|
+
const result = await runCommand(command, args);
|
|
5096
|
+
if (result.exitCode !== 0) {
|
|
5097
|
+
throw new CliError(result.stderr.trim() || `${command} read failed`, 1);
|
|
5098
|
+
}
|
|
5099
|
+
return result.stdout.trim();
|
|
5100
|
+
};
|
|
5101
|
+
var digestFromManifest = (manifest) => {
|
|
5102
|
+
const matches = [...manifest.matchAll(MANIFEST_IMAGE)].map((match) => match[1]);
|
|
5103
|
+
const digests = new Set(matches);
|
|
5104
|
+
if (digests.size !== 1) {
|
|
5105
|
+
throw new CliError("Helm revision has no unambiguous Tutti image", 1);
|
|
5106
|
+
}
|
|
5107
|
+
return [...digests][0];
|
|
5108
|
+
};
|
|
5109
|
+
var buildLocalRollbackCatalog = (history, deployment, manifests) => {
|
|
5110
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
5111
|
+
throw new CliError("Production Helm history is empty", 1);
|
|
5112
|
+
}
|
|
5113
|
+
const sorted = [...history].sort((left, right) => right.revision - left.revision);
|
|
5114
|
+
const latest = sorted[0];
|
|
5115
|
+
if (!latest || !["deployed", "failed"].includes(latest.status)) {
|
|
5116
|
+
throw new CliError("Latest Helm revision needs inspection", 1);
|
|
5117
|
+
}
|
|
5118
|
+
const resource = deployment;
|
|
5119
|
+
const images = resource?.spec?.template?.spec?.containers?.filter((container) => container.name === "tutti-server").map((container) => container.image);
|
|
5120
|
+
if (images?.length !== 1 || !images[0]?.startsWith(`${REPOSITORY}@`)) {
|
|
5121
|
+
throw new CliError("Production deployment has no unambiguous Tutti image", 1);
|
|
5122
|
+
}
|
|
5123
|
+
const currentDigest = images[0].slice(REPOSITORY.length + 1);
|
|
5124
|
+
if (!DIGEST.test(currentDigest)) {
|
|
5125
|
+
throw new CliError("Production deployment image is not digest-pinned", 1);
|
|
5126
|
+
}
|
|
5127
|
+
const entries = [latest, ...sorted.filter(
|
|
5128
|
+
(item) => item.revision !== latest.revision && ["deployed", "superseded"].includes(item.status)
|
|
5129
|
+
)].slice(0, 10);
|
|
5130
|
+
const candidates = entries.map((item) => {
|
|
5131
|
+
const manifest = manifests.get(item.revision);
|
|
5132
|
+
if (!manifest) {
|
|
5133
|
+
throw new CliError(`Missing manifest for Helm revision ${item.revision}`, 1);
|
|
5134
|
+
}
|
|
5135
|
+
const digest = digestFromManifest(manifest);
|
|
5136
|
+
return {
|
|
5137
|
+
revision: item.revision,
|
|
5138
|
+
deployedAt: item.updated,
|
|
5139
|
+
digest,
|
|
5140
|
+
version: `sha256:${digest.slice(7, 19)}`,
|
|
5141
|
+
current: digest === currentDigest
|
|
5142
|
+
};
|
|
5143
|
+
});
|
|
5144
|
+
if (candidates[0]?.digest !== currentDigest) {
|
|
5145
|
+
throw new CliError("Current image does not match the latest Helm revision", 1);
|
|
5146
|
+
}
|
|
5147
|
+
return { environment: "production", currentDigest, currentRevision: latest.revision, candidates };
|
|
5148
|
+
};
|
|
5149
|
+
var fetchLocalRollbackCatalog = async () => {
|
|
5150
|
+
const cluster = await read("kubectl", ["config", "view", "--minify", "-o", "jsonpath={.clusters[0].name}"]);
|
|
5151
|
+
if (cluster !== CLUSTER) {
|
|
5152
|
+
throw new CliError("Current kubectl context is not the production cluster", 1);
|
|
5153
|
+
}
|
|
5154
|
+
const [historyRaw, deploymentRaw] = await Promise.all([
|
|
5155
|
+
read("helm", ["history", "tutti-server", "-n", "tutti", "--max", "100", "-o", "json"]),
|
|
5156
|
+
read("kubectl", ["get", "deployment", "tutti-server", "-n", "tutti", "-o", "json"])
|
|
5157
|
+
]);
|
|
5158
|
+
const history = JSON.parse(historyRaw);
|
|
5159
|
+
const deployment = JSON.parse(deploymentRaw);
|
|
5160
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
5161
|
+
throw new CliError("Production Helm history is empty", 1);
|
|
5162
|
+
}
|
|
5163
|
+
const sorted = [...history].sort((left, right) => right.revision - left.revision);
|
|
5164
|
+
const latest = sorted[0];
|
|
5165
|
+
const entries = [latest, ...sorted.filter(
|
|
5166
|
+
(item) => item.revision !== latest.revision && ["deployed", "superseded"].includes(item.status)
|
|
5167
|
+
)].slice(0, 10);
|
|
5168
|
+
const manifests = new Map(await Promise.all(entries.map(async (item) => [
|
|
5169
|
+
item.revision,
|
|
5170
|
+
await read("helm", ["get", "manifest", "tutti-server", "-n", "tutti", "--revision", String(item.revision)])
|
|
5171
|
+
])));
|
|
5172
|
+
return buildLocalRollbackCatalog(history, deployment, manifests);
|
|
5173
|
+
};
|
|
5174
|
+
|
|
5175
|
+
// src/commands/release/rollback.ts
|
|
5088
5176
|
var REPO = "tutti-lab/tutti-server";
|
|
5089
5177
|
var WORKFLOW = "rollback.yml";
|
|
5090
5178
|
var SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
@@ -5093,10 +5181,23 @@ var SINGAPORE_TIME = new Intl.DateTimeFormat("sv-SE", {
|
|
|
5093
5181
|
dateStyle: "short",
|
|
5094
5182
|
timeStyle: "medium"
|
|
5095
5183
|
});
|
|
5184
|
+
var SINGAPORE_CHOICE_TIME = new Intl.DateTimeFormat("sv-SE", {
|
|
5185
|
+
timeZone: "Asia/Singapore",
|
|
5186
|
+
dateStyle: "short",
|
|
5187
|
+
timeStyle: "short"
|
|
5188
|
+
});
|
|
5096
5189
|
var displayTime = (raw) => {
|
|
5097
5190
|
const date = new Date(raw);
|
|
5098
5191
|
return Number.isNaN(date.getTime()) ? raw : `${SINGAPORE_TIME.format(date)} SGT`;
|
|
5099
5192
|
};
|
|
5193
|
+
var displayChoiceTime = (raw) => {
|
|
5194
|
+
const date = new Date(raw);
|
|
5195
|
+
return Number.isNaN(date.getTime()) ? raw : SINGAPORE_CHOICE_TIME.format(date);
|
|
5196
|
+
};
|
|
5197
|
+
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) => ({
|
|
5198
|
+
name: `${displayChoiceTime(candidate.deployedAt)} SGT #${candidate.revision} ${candidate.version}`,
|
|
5199
|
+
value: candidate.revision
|
|
5200
|
+
}));
|
|
5100
5201
|
var gh = async (args) => {
|
|
5101
5202
|
const result = await runCommand("gh", args);
|
|
5102
5203
|
if (result.exitCode !== 0) {
|
|
@@ -5181,34 +5282,50 @@ var fetchCatalog = async (runId) => {
|
|
|
5181
5282
|
await rm2(dir, { recursive: true, force: true });
|
|
5182
5283
|
}
|
|
5183
5284
|
};
|
|
5285
|
+
var readCatalog = async () => {
|
|
5286
|
+
const progress = ui.spinner("\u76F4\u63A5\u8BFB\u53D6\u751F\u4EA7 Helm \u5386\u53F2...").start();
|
|
5287
|
+
try {
|
|
5288
|
+
return await fetchLocalRollbackCatalog();
|
|
5289
|
+
} catch {
|
|
5290
|
+
} finally {
|
|
5291
|
+
progress.stop();
|
|
5292
|
+
}
|
|
5293
|
+
logger.warn("\u672C\u673A\u65E0\u6CD5\u5B8C\u6574\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2\uFF0C\u6539\u7528 GitHub Actions \u67E5\u8BE2\u3002");
|
|
5294
|
+
const listRun = await dispatch("list");
|
|
5295
|
+
return fetchCatalog(listRun.runId);
|
|
5296
|
+
};
|
|
5184
5297
|
var runTuttiRollback = async (options) => {
|
|
5185
5298
|
await ensurePrerequisites({ needsGh: true });
|
|
5186
5299
|
await ensureNoActiveDeployment();
|
|
5187
|
-
const
|
|
5188
|
-
const
|
|
5189
|
-
|
|
5190
|
-
name: `${displayTime(candidate.deployedAt)} ${candidate.version} ${candidate.digest}${candidate.current ? candidate.workflowSucceeded === false ? " [current, checks failed]" : " [current]" : ""}`,
|
|
5191
|
-
value: candidate.revision,
|
|
5192
|
-
disabled: candidate.current ? "currently deployed" : false
|
|
5193
|
-
}));
|
|
5194
|
-
logger.info("Production deployments, newest first:");
|
|
5195
|
-
choices.forEach((choice) => logger.info(choice.name));
|
|
5196
|
-
if (catalog.candidates.every((candidate) => candidate.current)) {
|
|
5300
|
+
const catalog = await readCatalog();
|
|
5301
|
+
const choices = buildRollbackChoices(catalog);
|
|
5302
|
+
if (choices.length === 0) {
|
|
5197
5303
|
throw new CliError("No previous retained production image is available to restore", 1);
|
|
5198
5304
|
}
|
|
5199
5305
|
let revision;
|
|
5200
5306
|
if (options.revision) {
|
|
5201
5307
|
revision = Number(options.revision);
|
|
5202
5308
|
} else {
|
|
5203
|
-
|
|
5309
|
+
const current = catalog.candidates.find((candidate) => candidate.current);
|
|
5310
|
+
logger.info(`Currently deployed: #${catalog.currentRevision} ${current?.version ?? catalog.currentDigest.slice(0, 19)}${current?.workflowSucceeded === false ? " [checks failed]" : ""}`);
|
|
5311
|
+
revision = await ui.select({
|
|
5312
|
+
message: "Select a production image to restore (newest first)",
|
|
5313
|
+
choices,
|
|
5314
|
+
pageSize: choices.length
|
|
5315
|
+
});
|
|
5204
5316
|
}
|
|
5205
|
-
const selected = catalog.candidates.find(
|
|
5317
|
+
const selected = catalog.candidates.find(
|
|
5318
|
+
(candidate) => candidate.revision === revision && !candidate.current && candidate.digest !== catalog.currentDigest
|
|
5319
|
+
);
|
|
5206
5320
|
if (!selected) {
|
|
5207
5321
|
throw new CliError(`Revision ${revision} is not an eligible recent deployment`, 1);
|
|
5208
5322
|
}
|
|
5209
5323
|
logger.info(`Environment: production`);
|
|
5210
|
-
logger.info(`Current: Helm revision ${catalog.currentRevision}
|
|
5211
|
-
logger.info(`
|
|
5324
|
+
logger.info(`Current: Helm revision ${catalog.currentRevision}`);
|
|
5325
|
+
logger.info(` ${catalog.currentDigest}`);
|
|
5326
|
+
logger.info(`Target: Helm revision ${selected.revision} / ${displayTime(selected.deployedAt)} / ${selected.version}`);
|
|
5327
|
+
logger.info(` ${selected.digest}`);
|
|
5328
|
+
logger.info("GitHub Actions will recheck the live deployment and target image before changing production.");
|
|
5212
5329
|
if (!options.yes && !await ui.confirm({ message: "Restore this image to production?", default: false })) {
|
|
5213
5330
|
logger.warn("Rollback cancelled");
|
|
5214
5331
|
return;
|