135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import { assessLagoCatalog, buildDesiredLagoCatalog, type LagoCatalogAssessment, type LagoCatalogSnapshot } from "./catalog";
|
|
import { BillingCatalogOperatorError, parseBillingCatalogCommand, safeBillingCatalogError, type BillingCatalogCommand } from "./contracts";
|
|
import { LagoCatalogClient } from "./lago-client";
|
|
|
|
type BillingCatalogExecutionResult = {
|
|
readonly target: BillingCatalogCommand["target"];
|
|
readonly mode: BillingCatalogCommand["mode"];
|
|
readonly complete: boolean;
|
|
readonly catalogVersion: string;
|
|
readonly catalogDigestSha256: string;
|
|
readonly snapshotPath: string | null;
|
|
readonly actions: LagoCatalogAssessment["actions"];
|
|
};
|
|
|
|
function safeTimestamp(now: Date): string {
|
|
return now.toISOString().replace(/[:.]/gu, "-");
|
|
}
|
|
|
|
export async function writeRedactedCatalogSnapshot(input: {
|
|
directory: string;
|
|
target: BillingCatalogCommand["target"];
|
|
snapshot: LagoCatalogSnapshot;
|
|
assessment: LagoCatalogAssessment;
|
|
now?: Date;
|
|
}): Promise<string> {
|
|
const path = resolve(input.directory, `${input.target}-${safeTimestamp(input.now ?? new Date())}.json`);
|
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
await writeFile(
|
|
path,
|
|
`${JSON.stringify(
|
|
{
|
|
target: input.target,
|
|
capturedAt: (input.now ?? new Date()).toISOString(),
|
|
catalogVersion: input.assessment.catalogVersion,
|
|
catalogDigestSha256: input.assessment.catalogDigestSha256,
|
|
snapshot: input.snapshot,
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
{ encoding: "utf8", mode: 0o600 }
|
|
);
|
|
return path;
|
|
}
|
|
|
|
export async function executeBillingCatalogCommand(
|
|
command: BillingCatalogCommand,
|
|
client = new LagoCatalogClient({
|
|
apiUrl: command.lagoApiUrl,
|
|
apiKey: command.lagoApiKey,
|
|
expectedOrganizationName: command.expectedOrganizationName,
|
|
expectedOrganizationIdSha256: command.expectedOrganizationIdSha256,
|
|
})
|
|
): Promise<BillingCatalogExecutionResult> {
|
|
const desired = buildDesiredLagoCatalog();
|
|
const initialSnapshot = await client.readCatalog(desired);
|
|
const initialAssessment = assessLagoCatalog(initialSnapshot, desired);
|
|
|
|
if (command.mode === "plan") {
|
|
return {
|
|
target: command.target,
|
|
mode: command.mode,
|
|
complete: initialAssessment.complete,
|
|
catalogVersion: desired.version,
|
|
catalogDigestSha256: desired.digestSha256,
|
|
snapshotPath: null,
|
|
actions: initialAssessment.actions,
|
|
};
|
|
}
|
|
if (command.mode === "verify") {
|
|
if (!initialAssessment.complete) {
|
|
throw new BillingCatalogOperatorError("billing catalog: verification found missing or drifted Lago resources");
|
|
}
|
|
return {
|
|
target: command.target,
|
|
mode: command.mode,
|
|
complete: true,
|
|
catalogVersion: desired.version,
|
|
catalogDigestSha256: desired.digestSha256,
|
|
snapshotPath: null,
|
|
actions: initialAssessment.actions,
|
|
};
|
|
}
|
|
|
|
const snapshotPath = await writeRedactedCatalogSnapshot({
|
|
directory: command.snapshotDirectory,
|
|
target: command.target,
|
|
snapshot: initialSnapshot,
|
|
assessment: initialAssessment,
|
|
});
|
|
for (const action of initialAssessment.actions) {
|
|
if (action.action === "noop") continue;
|
|
if (action.resource === "tax") {
|
|
await client.applyTax(desired, action.action === "update");
|
|
continue;
|
|
}
|
|
const plan = desired.plans.find((candidate) => candidate.code === action.code);
|
|
if (!plan) throw new BillingCatalogOperatorError("billing catalog: desired Lago plan is unavailable");
|
|
await client.applyPlan(plan, action.action === "update");
|
|
}
|
|
|
|
const finalAssessment = assessLagoCatalog(await client.readCatalog(desired), desired);
|
|
if (!finalAssessment.complete) {
|
|
throw new BillingCatalogOperatorError("billing catalog: apply did not converge to the canonical contract");
|
|
}
|
|
return {
|
|
target: command.target,
|
|
mode: command.mode,
|
|
complete: true,
|
|
catalogVersion: desired.version,
|
|
catalogDigestSha256: desired.digestSha256,
|
|
snapshotPath,
|
|
actions: finalAssessment.actions,
|
|
};
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
try {
|
|
const command = parseBillingCatalogCommand(process.argv.slice(2), process.env);
|
|
const result = await executeBillingCatalogCommand(command);
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} catch (error) {
|
|
process.stderr.write(`${safeBillingCatalogError(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
void main();
|
|
}
|