Clarify root platform ownership in reports

---
Build: pass | Tests: FAIL — Tests  11 failed | 2089 passed | 4 skipped (2104)
This commit is contained in:
Operator & Codex 2026-05-02 22:06:03 +02:00
parent ed80c822fa
commit 85646d24e8
11 changed files with 134 additions and 25 deletions

View file

@ -3,6 +3,13 @@ platform:
display_name: Clawdie
repo: Clawdie-AI
internal_base: home.arpa
services:
- clawdie
- clawdie_hostd
datasets:
- zroot/clawdie-ai
jails:
- worker
controlplane_exposure: internal
cms_admin_exposure: internal
code_admin_exposure: internal
@ -18,7 +25,6 @@ platform:
shared:
services:
- postgresql
- mevy_hostd
- cms
- web-service
- code-service

View file

@ -103,7 +103,9 @@ async function main(): Promise<void> {
console.log(`TENANT_CONTEXT_WARNING: ${warning}`);
}
console.log('SURFACES:');
for (const line of formatSurfaceMapLines(registry)) {
for (const line of formatSurfaceMapLines(registry, {
includeTenantSurfaces: TENANT_ID.trim().length > 0,
})) {
console.log(`- ${line}`);
}
} catch {

View file

@ -9,13 +9,15 @@ vi.mock('./authorized-hostd.js', () => ({
describe('platform-audit-report', () => {
it('collects observed service, jail, and dataset ownership', async () => {
// Service probes run in alphabetical order over the deduped union of
// shared.services + tenant services: cms, code-service, mevy,
// mevy_hostd, postgresql, web-service. Then jails, then datasets.
// platform.services + shared.services + tenant services:
// clawdie, clawdie_hostd, cms, code-service, mevy, postgresql, web-service.
// Then jails, then datasets.
callAuthorizedHostdMock
.mockResolvedValueOnce({ ok: false, error: 'clawdie is not running' })
.mockResolvedValueOnce({ ok: false, error: 'clawdie_hostd is not running' })
.mockResolvedValueOnce({ ok: false, error: 'cms is not running' })
.mockResolvedValueOnce({ ok: false, error: 'code-service is not running' })
.mockResolvedValueOnce({ ok: false, error: 'mevy is not running' })
.mockResolvedValueOnce({ ok: false, error: 'mevy_hostd is not running' })
.mockResolvedValueOnce({ ok: true, output: 'postgresql is running as pid 1' })
.mockResolvedValueOnce({ ok: false, error: 'web-service is not running' })
.mockResolvedValueOnce({
@ -33,7 +35,7 @@ describe('platform-audit-report', () => {
caller: 'operator',
});
expect(report.observedServices).toHaveLength(6);
expect(report.observedServices).toHaveLength(7);
expect(report.observedJails).toEqual({
shared: ['git'],
tenants: { mevy: ['mevy_ctrl_worker'] },

View file

@ -59,6 +59,7 @@ async function probeDeclaredServices(
opts: { tenantId: string; caller: HostdCaller },
): Promise<ServiceAuditStatus[]> {
const serviceNames = dedupeSorted([
...registry.platform.services,
...registry.shared.services,
...Object.values(registry.tenants).map((tenant) => tenant.service),
]);

View file

@ -14,13 +14,14 @@ const registry = loadTenantRegistry();
describe('platform-audit', () => {
it('classifies shared and tenant-owned services', () => {
expect(classifyServiceOwner('clawdie', registry)).toBe('tenant:clawdie');
expect(classifyServiceOwner('clawdie_hostd', registry)).toBe(
'tenant:clawdie',
);
expect(classifyServiceOwner('postgresql', registry)).toBe(
'shared-platform',
);
expect(classifyServiceOwner('mevy', registry)).toBe('tenant:mevy');
expect(classifyServiceOwner('mevy_hostd', registry)).toBe(
'shared-platform',
);
expect(classifyServiceOwner('unknown-service', registry)).toBe('unknown');
});
@ -29,12 +30,16 @@ describe('platform-audit', () => {
});
it('classifies shared and tenant-owned jails', () => {
expect(classifyJailOwner('worker', registry)).toBe('tenant:clawdie');
expect(classifyJailOwner('git', registry)).toBe('shared-platform');
expect(classifyJailOwner('mevy_ctrl_worker', registry)).toBe('tenant:mevy');
expect(classifyJailOwner('unknown-jail', registry)).toBe('unknown');
});
it('classifies datasets by shared ownership roots', () => {
expect(classifyDatasetOwner('zroot/clawdie-ai/pgdata', registry)).toBe(
'tenant:clawdie',
);
expect(
classifyDatasetOwner('zroot/clawdie-runtime/jails/git', registry),
).toBe('shared-platform');
@ -52,17 +57,17 @@ describe('platform-audit', () => {
it('summarizes ownership for a tenant', () => {
const summary = summarizeOwnership(
['postgresql', 'mevy', 'mevy_hostd', 'mystery'],
['postgresql', 'mevy', 'clawdie_hostd', 'mystery'],
classifyServiceOwner,
registry,
'mevy',
);
expect(summary).toEqual({
own: 1,
shared: 2,
otherTenants: 0,
shared: 1,
otherTenants: 1,
unknown: 1,
otherTenantBreakdown: {},
otherTenantBreakdown: { clawdie: 1 },
unknownItems: ['mystery'],
});
expect(
@ -73,11 +78,11 @@ describe('platform-audit', () => {
});
it('normalizes service-name comparisons (case + separator differences)', () => {
expect(classifyServiceOwner('Mevy_HostD', registry)).toBe(
'shared-platform',
expect(classifyServiceOwner('Clawdie_HostD', registry)).toBe(
'tenant:clawdie',
);
expect(classifyServiceOwner('mevy-hostd', registry)).toBe(
'shared-platform',
expect(classifyServiceOwner('clawdie-hostd', registry)).toBe(
'tenant:clawdie',
);
expect(classifyServiceOwner('MEVY', registry)).toBe('tenant:mevy');
});
@ -105,7 +110,7 @@ describe('platform-audit', () => {
it('buckets observed resources by owner', () => {
const buckets = bucketOwnedResources(
['git', 'mevy_ctrl_worker', 'mevy_hostd', 'mystery'],
['git', 'mevy_ctrl_worker', 'clawdie_hostd', 'mystery'],
(item, currentRegistry) => {
if (item.endsWith('_hostd'))
return classifyServiceOwner(item, currentRegistry);
@ -115,8 +120,9 @@ describe('platform-audit', () => {
);
expect(buckets).toEqual({
shared: ['git', 'mevy_hostd'],
shared: ['git'],
tenants: {
clawdie: ['clawdie_hostd'],
mevy: ['mevy_ctrl_worker'],
},
unknown: ['mystery'],

View file

@ -39,6 +39,13 @@ export function classifyServiceOwner(
serviceName: string,
registry: PlatformRegistry,
): ResourceOwner {
if (
registry.platform.services.some((platform) =>
aliasMatches(serviceName, platform),
)
) {
return `tenant:${registry.platform.id}`;
}
if (
registry.shared.services.some((shared) => aliasMatches(serviceName, shared))
) {
@ -58,6 +65,11 @@ export function classifyJailOwner(
jailName: string,
registry: PlatformRegistry,
): ResourceOwner {
if (
registry.platform.jails.some((platform) => aliasMatches(jailName, platform))
) {
return `tenant:${registry.platform.id}`;
}
if (registry.shared.jails.some((shared) => aliasMatches(jailName, shared))) {
return 'shared-platform';
}
@ -75,6 +87,11 @@ export function classifyDatasetOwner(
dataset: string,
registry: PlatformRegistry,
): ResourceOwner {
for (const platformDataset of registry.platform.datasets) {
if (isDatasetUnder(dataset, platformDataset)) {
return `tenant:${registry.platform.id}`;
}
}
for (const tenant of Object.values(registry.tenants)) {
for (const tenantDataset of tenant.datasets) {
if (isDatasetUnder(dataset, tenantDataset)) {

View file

@ -59,7 +59,7 @@ describe('system report rendering', () => {
const report = buildSystemReport({
observedAt: new Date('2026-04-25T21:58:00Z'),
services: [
{ label: 'Mevy agent', status: 'running' },
{ label: 'Clawdie agent', status: 'running' },
{ label: 'hostd', status: 'running' },
],
hostdReachable: true,
@ -76,7 +76,7 @@ describe('system report rendering', () => {
const rendered = renderSystemReport(report);
expect(rendered).toContain('<b>System Report</b>');
expect(rendered).toContain('<b>Observed</b>');
expect(rendered).toContain('- Mevy agent: <code>running</code>');
expect(rendered).toContain('- Clawdie agent: <code>running</code>');
expect(rendered).toContain('- Controlplane auth: <code>auth_required</code>');
expect(rendered).toContain('<b>Interpretation</b>');
expect(rendered).toContain('Controlplane auth is enforced for anonymous requests.');

View file

@ -10,6 +10,9 @@ const registry: PlatformRegistry = {
internalDomain: 'ai.home.arpa',
internalBase: 'home.arpa',
publicBase: 'example.com',
services: ['clawdie', 'clawdie_hostd'],
datasets: ['zroot/clawdie-ai'],
jails: ['worker'],
controlplaneExposure: 'public',
cmsAdminExposure: 'internal',
codeAdminExposure: 'internal',
@ -17,7 +20,7 @@ const registry: PlatformRegistry = {
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
},
shared: {
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
services: ['postgresql', 'cms', 'web-service', 'code-service'],
datasets: [],
jails: [],
},
@ -57,4 +60,18 @@ describe('formatSurfaceMapLines', () => {
]),
);
});
it('can omit tenant surfaces for root-mode displays', () => {
expect(formatSurfaceMapLines(registry, { includeTenantSurfaces: false })).toEqual(
expect.arrayContaining([
'cms-admin.internal=cms.home.arpa',
'code-admin.internal=git.home.arpa',
'controlplane.internal=ai.home.arpa',
'controlplane.public=ai.example.com',
]),
);
expect(
formatSurfaceMapLines(registry, { includeTenantSurfaces: false }).join('\n'),
).not.toContain('tenant-home.mevy');
});
});

View file

@ -1,8 +1,9 @@
import type { PlatformRegistry } from './tenant-registry.js';
import { buildSurfaceInventory } from './surface-inventory.js';
import type { SurfaceRecord } from './surface-inventory.js';
function surfaceLabel(surface: ReturnType<typeof buildSurfaceInventory>[number]): string {
function surfaceLabel(surface: SurfaceRecord): string {
switch (surface.kind) {
case 'controlplane':
return `controlplane.${surface.exposure}`;
@ -21,8 +22,17 @@ function surfaceLabel(surface: ReturnType<typeof buildSurfaceInventory>[number])
}
}
export function formatSurfaceMapLines(registry: PlatformRegistry): string[] {
export function formatSurfaceMapLines(
registry: PlatformRegistry,
opts: { includeTenantSurfaces?: boolean } = {},
): string[] {
const includeTenantSurfaces = opts.includeTenantSurfaces ?? true;
return buildSurfaceInventory(registry)
.filter((surface) =>
includeTenantSurfaces
? true
: surface.kind !== 'tenant-home' && surface.kind !== 'tenant-site',
)
.slice()
.sort((a, b) => surfaceLabel(a).localeCompare(surfaceLabel(b)))
.map((surface) => `${surfaceLabel(surface)}=${surface.host}`);

View file

@ -2012,7 +2012,7 @@ export async function handleReportCommand(
const report = buildSystemReport({
services: [
{
label: 'Mevy agent',
label: `${TENANT_DISPLAY_NAME || 'Clawdie'} agent`,
status: mainServiceRes.ok
? parseServiceStatus(mainServiceRes.output || '')
: 'unknown',

View file

@ -62,6 +62,9 @@ const PlatformRegistrySchema = z.object({
repo: z.string().optional(),
internal_base: z.string().optional(),
public_base: z.string().optional(),
services: z.array(z.string()).optional(),
datasets: z.array(z.string()).optional(),
jails: z.array(z.string()).optional(),
controlplane_exposure: z.enum(['internal', 'public']).optional(),
cms_admin_exposure: z.enum(['internal', 'public']).optional(),
code_admin_exposure: z.enum(['internal', 'public']).optional(),
@ -113,6 +116,9 @@ export interface PlatformRegistry {
internalDomain: string;
internalBase: string;
publicBase?: string;
services: string[];
datasets: string[];
jails: string[];
controlplaneExposure: 'internal' | 'public';
cmsAdminExposure: 'internal' | 'public';
codeAdminExposure: 'internal' | 'public';
@ -466,6 +472,9 @@ export function loadTenantRegistry(registryPath?: string): PlatformRegistry {
internalDomain: platformControlplaneInternalDomain(internalBase),
internalBase: internalBase || 'home.arpa',
publicBase: parsed.platform.public_base,
services: parsed.platform.services || [],
datasets: parsed.platform.datasets || [],
jails: parsed.platform.jails || [],
controlplaneExposure: parsed.platform.controlplane_exposure || 'internal',
cmsAdminExposure: parsed.platform.cms_admin_exposure || 'internal',
codeAdminExposure: parsed.platform.code_admin_exposure || 'internal',
@ -541,9 +550,17 @@ function validateTenantRecord(
const sharedJailAliases = new Set(
registry.shared.jails.map(canonicalResourceAlias),
);
const platformServiceAliases = new Set(
registry.platform.services.map(canonicalResourceAlias),
);
const platformJailAliases = new Set(
registry.platform.jails.map(canonicalResourceAlias),
);
const reservedIdAliases = new Set([
canonicalResourceAlias(registry.platform.id),
...registry.platform.reservedHostLabels.map(canonicalHostLabel),
...platformServiceAliases,
...platformJailAliases,
...sharedServiceAliases,
...sharedJailAliases,
]);
@ -556,9 +573,17 @@ function validateTenantRecord(
`Tenant service conflicts with shared service: ${candidate.service}`,
);
}
if (platformServiceAliases.has(canonicalResourceAlias(candidate.service))) {
throw new Error(
`Tenant service conflicts with platform service: ${candidate.service}`,
);
}
if (sharedJailAliases.has(canonicalResourceAlias(candidate.service))) {
throw new Error(`Tenant service conflicts with shared jail: ${candidate.service}`);
}
if (platformJailAliases.has(canonicalResourceAlias(candidate.service))) {
throw new Error(`Tenant service conflicts with platform jail: ${candidate.service}`);
}
const sharedDatasetOverlap = findDatasetOverlap(
candidate.datasets,
@ -569,6 +594,15 @@ function validateTenantRecord(
`Tenant dataset overlaps shared platform dataset: ${sharedDatasetOverlap.left} vs ${sharedDatasetOverlap.right}`,
);
}
const platformDatasetOverlap = findDatasetOverlap(
candidate.datasets,
registry.platform.datasets,
);
if (platformDatasetOverlap) {
throw new Error(
`Tenant dataset overlaps platform dataset: ${platformDatasetOverlap.left} vs ${platformDatasetOverlap.right}`,
);
}
if (
hasCanonicalOverlap(
@ -581,6 +615,17 @@ function validateTenantRecord(
`Tenant worker jails overlap shared platform jails: ${candidate.id}`,
);
}
if (
hasCanonicalOverlap(
candidate.workerJails,
registry.platform.jails,
canonicalResourceAlias,
)
) {
throw new Error(
`Tenant worker jails overlap platform jails: ${candidate.id}`,
);
}
if (
canonicalDomain(candidate.internalDomain) ===
@ -751,6 +796,9 @@ function buildPlainRegistry(registry: PlatformRegistry): Record<string, unknown>
repo: registry.platform.repo,
internal_base: registry.platform.internalBase,
public_base: registry.platform.publicBase,
services: registry.platform.services,
datasets: registry.platform.datasets,
jails: registry.platform.jails,
controlplane_exposure: registry.platform.controlplaneExposure,
cms_admin_exposure: registry.platform.cmsAdminExposure,
code_admin_exposure: registry.platform.codeAdminExposure,