diff --git a/docs/internal/MULTITENANT-HANDOFF.md b/docs/internal/MULTITENANT-HANDOFF.md index ea644dd..7bda765 100644 --- a/docs/internal/MULTITENANT-HANDOFF.md +++ b/docs/internal/MULTITENANT-HANDOFF.md @@ -155,6 +155,7 @@ Also updated: - `just tenant-list` - `just tenant-show ` - `just tenant-plan ` + - `just tenant-apply ` (dry-run) - `just tenant-add ` - `just tenant-remove ` (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: diff --git a/justfile b/justfile index c933871..576bbee 100644 --- a/justfile +++ b/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: diff --git a/scripts/tenant-lifecycle.ts b/scripts/tenant-lifecycle.ts index f08a022..a6461be 100644 --- a/scripts/tenant-lifecycle.ts +++ b/scripts/tenant-lifecycle.ts @@ -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 [tenant-id]\n' + + 'Usage: npx tsx scripts/tenant-lifecycle.ts [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, +): 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); diff --git a/src/tenant-registry.test.ts b/src/tenant-registry.test.ts index d2172f1..e04c125 100644 --- a/src/tenant-registry.test.ts +++ b/src/tenant-registry.test.ts @@ -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'); diff --git a/src/tenant-registry.ts b/src/tenant-registry.ts index 4cb9e5c..c19a7e4 100644 --- a/src/tenant-registry.ts +++ b/src/tenant-registry.ts @@ -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,