@tutti-os/nexight-devops 0.5.2 → 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}$/;
@@ -5106,7 +5194,7 @@ var displayChoiceTime = (raw) => {
5106
5194
  const date = new Date(raw);
5107
5195
  return Number.isNaN(date.getTime()) ? raw : SINGAPORE_CHOICE_TIME.format(date);
5108
5196
  };
5109
- var buildRollbackChoices = (catalog) => catalog.candidates.filter((candidate) => !candidate.current).sort((left, right) => Date.parse(right.deployedAt) - Date.parse(left.deployedAt)).map((candidate) => ({
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) => ({
5110
5198
  name: `${displayChoiceTime(candidate.deployedAt)} SGT #${candidate.revision} ${candidate.version}`,
5111
5199
  value: candidate.revision
5112
5200
  }));
@@ -5194,11 +5282,22 @@ var fetchCatalog = async (runId) => {
5194
5282
  await rm2(dir, { recursive: true, force: true });
5195
5283
  }
5196
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
+ };
5197
5297
  var runTuttiRollback = async (options) => {
5198
5298
  await ensurePrerequisites({ needsGh: true });
5199
5299
  await ensureNoActiveDeployment();
5200
- const listRun = await dispatch("list");
5201
- const catalog = await fetchCatalog(listRun.runId);
5300
+ const catalog = await readCatalog();
5202
5301
  const choices = buildRollbackChoices(catalog);
5203
5302
  if (choices.length === 0) {
5204
5303
  throw new CliError("No previous retained production image is available to restore", 1);
@@ -5215,7 +5314,9 @@ var runTuttiRollback = async (options) => {
5215
5314
  pageSize: choices.length
5216
5315
  });
5217
5316
  }
5218
- 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
+ );
5219
5320
  if (!selected) {
5220
5321
  throw new CliError(`Revision ${revision} is not an eligible recent deployment`, 1);
5221
5322
  }
@@ -5224,6 +5325,7 @@ var runTuttiRollback = async (options) => {
5224
5325
  logger.info(` ${catalog.currentDigest}`);
5225
5326
  logger.info(`Target: Helm revision ${selected.revision} / ${displayTime(selected.deployedAt)} / ${selected.version}`);
5226
5327
  logger.info(` ${selected.digest}`);
5328
+ logger.info("GitHub Actions will recheck the live deployment and target image before changing production.");
5227
5329
  if (!options.yes && !await ui.confirm({ message: "Restore this image to production?", default: false })) {
5228
5330
  logger.warn("Rollback cancelled");
5229
5331
  return;