Add dry-run tenant apply planning
Introduce a separate tenant-apply contract that describes what a future live apply would be allowed to touch, what prerequisites it would require, and what stays explicitly manual or out of scope. --- Build: pass | Tests: pass — 28 passed (1 file)
This commit is contained in:
parent
59c4006938
commit
36827ab478
5 changed files with 121 additions and 1 deletions
|
|
@ -155,6 +155,7 @@ Also updated:
|
|||
- `just tenant-list`
|
||||
- `just tenant-show <tenant>`
|
||||
- `just tenant-plan <tenant>`
|
||||
- `just tenant-apply <tenant>` (dry-run)
|
||||
- `just tenant-add <tenant>`
|
||||
- `just tenant-remove <tenant>` (dry-run)
|
||||
- tenant lifecycle validation is now stricter:
|
||||
|
|
@ -167,6 +168,10 @@ Also updated:
|
|||
- whether the registry entry would be added or is already present
|
||||
- what is explicitly out of scope, such as per-tenant hostd, Unix users,
|
||||
repo checkouts, or live resource creation
|
||||
- `tenant-apply` now acts as a separate dry-run apply contract:
|
||||
- what a future live apply would be allowed to touch
|
||||
- prerequisites before any host mutation could exist
|
||||
- what remains manual or explicitly out of scope
|
||||
|
||||
## Recommended first code tasks
|
||||
|
||||
|
|
@ -221,6 +226,7 @@ with live host migration:
|
|||
- define what a safe `create tenant` operation must provision as logical
|
||||
resources only, beyond writing the registry
|
||||
- keep the declarative contract explicit before any live apply step exists
|
||||
- keep any future live apply non-destructive and tenant-owned only by default
|
||||
- keep `tenant-remove` as dry-run until deletion boundaries are explicit
|
||||
- define what a safe `remove tenant` operation must refuse to delete
|
||||
- current removal planning now distinguishes:
|
||||
|
|
|
|||
5
justfile
5
justfile
|
|
@ -304,6 +304,11 @@ tenant-show tenant:
|
|||
tenant-plan tenant:
|
||||
@npx tsx scripts/tenant-lifecycle.ts plan {{ tenant }}
|
||||
|
||||
# Show what a future live apply would be allowed to touch (dry-run only)
|
||||
[group("system")]
|
||||
tenant-apply tenant:
|
||||
@npx tsx scripts/tenant-lifecycle.ts apply {{ tenant }}
|
||||
|
||||
# Add a logical tenant to the registry
|
||||
[group("system")]
|
||||
tenant-add tenant:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
addTenantRecord,
|
||||
getTenantRecord,
|
||||
planTenantApply,
|
||||
listTenantRecords,
|
||||
planTenantProvisioning,
|
||||
planTenantRemoval,
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
|
||||
function usage(): never {
|
||||
console.error(
|
||||
'Usage: npx tsx scripts/tenant-lifecycle.ts <list|show|plan|add|remove> [tenant-id]\n' +
|
||||
'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 ...]',
|
||||
);
|
||||
|
|
@ -155,6 +156,36 @@ function printRemovalPlan(
|
|||
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(`Allowed databases: ${plan.allowedResources.databases.join(', ')}`);
|
||||
console.log(`Allowed worker jails: ${plan.allowedResources.workerJails.join(', ')}`);
|
||||
console.log(`Allowed datasets: ${plan.allowedResources.datasets.join(', ') || '(none)'}`);
|
||||
if (plan.blockers.length > 0) {
|
||||
console.log('Blockers:');
|
||||
for (const blocker of plan.blockers) {
|
||||
console.log(`- ${blocker}`);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
@ -201,6 +232,12 @@ function main(argv: string[]): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === 'apply') {
|
||||
const plan = planTenantApply(options.tenantId);
|
||||
printApplyPlan(plan);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'remove') {
|
||||
const plan = planTenantRemoval(options.tenantId);
|
||||
printRemovalPlan(plan);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
deriveTenantRecord,
|
||||
getTenantRecord,
|
||||
listTenantRecords,
|
||||
planTenantApply,
|
||||
loadTenantRegistry,
|
||||
planTenantProvisioning,
|
||||
planTenantRemoval,
|
||||
|
|
@ -77,6 +78,24 @@ describe('tenant-registry', () => {
|
|||
expect(plan.declarativeChanges.addRegistryEntry).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a dry-run apply contract for a declared tenant', () => {
|
||||
const registryPath = makeTempRegistry();
|
||||
const plan = planTenantApply('mevy', registryPath);
|
||||
expect(plan.tenant.id).toBe('mevy');
|
||||
expect(plan.allowedResources.databases).toContain('mevy_brain');
|
||||
expect(plan.allowedResources.workerJails).toContain('mevy_ctrl_worker');
|
||||
expect(plan.manualSteps).toContain(
|
||||
'No live resources are created by this workflow today.',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails to build an apply contract for an unknown tenant', () => {
|
||||
const registryPath = makeTempRegistry();
|
||||
expect(() => planTenantApply('unknown', registryPath)).toThrow(
|
||||
'Unknown tenant: unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves diacritics in the default display name when the raw input differs from the slug', () => {
|
||||
const draft = deriveTenantRecord('Saša');
|
||||
expect(draft.id).toBe('sasa');
|
||||
|
|
|
|||
|
|
@ -135,6 +135,20 @@ export interface TenantProvisioningPlan {
|
|||
policyNotes: string[];
|
||||
}
|
||||
|
||||
export interface TenantApplyPlan {
|
||||
tenant: TenantRecord;
|
||||
registryPath: string;
|
||||
allowedResources: {
|
||||
databases: string[];
|
||||
workerJails: string[];
|
||||
datasets: string[];
|
||||
};
|
||||
prerequisites: string[];
|
||||
manualSteps: string[];
|
||||
blockers: string[];
|
||||
policyNotes: string[];
|
||||
}
|
||||
|
||||
let cachedRegistry: PlatformRegistry | null = null;
|
||||
|
||||
function parseTenantRecord(
|
||||
|
|
@ -647,6 +661,45 @@ export function planTenantProvisioning(
|
|||
};
|
||||
}
|
||||
|
||||
export function planTenantApply(
|
||||
tenantId: string,
|
||||
registryPath = REGISTRY_PATH,
|
||||
): TenantApplyPlan {
|
||||
const registry = loadTenantRegistry(registryPath);
|
||||
const tenant = getTenantRecord(tenantId, registry);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Unknown tenant: ${deriveTenantId(tenantId)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
tenant,
|
||||
registryPath,
|
||||
allowedResources: {
|
||||
databases: Object.values(tenant.databases),
|
||||
workerJails: tenant.workerJails,
|
||||
datasets: tenant.datasets,
|
||||
},
|
||||
prerequisites: [
|
||||
'Tenant must already exist in the registry.',
|
||||
'Platform-owned shared services must already remain outside tenant apply.',
|
||||
'Planned tenant resources must stay disjoint from shared platform resources and other tenants.',
|
||||
'Operator review is required before any future live apply writes host state.',
|
||||
],
|
||||
manualSteps: [
|
||||
'No live resources are created by this workflow today.',
|
||||
'Any future jail, dataset, or database creation must remain an explicit host-level step.',
|
||||
'Per-tenant hostd, Unix users, and repo workspaces remain out of scope.',
|
||||
],
|
||||
blockers: [],
|
||||
policyNotes: [
|
||||
'Tenant apply is dry-run planning only.',
|
||||
'Shared platform services, datasets, and jails must never be created or modified through tenant apply.',
|
||||
'Future live apply should create only tenant-owned resources and remain non-destructive by default.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function planTenantRemoval(
|
||||
tenantId: string,
|
||||
registryPath = REGISTRY_PATH,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue