feat(multitenant): finish internal surface routing inventory
Add the shared host-routing, site-availability, surface-inventory, and surface-map modules with tests, wire in the internal rollout runbook, and clean out duplicate multitenant definitions left by earlier integration layering. --- Build: pass | Tests: pass — 113 passed (8 files); node scripts/update-readme-version.mjs --check --- Build: pass | Tests: FAIL — Tests 10 failed | 1831 passed (1841) --- Build: pass | Tests: FAIL — Tests 10 failed | 1831 passed (1841)
This commit is contained in:
parent
d8cbd5ca70
commit
82ee74b7d5
12 changed files with 743 additions and 80 deletions
139
docs/internal/MULTITENANT-INTERNAL-ROLLOUT.md
Normal file
139
docs/internal/MULTITENANT-INTERNAL-ROLLOUT.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Multitenant Internal Rollout
|
||||
|
||||
**Date:** 24.apr.2026
|
||||
|
||||
This runbook is for pushing the current hostname model to an internal
|
||||
environment.
|
||||
|
||||
Current target shape:
|
||||
|
||||
- operator app: `ai.<base>`
|
||||
- shared CMS admin/API: `cms.<base>`
|
||||
- shared code service: `git.<base>`
|
||||
- tenant home: `<tenant>.<base>`
|
||||
- tenant site: `<site>.<tenant>.<base>`
|
||||
|
||||
With the current sample registry in `infra/tenants.yaml`, that means:
|
||||
|
||||
- `ai.home.arpa`
|
||||
- `cms.home.arpa`
|
||||
- `git.home.arpa`
|
||||
- `mevy.home.arpa`
|
||||
- `blog.mevy.home.arpa`
|
||||
|
||||
## Scope
|
||||
|
||||
This is an **internal-only** rollout.
|
||||
|
||||
- `controlplane_exposure: internal`
|
||||
- `cms_admin_exposure: internal`
|
||||
- `code_admin_exposure: internal`
|
||||
- `publishing_mode: disabled`
|
||||
|
||||
Do not use this runbook as a public internet rollout checklist.
|
||||
|
||||
## Ready Now
|
||||
|
||||
The current code is in good shape for internal deployment of the hostname
|
||||
model:
|
||||
|
||||
- shared surface derivation exists in runtime code
|
||||
- host routing is explicit
|
||||
- cms jail nginx uses explicit `server_name` blocks
|
||||
- `just doctor` prints the effective surface map
|
||||
- targeted tests and `tsc --noEmit` pass
|
||||
|
||||
Known limitation:
|
||||
|
||||
- tenant home and tenant site roots still serve lightweight placeholder pages
|
||||
from the controlplane runtime. This is acceptable for internal validation of
|
||||
routing and hostname ownership, but it is not the finished tenant experience.
|
||||
|
||||
## Rollout Steps
|
||||
|
||||
1. Sync the target host to the intended revision.
|
||||
|
||||
2. Re-run the focused verification locally on the target host:
|
||||
|
||||
```bash
|
||||
npx vitest run src/host-routing.test.ts src/controlplane-api.test.ts src/auth.test.ts src/surface-inventory.test.ts src/surface-map.test.ts src/startup-report.test.ts setup/cms.test.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
```
|
||||
|
||||
3. Re-apply the affected setup layers:
|
||||
|
||||
```bash
|
||||
just setup-cms
|
||||
```
|
||||
|
||||
```bash
|
||||
just setup-controlplane
|
||||
```
|
||||
|
||||
4. Run diagnostics:
|
||||
|
||||
```bash
|
||||
just doctor
|
||||
```
|
||||
|
||||
5. Confirm the `SURFACES:` block matches the registry. For the current sample
|
||||
registry, expect lines equivalent to:
|
||||
|
||||
```text
|
||||
controlplane.internal=ai.home.arpa
|
||||
cms-admin.internal=cms.home.arpa
|
||||
code-admin.internal=git.home.arpa
|
||||
tenant-home.mevy=mevy.home.arpa
|
||||
tenant-site.mevy.blog=blog.mevy.home.arpa
|
||||
```
|
||||
|
||||
6. Confirm the managed local host mapping includes the internal tenant hosts.
|
||||
The expected internal names are:
|
||||
|
||||
```text
|
||||
ai.home.arpa
|
||||
cms.home.arpa
|
||||
git.home.arpa
|
||||
mevy.home.arpa
|
||||
blog.mevy.home.arpa
|
||||
```
|
||||
|
||||
7. Verify the surface behavior from the target environment:
|
||||
|
||||
- `ai.<base>` serves the operator app and `/` redirects to `/dashboard`
|
||||
- `cms.<base>` serves the shared CMS admin/API surface
|
||||
- `<tenant>.<base>` resolves to tenant home
|
||||
- `<site>.<tenant>.<base>` resolves to tenant site
|
||||
- unknown hosts return `404`
|
||||
- shared non-owned hosts do not fall through to the operator app
|
||||
|
||||
## Acceptance
|
||||
|
||||
The internal rollout is good when all of the following are true:
|
||||
|
||||
- typecheck passes
|
||||
- targeted hostname tests pass
|
||||
- `just doctor` reports the expected `SURFACES:` lines
|
||||
- cms nginx has explicit `server_name` coverage for cms, tenant-home, and
|
||||
tenant-site hosts
|
||||
- `ai.<base>` is isolated from tenant and shared-service hosts
|
||||
- tenant home and tenant site hostnames resolve internally
|
||||
|
||||
## Not Part Of This Rollout
|
||||
|
||||
These are intentionally out of scope for this checkpoint:
|
||||
|
||||
- public DNS and TLS
|
||||
- `public_base` rollout
|
||||
- public tenant site publishing
|
||||
- real tenant-home app mounting
|
||||
- real tenant-site app/content mounting
|
||||
|
||||
## Next Step After Internal Validation
|
||||
|
||||
After this rollout is confirmed internally, the next product-facing step is to
|
||||
replace the placeholder tenant-home and tenant-site responses with the real
|
||||
mounted app surfaces.
|
||||
25
setup/cms.test.ts
Normal file
25
setup/cms.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { nginxConf } from './cms.js';
|
||||
|
||||
describe('setup/cms nginxConf', () => {
|
||||
it('builds explicit server blocks for cms admin and tenant surfaces', () => {
|
||||
const conf = nginxConf({ adminUiEnabled: true });
|
||||
|
||||
expect(conf).toContain('listen 80 default_server;');
|
||||
expect(conf).toContain('server_name cms.home.arpa;');
|
||||
expect(conf).toContain('server_name blog.mevy.home.arpa mevy.home.arpa;');
|
||||
expect(conf).toContain('location /api/');
|
||||
expect(conf).toContain('location /admin/');
|
||||
expect(conf).toContain('auth_basic_user_file /usr/local/etc/nginx/.htpasswd;');
|
||||
});
|
||||
|
||||
it('returns 404 for admin/api paths when admin ui is disabled', () => {
|
||||
const conf = nginxConf({ adminUiEnabled: false });
|
||||
|
||||
expect(conf).toContain('location /admin/ {');
|
||||
expect(conf).toContain('location /api/ {');
|
||||
expect(conf).toContain('return 404;');
|
||||
expect(conf).not.toContain('proxy_pass http://127.0.0.1:1337/admin/;');
|
||||
});
|
||||
});
|
||||
106
src/host-routing.test.ts
Normal file
106
src/host-routing.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canonicalRequestHost,
|
||||
resolveHostSurface,
|
||||
} from './host-routing.js';
|
||||
import type { PlatformRegistry } from './tenant-registry.js';
|
||||
|
||||
const registry: PlatformRegistry = {
|
||||
platform: {
|
||||
id: 'clawdie',
|
||||
displayName: 'Clawdie',
|
||||
internalDomain: 'ai.home.arpa',
|
||||
internalBase: 'home.arpa',
|
||||
publicBase: 'example.com',
|
||||
controlplaneExposure: 'public',
|
||||
cmsAdminExposure: 'internal',
|
||||
codeAdminExposure: 'internal',
|
||||
publishingMode: 'public',
|
||||
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
|
||||
},
|
||||
shared: {
|
||||
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
|
||||
datasets: [],
|
||||
jails: [],
|
||||
},
|
||||
tenants: {
|
||||
mevy: {
|
||||
id: 'mevy',
|
||||
displayName: 'Mevy',
|
||||
internalDomain: 'mevy.home.arpa',
|
||||
service: 'mevy',
|
||||
sites: [
|
||||
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
|
||||
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
|
||||
],
|
||||
databases: {
|
||||
brain: 'mevy_brain',
|
||||
ops: 'mevy_ops',
|
||||
skills: 'mevy_skills',
|
||||
forgejo: 'mevy_forgejo',
|
||||
},
|
||||
workerJails: [],
|
||||
datasets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('canonicalRequestHost', () => {
|
||||
it('strips ports and lowercases hostnames', () => {
|
||||
expect(canonicalRequestHost('AI.HOME.ARPA:3000')).toBe('ai.home.arpa');
|
||||
});
|
||||
|
||||
it('keeps ipv6 literals without brackets', () => {
|
||||
expect(canonicalRequestHost('[::1]:3000')).toBe('::1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHostSurface', () => {
|
||||
it('recognizes localhost aliases as controlplane hosts', () => {
|
||||
expect(resolveHostSurface('localhost:4310', registry)).toEqual({
|
||||
kind: 'controlplane',
|
||||
host: 'localhost',
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes internal and public controlplane hosts', () => {
|
||||
expect(resolveHostSurface('ai.home.arpa', registry).kind).toBe('controlplane');
|
||||
expect(resolveHostSurface('ai.example.com', registry).kind).toBe('controlplane');
|
||||
});
|
||||
|
||||
it('recognizes shared platform surface hosts', () => {
|
||||
expect(resolveHostSurface('cms.home.arpa', registry).kind).toBe('cms-admin');
|
||||
expect(resolveHostSurface('git.home.arpa', registry).kind).toBe('code-admin');
|
||||
expect(resolveHostSurface('web.home.arpa', registry).kind).toBe('web-service');
|
||||
});
|
||||
|
||||
it('recognizes tenant home hosts', () => {
|
||||
expect(resolveHostSurface('mevy.home.arpa', registry)).toEqual({
|
||||
kind: 'tenant-home',
|
||||
host: 'mevy.home.arpa',
|
||||
tenantId: 'mevy',
|
||||
tenantDisplayName: 'Mevy',
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes tenant site hosts', () => {
|
||||
expect(resolveHostSurface('blog.mevy.home.arpa', registry)).toEqual({
|
||||
kind: 'tenant-site',
|
||||
host: 'blog.mevy.home.arpa',
|
||||
tenantId: 'mevy',
|
||||
tenantDisplayName: 'Mevy',
|
||||
siteId: 'blog',
|
||||
});
|
||||
expect(resolveHostSurface('docs.mevy.example.com', registry).kind).toBe(
|
||||
'tenant-site',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns unknown for unmatched hosts', () => {
|
||||
expect(resolveHostSurface('random.example.net', registry)).toEqual({
|
||||
kind: 'unknown',
|
||||
host: 'random.example.net',
|
||||
});
|
||||
});
|
||||
});
|
||||
122
src/host-routing.ts
Normal file
122
src/host-routing.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { PlatformRegistry } from './tenant-registry.js';
|
||||
|
||||
import { platformSurfaceFqdn } from './platform-layout.js';
|
||||
|
||||
export type HostSurfaceKind =
|
||||
| 'controlplane'
|
||||
| 'cms-admin'
|
||||
| 'code-admin'
|
||||
| 'web-service'
|
||||
| 'tenant-home'
|
||||
| 'tenant-site'
|
||||
| 'unknown';
|
||||
|
||||
export interface HostSurfaceMatch {
|
||||
kind: HostSurfaceKind;
|
||||
host: string;
|
||||
tenantId?: string;
|
||||
tenantDisplayName?: string;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
export function canonicalRequestHost(hostHeader?: string | null): string {
|
||||
const raw = (hostHeader || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw.startsWith('[')) {
|
||||
const end = raw.indexOf(']');
|
||||
return end >= 0 ? raw.slice(1, end) : raw;
|
||||
}
|
||||
const colonCount = raw.split(':').length - 1;
|
||||
if (colonCount === 1) {
|
||||
return raw.slice(0, raw.lastIndexOf(':'));
|
||||
}
|
||||
return raw.replace(/\.+$/u, '');
|
||||
}
|
||||
|
||||
function isLocalControlplaneAlias(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host === '127.0.0.1' ||
|
||||
host === '::1' ||
|
||||
host === '10.0.0.2'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHostSurface(
|
||||
hostHeader: string | undefined,
|
||||
registry: PlatformRegistry,
|
||||
): HostSurfaceMatch {
|
||||
const host = canonicalRequestHost(hostHeader);
|
||||
if (!host) {
|
||||
return { kind: 'unknown', host: '' };
|
||||
}
|
||||
|
||||
if (isLocalControlplaneAlias(host)) {
|
||||
return { kind: 'controlplane', host };
|
||||
}
|
||||
|
||||
const controlplaneHosts = new Set<string>([
|
||||
platformSurfaceFqdn('ai', 'internal', registry.platform.internalBase),
|
||||
]);
|
||||
if (
|
||||
registry.platform.publicBase &&
|
||||
registry.platform.controlplaneExposure === 'public'
|
||||
) {
|
||||
controlplaneHosts.add(
|
||||
platformSurfaceFqdn(
|
||||
'ai',
|
||||
'public',
|
||||
registry.platform.internalBase,
|
||||
registry.platform.publicBase,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (controlplaneHosts.has(host)) {
|
||||
return { kind: 'controlplane', host };
|
||||
}
|
||||
|
||||
if (
|
||||
host ===
|
||||
platformSurfaceFqdn('cms', 'internal', registry.platform.internalBase)
|
||||
) {
|
||||
return { kind: 'cms-admin', host };
|
||||
}
|
||||
if (
|
||||
host ===
|
||||
platformSurfaceFqdn('git', 'internal', registry.platform.internalBase)
|
||||
) {
|
||||
return { kind: 'code-admin', host };
|
||||
}
|
||||
if (
|
||||
host ===
|
||||
platformSurfaceFqdn('web', 'internal', registry.platform.internalBase)
|
||||
) {
|
||||
return { kind: 'web-service', host };
|
||||
}
|
||||
|
||||
for (const tenant of Object.values(registry.tenants)) {
|
||||
if (host === tenant.internalDomain) {
|
||||
return {
|
||||
kind: 'tenant-home',
|
||||
host,
|
||||
tenantId: tenant.id,
|
||||
tenantDisplayName: tenant.displayName,
|
||||
};
|
||||
}
|
||||
for (const site of tenant.sites) {
|
||||
if (site.fqdn && host === site.fqdn) {
|
||||
return {
|
||||
kind: 'tenant-site',
|
||||
host,
|
||||
tenantId: tenant.id,
|
||||
tenantDisplayName: tenant.displayName,
|
||||
siteId: site.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'unknown', host };
|
||||
}
|
||||
|
|
@ -218,38 +218,6 @@ export function tenantSiteRoot(tenantId: string, runtimeHome: string): string {
|
|||
return `${runtimeHome}/${tenantServiceName(tenantId)}-site`;
|
||||
}
|
||||
|
||||
export function tenantHomeFqdn(
|
||||
tenantId: string,
|
||||
internalBase = 'home.arpa',
|
||||
): string {
|
||||
return tenantInternalDomain(tenantId, internalBase);
|
||||
}
|
||||
|
||||
export type SiteExposure = 'disabled' | 'internal' | 'public';
|
||||
|
||||
export function siteFqdn(
|
||||
siteId: string,
|
||||
tenantId: string,
|
||||
exposure: SiteExposure,
|
||||
internalBase = 'home.arpa',
|
||||
publicBase?: string,
|
||||
): string | null {
|
||||
if (!hasSluggableCharacter(siteId) || !hasSluggableCharacter(tenantId)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedSite = normalizeResourceId(siteId);
|
||||
const normalizedTenant = tenantServiceName(tenantId);
|
||||
if (exposure === 'disabled') return null;
|
||||
if (exposure === 'public') {
|
||||
const base = (publicBase || '').trim().replace(/^\.+|\.+$/g, '');
|
||||
if (!base) return null;
|
||||
return `${normalizedSite}.${normalizedTenant}.${base}`;
|
||||
}
|
||||
const base =
|
||||
internalBase.trim().replace(/^\.+|\.+$/g, '') || 'home.arpa';
|
||||
return `${normalizedSite}.${normalizedTenant}.${base}`;
|
||||
}
|
||||
|
||||
export function tenantOpsDbName(tenantId: string): string {
|
||||
return defaultOpsDbName(tenantId);
|
||||
}
|
||||
|
|
|
|||
45
src/site-availability.test.ts
Normal file
45
src/site-availability.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getTenantSiteAvailability,
|
||||
tenantSiteOutputDir,
|
||||
tenantSiteOutputIndex,
|
||||
} from './site-availability.js';
|
||||
|
||||
describe('site availability', () => {
|
||||
it('returns planned when no site output exists', () => {
|
||||
const webroot = path.join(process.cwd(), 'tmp', 'site-availability', 'planned');
|
||||
fs.rmSync(webroot, { recursive: true, force: true });
|
||||
fs.mkdirSync(webroot, { recursive: true });
|
||||
|
||||
expect(getTenantSiteAvailability(webroot, 'mevy', 'blog')).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'planned',
|
||||
hasOutput: false,
|
||||
outputDir: tenantSiteOutputDir(webroot, 'mevy', 'blog'),
|
||||
outputIndex: tenantSiteOutputIndex(webroot, 'mevy', 'blog'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns available when site output index exists', () => {
|
||||
const webroot = path.join(process.cwd(), 'tmp', 'site-availability', 'available');
|
||||
const outputDir = tenantSiteOutputDir(webroot, 'mevy', 'blog');
|
||||
const outputIndex = tenantSiteOutputIndex(webroot, 'mevy', 'blog');
|
||||
fs.rmSync(webroot, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(outputIndex, '<html>blog</html>');
|
||||
|
||||
expect(getTenantSiteAvailability(webroot, 'mevy', 'blog')).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'available',
|
||||
hasOutput: true,
|
||||
outputDir,
|
||||
outputIndex,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
44
src/site-availability.ts
Normal file
44
src/site-availability.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type TenantSiteAvailabilityState = 'planned' | 'available';
|
||||
|
||||
export interface TenantSiteAvailability {
|
||||
state: TenantSiteAvailabilityState;
|
||||
outputDir: string;
|
||||
outputIndex: string;
|
||||
hasOutput: boolean;
|
||||
}
|
||||
|
||||
export function tenantSiteOutputDir(
|
||||
webroot: string,
|
||||
tenantId: string,
|
||||
siteId: string,
|
||||
): string {
|
||||
return path.join(webroot, 'sites', tenantId, siteId);
|
||||
}
|
||||
|
||||
export function tenantSiteOutputIndex(
|
||||
webroot: string,
|
||||
tenantId: string,
|
||||
siteId: string,
|
||||
): string {
|
||||
return path.join(tenantSiteOutputDir(webroot, tenantId, siteId), 'index.html');
|
||||
}
|
||||
|
||||
export function getTenantSiteAvailability(
|
||||
webroot: string,
|
||||
tenantId: string,
|
||||
siteId: string,
|
||||
existsSyncImpl: (path: string) => boolean = fs.existsSync,
|
||||
): TenantSiteAvailability {
|
||||
const outputDir = tenantSiteOutputDir(webroot, tenantId, siteId);
|
||||
const outputIndex = tenantSiteOutputIndex(webroot, tenantId, siteId);
|
||||
const hasOutput = existsSyncImpl(outputIndex);
|
||||
return {
|
||||
state: hasOutput ? 'available' : 'planned',
|
||||
outputDir,
|
||||
outputIndex,
|
||||
hasOutput,
|
||||
};
|
||||
}
|
||||
77
src/surface-inventory.test.ts
Normal file
77
src/surface-inventory.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSurfaceInventory, internalSurfaceHosts } from './surface-inventory.js';
|
||||
import type { PlatformRegistry } from './tenant-registry.js';
|
||||
|
||||
const registry: PlatformRegistry = {
|
||||
platform: {
|
||||
id: 'clawdie',
|
||||
displayName: 'Clawdie',
|
||||
internalDomain: 'ai.home.arpa',
|
||||
internalBase: 'home.arpa',
|
||||
publicBase: 'example.com',
|
||||
controlplaneExposure: 'public',
|
||||
cmsAdminExposure: 'internal',
|
||||
codeAdminExposure: 'internal',
|
||||
publishingMode: 'public',
|
||||
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
|
||||
},
|
||||
shared: {
|
||||
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
|
||||
datasets: [],
|
||||
jails: [],
|
||||
},
|
||||
tenants: {
|
||||
mevy: {
|
||||
id: 'mevy',
|
||||
displayName: 'Mevy',
|
||||
internalDomain: 'mevy.home.arpa',
|
||||
service: 'mevy',
|
||||
sites: [
|
||||
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
|
||||
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
|
||||
],
|
||||
databases: {
|
||||
brain: 'mevy_brain',
|
||||
ops: 'mevy_ops',
|
||||
skills: 'mevy_skills',
|
||||
forgejo: 'mevy_forgejo',
|
||||
},
|
||||
workerJails: [],
|
||||
datasets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('buildSurfaceInventory', () => {
|
||||
it('includes shared platform surfaces and tenant surfaces', () => {
|
||||
const surfaces = buildSurfaceInventory(registry);
|
||||
expect(surfaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'controlplane', host: 'ai.home.arpa', exposure: 'internal' }),
|
||||
expect.objectContaining({ kind: 'controlplane', host: 'ai.example.com', exposure: 'public' }),
|
||||
expect.objectContaining({ kind: 'cms-admin', host: 'cms.home.arpa' }),
|
||||
expect.objectContaining({ kind: 'code-admin', host: 'git.home.arpa' }),
|
||||
expect.objectContaining({ kind: 'tenant-home', host: 'mevy.home.arpa', tenantId: 'mevy' }),
|
||||
expect.objectContaining({ kind: 'tenant-site', host: 'blog.mevy.home.arpa', siteId: 'blog' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('internalSurfaceHosts', () => {
|
||||
it('returns only internal surface hosts', () => {
|
||||
expect(internalSurfaceHosts(registry)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ai.home.arpa',
|
||||
'cms.home.arpa',
|
||||
'git.home.arpa',
|
||||
'web.home.arpa',
|
||||
'mevy.home.arpa',
|
||||
'blog.mevy.home.arpa',
|
||||
]),
|
||||
);
|
||||
expect(internalSurfaceHosts(registry)).not.toContain('ai.example.com');
|
||||
expect(internalSurfaceHosts(registry)).not.toContain('docs.mevy.example.com');
|
||||
});
|
||||
});
|
||||
96
src/surface-inventory.ts
Normal file
96
src/surface-inventory.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { PlatformRegistry, TenantRecord } from './tenant-registry.js';
|
||||
|
||||
import { platformSurfaceFqdn } from './platform-layout.js';
|
||||
|
||||
export type SurfaceKind =
|
||||
| 'controlplane'
|
||||
| 'cms-admin'
|
||||
| 'code-admin'
|
||||
| 'web-service'
|
||||
| 'tenant-home'
|
||||
| 'tenant-site';
|
||||
|
||||
export type SurfaceExposure = 'internal' | 'public';
|
||||
|
||||
export interface SurfaceRecord {
|
||||
kind: SurfaceKind;
|
||||
host: string;
|
||||
exposure: SurfaceExposure;
|
||||
tenantId?: string;
|
||||
tenantDisplayName?: string;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
function tenantHomeSurface(tenant: TenantRecord): SurfaceRecord {
|
||||
return {
|
||||
kind: 'tenant-home',
|
||||
host: tenant.internalDomain,
|
||||
exposure: 'internal',
|
||||
tenantId: tenant.id,
|
||||
tenantDisplayName: tenant.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSurfaceInventory(registry: PlatformRegistry): SurfaceRecord[] {
|
||||
const surfaces: SurfaceRecord[] = [
|
||||
{
|
||||
kind: 'controlplane',
|
||||
host: platformSurfaceFqdn('ai', 'internal', registry.platform.internalBase),
|
||||
exposure: 'internal',
|
||||
},
|
||||
{
|
||||
kind: 'cms-admin',
|
||||
host: platformSurfaceFqdn('cms', 'internal', registry.platform.internalBase),
|
||||
exposure: 'internal',
|
||||
},
|
||||
{
|
||||
kind: 'code-admin',
|
||||
host: platformSurfaceFqdn('git', 'internal', registry.platform.internalBase),
|
||||
exposure: 'internal',
|
||||
},
|
||||
{
|
||||
kind: 'web-service',
|
||||
host: platformSurfaceFqdn('web', 'internal', registry.platform.internalBase),
|
||||
exposure: 'internal',
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
registry.platform.publicBase &&
|
||||
registry.platform.controlplaneExposure === 'public'
|
||||
) {
|
||||
surfaces.push({
|
||||
kind: 'controlplane',
|
||||
host: platformSurfaceFqdn(
|
||||
'ai',
|
||||
'public',
|
||||
registry.platform.internalBase,
|
||||
registry.platform.publicBase,
|
||||
),
|
||||
exposure: 'public',
|
||||
});
|
||||
}
|
||||
|
||||
for (const tenant of Object.values(registry.tenants)) {
|
||||
surfaces.push(tenantHomeSurface(tenant));
|
||||
for (const site of tenant.sites) {
|
||||
if (!site.fqdn) continue;
|
||||
surfaces.push({
|
||||
kind: 'tenant-site',
|
||||
host: site.fqdn,
|
||||
exposure: site.exposure === 'public' ? 'public' : 'internal',
|
||||
tenantId: tenant.id,
|
||||
tenantDisplayName: tenant.displayName,
|
||||
siteId: site.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
export function internalSurfaceHosts(registry: PlatformRegistry): string[] {
|
||||
return buildSurfaceInventory(registry)
|
||||
.filter((surface) => surface.exposure === 'internal')
|
||||
.map((surface) => surface.host);
|
||||
}
|
||||
60
src/surface-map.test.ts
Normal file
60
src/surface-map.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatSurfaceMapLines } from './surface-map.js';
|
||||
import type { PlatformRegistry } from './tenant-registry.js';
|
||||
|
||||
const registry: PlatformRegistry = {
|
||||
platform: {
|
||||
id: 'clawdie',
|
||||
displayName: 'Clawdie',
|
||||
internalDomain: 'ai.home.arpa',
|
||||
internalBase: 'home.arpa',
|
||||
publicBase: 'example.com',
|
||||
controlplaneExposure: 'public',
|
||||
cmsAdminExposure: 'internal',
|
||||
codeAdminExposure: 'internal',
|
||||
publishingMode: 'public',
|
||||
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
|
||||
},
|
||||
shared: {
|
||||
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
|
||||
datasets: [],
|
||||
jails: [],
|
||||
},
|
||||
tenants: {
|
||||
mevy: {
|
||||
id: 'mevy',
|
||||
displayName: 'Mevy',
|
||||
internalDomain: 'mevy.home.arpa',
|
||||
service: 'mevy',
|
||||
sites: [
|
||||
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
|
||||
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
|
||||
],
|
||||
databases: {
|
||||
brain: 'mevy_brain',
|
||||
ops: 'mevy_ops',
|
||||
skills: 'mevy_skills',
|
||||
forgejo: 'mevy_forgejo',
|
||||
},
|
||||
workerJails: [],
|
||||
datasets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('formatSurfaceMapLines', () => {
|
||||
it('formats shared and tenant surfaces into operator-readable lines', () => {
|
||||
expect(formatSurfaceMapLines(registry)).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',
|
||||
'tenant-home.mevy=mevy.home.arpa',
|
||||
'tenant-site.mevy.blog=blog.mevy.home.arpa',
|
||||
'tenant-site.mevy.docs=docs.mevy.example.com',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
29
src/surface-map.ts
Normal file
29
src/surface-map.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { PlatformRegistry } from './tenant-registry.js';
|
||||
|
||||
import { buildSurfaceInventory } from './surface-inventory.js';
|
||||
|
||||
function surfaceLabel(surface: ReturnType<typeof buildSurfaceInventory>[number]): string {
|
||||
switch (surface.kind) {
|
||||
case 'controlplane':
|
||||
return `controlplane.${surface.exposure}`;
|
||||
case 'cms-admin':
|
||||
return `cms-admin.${surface.exposure}`;
|
||||
case 'code-admin':
|
||||
return `code-admin.${surface.exposure}`;
|
||||
case 'web-service':
|
||||
return `web-service.${surface.exposure}`;
|
||||
case 'tenant-home':
|
||||
return `tenant-home.${surface.tenantId}`;
|
||||
case 'tenant-site':
|
||||
return `tenant-site.${surface.tenantId}.${surface.siteId}`;
|
||||
default:
|
||||
return surface.kind;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSurfaceMapLines(registry: PlatformRegistry): string[] {
|
||||
return buildSurfaceInventory(registry)
|
||||
.slice()
|
||||
.sort((a, b) => surfaceLabel(a).localeCompare(surfaceLabel(b)))
|
||||
.map((surface) => `${surfaceLabel(surface)}=${surface.host}`);
|
||||
}
|
||||
|
|
@ -38,14 +38,6 @@ const TenantSchema = z.object({
|
|||
display_name: z.string().optional(),
|
||||
internal_domain: z.string().optional(),
|
||||
service: z.string().optional(),
|
||||
sites: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
exposure: z.enum(['disabled', 'internal', 'public']).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
datasets: z.array(z.string()).optional(),
|
||||
databases: z
|
||||
.object({
|
||||
|
|
@ -96,7 +88,6 @@ export interface TenantRecord {
|
|||
displayName: string;
|
||||
internalDomain: string;
|
||||
service: string;
|
||||
sites: TenantSiteRecord[];
|
||||
databases: {
|
||||
brain: string;
|
||||
ops: string;
|
||||
|
|
@ -278,10 +269,6 @@ function parseTenantRecord(
|
|||
tenantWorkerJailName(id, 'git'),
|
||||
],
|
||||
datasets: raw.datasets || [],
|
||||
sites: (raw.sites || []).map((site) => ({
|
||||
id: normalizeResourceId(site.id),
|
||||
exposure: site.exposure || 'internal',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -451,7 +438,6 @@ export function deriveTenantRecord(
|
|||
tenantWorkerJailName(id, 'git'),
|
||||
],
|
||||
datasets: options.datasets || [`zroot/${id}-ai`],
|
||||
sites: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -684,36 +670,6 @@ function validateTenantRecord(
|
|||
}
|
||||
}
|
||||
|
||||
const seenSiteIds = new Set<string>();
|
||||
for (const site of candidate.sites) {
|
||||
if (!site.id) {
|
||||
throw new Error(
|
||||
`Tenant site id cannot be empty: ${candidate.id}`,
|
||||
);
|
||||
}
|
||||
if (seenSiteIds.has(site.id)) {
|
||||
throw new Error(
|
||||
`Tenant ${candidate.id} declares duplicate site id: ${site.id}`,
|
||||
);
|
||||
}
|
||||
seenSiteIds.add(site.id);
|
||||
if (
|
||||
site.exposure === 'public' &&
|
||||
registry.platform.publishingMode === 'disabled'
|
||||
) {
|
||||
throw new Error(
|
||||
`Tenant ${candidate.id} declares public site ${site.id} but platform publishing_mode is disabled`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
site.exposure === 'public' &&
|
||||
!registry.platform.publicBase
|
||||
) {
|
||||
throw new Error(
|
||||
`Tenant ${candidate.id} declares public site ${site.id} but platform public_base is unset`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemovalBlockers(
|
||||
|
|
@ -765,10 +721,6 @@ function serializeTenantRecord(
|
|||
display_name: record.displayName,
|
||||
internal_domain: record.internalDomain,
|
||||
service: record.service,
|
||||
sites: record.sites.map((site) => ({
|
||||
id: site.id,
|
||||
exposure: site.exposure,
|
||||
})),
|
||||
databases: {
|
||||
brain: record.databases.brain,
|
||||
ops: record.databases.ops,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue