Drop the allowedResources field from TenantApplyPlan — it was derived field-for-field from resourceChecklist already, which was exactly the "triplicate representation" flagged in the handoff's consolidation list. Update scripts/tenant-lifecycle.ts to compute the same lists from the checklist when it prints, and drop the tautological equality assertions from the test (resourceChecklist is now the single source). --- Build: pass | Tests: pass — 33 passed (1 file) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
293 lines
9 KiB
TypeScript
293 lines
9 KiB
TypeScript
import {
|
|
addTenantRecord,
|
|
getTenantRecord,
|
|
planTenantApply,
|
|
listTenantRecords,
|
|
planTenantProvisioning,
|
|
planTenantRemoval,
|
|
} from '../src/tenant-registry.js';
|
|
|
|
function usage(): never {
|
|
console.error(
|
|
'Usage: npx tsx scripts/tenant-lifecycle.ts <list|show|plan|apply|add|remove> [tenant-id]\n' +
|
|
' [--domain value] [--display-name value] [--internal-domain value]\n' +
|
|
' [--service value] [--dataset zroot/path ...]',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
interface CliOptions {
|
|
tenantId?: string;
|
|
domain?: string;
|
|
displayName?: string;
|
|
internalDomain?: string;
|
|
service?: string;
|
|
datasets?: string[];
|
|
}
|
|
|
|
function parseOptions(args: string[]): CliOptions {
|
|
const result: CliOptions = {};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (!arg) continue;
|
|
if (!arg.startsWith('--') && !result.tenantId) {
|
|
result.tenantId = arg;
|
|
continue;
|
|
}
|
|
if (arg === '--domain') {
|
|
result.domain = args[++i] || '';
|
|
continue;
|
|
}
|
|
if (arg === '--display-name') {
|
|
result.displayName = args[++i] || '';
|
|
continue;
|
|
}
|
|
if (arg === '--internal-domain') {
|
|
result.internalDomain = args[++i] || '';
|
|
continue;
|
|
}
|
|
if (arg === '--service') {
|
|
result.service = args[++i] || '';
|
|
continue;
|
|
}
|
|
if (arg === '--dataset') {
|
|
const value = args[++i] || '';
|
|
if (value) {
|
|
result.datasets = [...(result.datasets || []), value];
|
|
}
|
|
continue;
|
|
}
|
|
usage();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function draftOptionsFromCli(options: CliOptions): {
|
|
displayName?: string;
|
|
domain?: string;
|
|
internalDomain?: string;
|
|
service?: string;
|
|
datasets?: string[];
|
|
} {
|
|
return {
|
|
displayName: options.displayName,
|
|
domain: options.domain,
|
|
internalDomain: options.internalDomain,
|
|
service: options.service,
|
|
datasets: options.datasets,
|
|
};
|
|
}
|
|
|
|
function printTenantSummary(
|
|
tenant: ReturnType<typeof planTenantProvisioning>['tenant'],
|
|
): void {
|
|
console.log(`Tenant: ${tenant.id}`);
|
|
console.log(`Display: ${tenant.displayName}`);
|
|
console.log(`Service: ${tenant.service}`);
|
|
console.log(`Internal domain: ${tenant.internalDomain}`);
|
|
console.log(`Databases: ${Object.values(tenant.databases).join(', ')}`);
|
|
console.log(`Worker jails: ${tenant.workerJails.join(', ')}`);
|
|
console.log(`Datasets: ${tenant.datasets.join(', ') || '(none)'}`);
|
|
}
|
|
|
|
function printProvisioningPlan(
|
|
plan: ReturnType<typeof planTenantProvisioning>,
|
|
): void {
|
|
console.log(
|
|
plan.alreadyExists
|
|
? `Tenant ${plan.tenant.id} already exists in registry. Declarative contract shown for review.`
|
|
: `Provisioning contract for new tenant ${plan.tenant.id}:`,
|
|
);
|
|
console.log(`Display: ${plan.tenant.displayName}`);
|
|
console.log(
|
|
`Registry entry: ${plan.declarativeChanges.addRegistryEntry ? 'would be added declaratively' : 'already present'}`,
|
|
);
|
|
console.log(`Service: ${plan.logicalResources.service}`);
|
|
console.log(`Internal domain: ${plan.logicalResources.internalDomain}`);
|
|
console.log(`Databases: ${plan.logicalResources.databases.join(', ')}`);
|
|
console.log(`Worker jails: ${plan.logicalResources.workerJails.join(', ')}`);
|
|
console.log(`Datasets: ${plan.logicalResources.datasets.join(', ') || '(none)'}`);
|
|
console.log('Excluded from this workflow:');
|
|
for (const item of plan.excludedResources) {
|
|
console.log(`- ${item}`);
|
|
}
|
|
console.log('Policy:');
|
|
for (const note of plan.policyNotes) {
|
|
console.log(`- ${note}`);
|
|
}
|
|
}
|
|
|
|
function printRemovalPlan(
|
|
plan: ReturnType<typeof planTenantRemoval>,
|
|
): void {
|
|
console.log(`Removal plan for tenant ${plan.tenant.id}:`);
|
|
console.log(`Display: ${plan.tenant.displayName}`);
|
|
console.log(
|
|
`Registry removal: ${plan.declarativeChanges.removeRegistryEntry ? 'allowed in future declarative flow' : 'blocked'}`,
|
|
);
|
|
console.log(`Service: ${plan.impacts.service}`);
|
|
console.log(`Internal domain: ${plan.impacts.internalDomain}`);
|
|
console.log(`Databases: ${plan.impacts.databases.join(', ')}`);
|
|
console.log(`Worker jails: ${plan.impacts.workerJails.join(', ')}`);
|
|
console.log(`Datasets: ${plan.impacts.datasets.join(', ') || '(none)'}`);
|
|
console.log(`Protected platform id: ${plan.protectedResources.platformId}`);
|
|
console.log(
|
|
`Protected shared services: ${plan.protectedResources.sharedServices.join(', ') || '(none)'}`,
|
|
);
|
|
console.log(
|
|
`Protected shared datasets: ${plan.protectedResources.sharedDatasets.join(', ') || '(none)'}`,
|
|
);
|
|
console.log(
|
|
`Protected shared jails: ${plan.protectedResources.sharedJails.join(', ') || '(none)'}`,
|
|
);
|
|
if (plan.blockers.length > 0) {
|
|
console.log('Blockers:');
|
|
for (const blocker of plan.blockers) {
|
|
console.log(`- ${blocker}`);
|
|
}
|
|
}
|
|
console.log('Policy:');
|
|
for (const note of plan.policyNotes) {
|
|
console.log(`- ${note}`);
|
|
}
|
|
console.log('');
|
|
console.log('No changes were made. This is a dry-run only.');
|
|
}
|
|
|
|
function printApplyPlan(
|
|
plan: ReturnType<typeof planTenantApply>,
|
|
): void {
|
|
console.log(`Apply plan for tenant ${plan.tenant.id}:`);
|
|
console.log(`Display: ${plan.tenant.displayName}`);
|
|
console.log(
|
|
`Automatic apply: ${plan.readyForAutomaticApply ? 'ready' : 'not ready'}`,
|
|
);
|
|
console.log('Contract checklist:');
|
|
for (const entry of plan.contractChecklist) {
|
|
console.log(`- [${entry.status}] ${entry.field} ${entry.value}: ${entry.detail}`);
|
|
}
|
|
const allowedDatabases = plan.resourceChecklist.databases.map((e) => e.name);
|
|
const allowedWorkerJails = plan.resourceChecklist.workerJails.map((e) => e.name);
|
|
const allowedDatasets = plan.resourceChecklist.datasets.map((e) => e.name);
|
|
console.log(`Allowed databases: ${allowedDatabases.join(', ')}`);
|
|
console.log(`Allowed worker jails: ${allowedWorkerJails.join(', ')}`);
|
|
console.log(`Allowed datasets: ${allowedDatasets.join(', ') || '(none)'}`);
|
|
console.log('Normalization hints:');
|
|
for (const hint of plan.normalizationHints) {
|
|
console.log(`- ${hint}`);
|
|
}
|
|
console.log('Resource checklist:');
|
|
for (const entry of plan.resourceChecklist.databases) {
|
|
console.log(`- [${entry.status}] database ${entry.name}: ${entry.detail}`);
|
|
}
|
|
for (const entry of plan.resourceChecklist.workerJails) {
|
|
console.log(`- [${entry.status}] worker-jail ${entry.name}: ${entry.detail}`);
|
|
}
|
|
for (const entry of plan.resourceChecklist.datasets) {
|
|
console.log(`- [${entry.status}] dataset ${entry.name}: ${entry.detail}`);
|
|
}
|
|
console.log('Action policy:');
|
|
for (const action of plan.actionPolicy.automaticCandidates) {
|
|
console.log(`- [future-auto] ${action.name}: ${action.detail}`);
|
|
}
|
|
for (const action of plan.actionPolicy.manualOnly) {
|
|
console.log(`- [manual] ${action.name}: ${action.detail}`);
|
|
}
|
|
for (const action of plan.actionPolicy.outOfScope) {
|
|
console.log(`- [out-of-scope] ${action.name}: ${action.detail}`);
|
|
}
|
|
if (plan.blockers.length > 0) {
|
|
console.log('Blockers:');
|
|
for (const blocker of plan.blockers) {
|
|
console.log(`- ${blocker}`);
|
|
}
|
|
}
|
|
console.log('Preflight:');
|
|
for (const check of plan.preflightChecks) {
|
|
console.log(`- [${check.status}] ${check.name}: ${check.detail}`);
|
|
}
|
|
console.log('Prerequisites:');
|
|
for (const item of plan.prerequisites) {
|
|
console.log(`- ${item}`);
|
|
}
|
|
console.log('Manual steps:');
|
|
for (const item of plan.manualSteps) {
|
|
console.log(`- ${item}`);
|
|
}
|
|
console.log('Policy:');
|
|
for (const note of plan.policyNotes) {
|
|
console.log(`- ${note}`);
|
|
}
|
|
console.log('');
|
|
console.log('No changes were made. This is a dry-run only.');
|
|
}
|
|
|
|
function main(argv: string[]): void {
|
|
try {
|
|
const [command, ...rest] = argv;
|
|
if (!command) usage();
|
|
|
|
if (command === 'list') {
|
|
for (const tenant of listTenantRecords()) {
|
|
console.log(
|
|
`${tenant.id}\t${tenant.service}\t${tenant.internalDomain}\t${tenant.displayName}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const options = parseOptions(rest);
|
|
if (!options.tenantId) usage();
|
|
|
|
if (command === 'show') {
|
|
const tenant = getTenantRecord(options.tenantId);
|
|
if (!tenant) {
|
|
console.error(`Unknown tenant: ${options.tenantId}`);
|
|
process.exit(1);
|
|
}
|
|
printTenantSummary(tenant);
|
|
return;
|
|
}
|
|
|
|
if (command === 'plan') {
|
|
const plan = planTenantProvisioning(
|
|
options.tenantId,
|
|
draftOptionsFromCli(options),
|
|
);
|
|
printProvisioningPlan(plan);
|
|
return;
|
|
}
|
|
|
|
if (command === 'add') {
|
|
const added = addTenantRecord(
|
|
options.tenantId,
|
|
draftOptionsFromCli(options),
|
|
);
|
|
console.log(`Added tenant ${added.id} to infra/tenants.yaml`);
|
|
printTenantSummary(added);
|
|
return;
|
|
}
|
|
|
|
if (command === 'apply') {
|
|
const plan = planTenantApply(options.tenantId);
|
|
printApplyPlan(plan);
|
|
return;
|
|
}
|
|
|
|
if (command === 'remove') {
|
|
const plan = planTenantRemoval(options.tenantId);
|
|
printRemovalPlan(plan);
|
|
return;
|
|
}
|
|
|
|
usage();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main(process.argv.slice(2));
|