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:
Mevy Assistant 2026-04-24 09:13:52 +02:00
parent 59c4006938
commit 36827ab478
5 changed files with 121 additions and 1 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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);

View file

@ -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');

View file

@ -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,