188 行
6.4 KiB
JavaScript
188 行
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
|
|
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
|
|
const FLAG_KEY = "payment_batch_v1";
|
|
|
|
function parseArgs(argv) {
|
|
const [action, ...rest] = argv;
|
|
const options = {
|
|
action,
|
|
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
|
|
snapshotFile: "",
|
|
};
|
|
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
const arg = rest[index];
|
|
if (arg === "--snapshot-file") {
|
|
options.snapshotFile = rest[index + 1] ?? "";
|
|
index += 1;
|
|
} else if (arg.startsWith("--snapshot-file=")) {
|
|
options.snapshotFile = arg.slice("--snapshot-file=".length);
|
|
} else if (arg.startsWith("--base-url=")) {
|
|
options.baseUrl = arg.slice("--base-url=".length);
|
|
}
|
|
}
|
|
|
|
if (!new Set(["capture", "restore"]).has(options.action)) {
|
|
throw new Error("action must be capture or restore");
|
|
}
|
|
if (!options.snapshotFile.trim()) {
|
|
throw new Error("--snapshot-file is required");
|
|
}
|
|
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
|
|
options.snapshotFile = options.snapshotFile.trim();
|
|
return options;
|
|
}
|
|
|
|
function getAdminCredential() {
|
|
const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim();
|
|
const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim();
|
|
if (!identifier || !password) {
|
|
throw new Error("missing LIVE_SMOKE_ADMIN_* credentials");
|
|
}
|
|
return { identifier, password };
|
|
}
|
|
|
|
async function fetchJson(url, init = {}) {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
const text = await response.text();
|
|
let json = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return { ok: response.ok, status: response.status, json };
|
|
}
|
|
|
|
function errorCode(json) {
|
|
return typeof json?.error?.code === "string" ? json.error.code : "";
|
|
}
|
|
|
|
async function login(baseUrl, credential) {
|
|
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": "payment-batch-flag-window-snapshot",
|
|
},
|
|
body: JSON.stringify(credential),
|
|
});
|
|
const accessToken = result.json?.data?.accessToken;
|
|
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
|
throw new Error(`admin login failed: HTTP ${result.status} ${errorCode(result.json)}`.trim());
|
|
}
|
|
return accessToken;
|
|
}
|
|
|
|
async function loadFlag(baseUrl, accessToken) {
|
|
const result = await fetchJson(`${baseUrl}/api/system/feature-flags`, {
|
|
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(`feature flag list failed: HTTP ${result.status} ${errorCode(result.json)}`.trim());
|
|
}
|
|
const flag = result.json?.data?.items?.find((item) => item.flagKey === FLAG_KEY);
|
|
if (!flag) {
|
|
throw new Error(`missing feature flag: ${FLAG_KEY}`);
|
|
}
|
|
return flag;
|
|
}
|
|
|
|
function toSnapshot(flag) {
|
|
return {
|
|
flagKey: FLAG_KEY,
|
|
overrideMode: flag.overrideMode,
|
|
rolloutType: flag.rolloutType,
|
|
departmentIds: Array.isArray(flag.departmentIds) ? [...flag.departmentIds] : [],
|
|
note: typeof flag.note === "string" ? flag.note : "",
|
|
effectiveEnabled: flag.effectiveEnabled === true,
|
|
effectiveReason: typeof flag.effectiveReason === "string" ? flag.effectiveReason : "unknown",
|
|
};
|
|
}
|
|
|
|
function validateSnapshot(value) {
|
|
const overrideModes = new Set(["inherit", "force_on", "force_off"]);
|
|
const rolloutTypes = new Set(["global", "department_allowlist"]);
|
|
if (
|
|
value?.flagKey !== FLAG_KEY ||
|
|
!overrideModes.has(value.overrideMode) ||
|
|
!rolloutTypes.has(value.rolloutType) ||
|
|
!Array.isArray(value.departmentIds) ||
|
|
!value.departmentIds.every((item) => typeof item === "string") ||
|
|
typeof value.note !== "string" ||
|
|
typeof value.effectiveEnabled !== "boolean" ||
|
|
typeof value.effectiveReason !== "string"
|
|
) {
|
|
throw new Error("invalid payment batch flag snapshot");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sameConfiguration(flag, snapshot) {
|
|
return flag.overrideMode === snapshot.overrideMode &&
|
|
flag.rolloutType === snapshot.rolloutType &&
|
|
JSON.stringify(flag.departmentIds ?? []) === JSON.stringify(snapshot.departmentIds) &&
|
|
(flag.note ?? "") === snapshot.note;
|
|
}
|
|
|
|
async function updateFlag(baseUrl, accessToken, current, snapshot) {
|
|
const result = await fetchJson(`${baseUrl}/api/system/feature-flags/${FLAG_KEY}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
authorization: `Bearer ${accessToken}`,
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
overrideMode: snapshot.overrideMode,
|
|
rolloutType: snapshot.rolloutType,
|
|
departmentIds: snapshot.departmentIds,
|
|
note: snapshot.note,
|
|
expectedVersion: current.version,
|
|
}),
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(`feature flag restore failed: HTTP ${result.status} ${errorCode(result.json)}`.trim());
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (process.env.P2_FIN_LIVE_APPROVED !== "1") {
|
|
throw new Error("P2_FIN_LIVE_APPROVED=1 is required");
|
|
}
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const credential = getAdminCredential();
|
|
const accessToken = await login(options.baseUrl, credential);
|
|
|
|
if (options.action === "capture") {
|
|
const current = await loadFlag(options.baseUrl, accessToken);
|
|
await writeFile(options.snapshotFile, `${JSON.stringify(toSnapshot(current), null, 2)}\n`, { mode: 0o600 });
|
|
await chmod(options.snapshotFile, 0o600);
|
|
console.log(`[payment-flag-snapshot] captured override=${current.overrideMode} effective=${current.effectiveEnabled}`);
|
|
return;
|
|
}
|
|
|
|
const snapshot = validateSnapshot(JSON.parse(await readFile(options.snapshotFile, "utf8")));
|
|
const current = await loadFlag(options.baseUrl, accessToken);
|
|
if (!sameConfiguration(current, snapshot)) {
|
|
await updateFlag(options.baseUrl, accessToken, current, snapshot);
|
|
}
|
|
const restored = await loadFlag(options.baseUrl, accessToken);
|
|
if (!sameConfiguration(restored, snapshot)) {
|
|
throw new Error("payment batch flag does not match caller snapshot after restore");
|
|
}
|
|
console.log(`[payment-flag-snapshot] restored override=${restored.overrideMode} effective=${restored.effectiveEnabled}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[payment-flag-snapshot][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
});
|