@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 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` lists the current production image and recent successful Helm deployments (up to ten entries), then restores a selected image through GitHub Actions and waits for health checks. Use `--revision <number> --yes` for scripted selection and confirmation.
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 listRun = await dispatch("list");
5188
- const catalog = await fetchCatalog(listRun.runId);
5189
- const choices = catalog.candidates.map((candidate) => ({
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
- revision = await ui.select({ message: "Select the image to restore", choices });
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((candidate) => candidate.revision === revision && !candidate.current);
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} / ${catalog.currentDigest}`);
5211
- logger.info(`Target: Helm revision ${selected.revision} / ${displayTime(selected.deployedAt)} / ${selected.digest}`);
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;