From aaf988c3557696c539de68c66fd92ff2edb20921 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 30 Sep 2026 09:51:07 -0400 Subject: [PATCH 01/16] auth: add CLI loopback login handoff at /auth/cli/callback (#475) --- .../client/src/pages/auth/LoginPage.tsx | 3 + create-a-container/openapi.v1.yaml | 45 +++++ .../routers/api/v1/__tests__/cli-auth.test.js | 152 +++++++++++++++++ create-a-container/routers/api/v1/auth.js | 3 + create-a-container/routers/api/v1/cli-auth.js | 156 ++++++++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 create-a-container/routers/api/v1/__tests__/cli-auth.test.js create mode 100644 create-a-container/routers/api/v1/cli-auth.js diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx index 3b5e06f4..b2ffd624 100644 --- a/create-a-container/client/src/pages/auth/LoginPage.tsx +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -45,6 +45,9 @@ function asExternalUrl(target: string): string | null { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + // Same-origin server routes (e.g. the CLI login handoff at + // /api/v1/auth/cli/callback) aren't SPA pages — they need a real navigation. + if (url.origin === window.location.origin && url.pathname.startsWith('/api/')) return url.href; // Same-origin targets stay in-app (let react-router handle them as paths). if (url.origin === window.location.origin) return null; return url.href; diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 81f6e935..774eadcb 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -621,6 +621,51 @@ paths: responses: '302': { description: 'Redirect to the post-login destination on success, or /login?oidc_error= on failure' } '404': { description: 'OIDC not configured (code: oidc_disabled)' } + /auth/cli/callback: + get: + operationId: cli_login_authorize + tags: [Auth] + summary: CLI loopback login — browser confirmation page + description: >- + Browser-facing (HTML), used by `mieweb login`. Without a session, it + redirects through the normal sign-in (OIDC or /login) and comes back + here. With a session, it renders a confirmation form that POSTs back to + this path. A GET never mints a key. + security: [] + parameters: + - { in: query, name: port, required: true, schema: { type: integer, minimum: 1024, maximum: 65535 }, description: Loopback port the CLI listens on (host is always 127.0.0.1) } + - { in: query, name: state, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16,128}$' }, description: CLI-generated one-time nonce, echoed back } + - { in: query, name: client, schema: { type: string, pattern: '^[A-Za-z0-9._@-]{1,64}$' }, description: Label recorded in the minted key's description } + responses: + '200': { description: HTML confirmation page } + '302': { description: Redirect to sign-in when there is no session } + '400': { description: HTML error page for invalid parameters } + post: + operationId: cli_login_mint + tags: [Auth] + summary: CLI loopback login — mint an API key and hand it to the loopback listener + description: >- + Requires a browser session and a CSRF token (`_csrf`). Bearer-only + requests are rejected. Mints an API key for the session user and + 303-redirects to `http://127.0.0.1:/callback#key=…&id=…&user=…&state=…`. + The key is in the URL fragment, so it never appears in a request line. + security: [] + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [_csrf, port, state] + properties: + _csrf: { type: string } + port: { type: integer } + state: { type: string } + client: { type: string } + responses: + '303': { description: Redirect to the loopback listener with the key in the fragment } + '400': { description: HTML error page for invalid parameters } + '401': { description: No browser session } + '403': { description: Missing/invalid CSRF token } /auth/logout: post: operationId: logout diff --git a/create-a-container/routers/api/v1/__tests__/cli-auth.test.js b/create-a-container/routers/api/v1/__tests__/cli-auth.test.js new file mode 100644 index 00000000..5852142f --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/cli-auth.test.js @@ -0,0 +1,152 @@ +/** + * Integration tests for the CLI loopback login handoff (issue #475 §4.3): + * GET/POST /api/v1/auth/cli/callback. + * + * Requests carry a non-localhost X-Forwarded-For so the CSRF guard's + * localhost bypass doesn't apply; that way the session + CSRF path is the + * real one a browser takes. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { ApiKey } = require('../../../../models'); +const { parseHandoff } = require('../cli-auth'); + +const REMOTE = ['X-Forwarded-For', '203.0.113.7']; +const STATE = 'abcdefghijklmnop0123456789'; +const BASE = '/api/v1/auth/cli/callback'; + +async function loggedInAgent(app, uid) { + const agent = request.agent(app); + const csrf = await agent.get('/api/v1/csrf-token').set(...REMOTE); + const token = csrf.body.data.csrfToken; + const res = await agent + .post('/api/v1/auth/login') + .set(...REMOTE) + .set('X-CSRF-Token', token) + .send({ username: uid, password: 'correct horse battery staple' }); + expect(res.status).toBe(200); + return agent; +} + +function csrfFrom(html) { + const m = html.match(/name="_csrf" value="([^"]+)"/); + return m && m[1]; +} + +describe('parseHandoff', () => { + test('accepts a valid port/state/client', () => { + expect(parseHandoff({ port: '53682', state: STATE, client: 'mieweb-cli@laptop' })).toEqual({ + port: 53682, + state: STATE, + client: 'mieweb-cli@laptop', + }); + }); + + test.each([ + [{ port: '80', state: STATE }], + [{ port: '70000', state: STATE }], + [{ port: '5368a', state: STATE }], + [{ port: '053682', state: STATE }], + [{ port: '53682', state: 'short' }], + [{ port: '53682', state: `${STATE}`; + +interface Handoff { + key: string; + id: string; + user: string; +} + +function safeEqual(a: string, b: string): boolean { + const x = Buffer.from(a); + const y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +} + +function readBody(req: IncomingMessage, limit = 8192): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + body += chunk; + if (body.length > limit) { + reject(new Error('body too large')); + req.destroy(); + } + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + }); +} + +/** Start the loopback listener; resolves the handoff once a valid `/token` POST arrives. */ +export async function startLoopback( + state: string, + signal: AbortSignal, + timeoutMs: number, +): Promise<{ port: number; result: Promise; close: () => void }> { + let settle!: { resolve: (h: Handoff) => void; reject: (e: unknown) => void }; + const result = new Promise((resolve, reject) => { + settle = { resolve, reject }; + }); + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const send = (status: number, type: string, body: string): void => { + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); + res.end(body); + }; + if (req.method === 'GET' && url.pathname === '/callback') { + send(200, 'text/html; charset=utf-8', CALLBACK_PAGE); + return; + } + if (req.method === 'POST' && url.pathname === '/token') { + // Only our own callback page (same origin) may post the handoff. + const origin = req.headers.origin; + const self = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + if (origin !== undefined && origin !== self) { + send(403, 'text/plain', 'Forbidden origin'); + return; + } + readBody(req).then( + (body) => { + const p = new URLSearchParams(body); + const key = p.get('key') ?? ''; + const id = p.get('id') ?? ''; + const user = p.get('user') ?? ''; + if (!safeEqual(p.get('state') ?? '', state)) { + send(400, 'text/plain', 'State mismatch — this sign-in was not started by this terminal. Re-run `mieweb login`.'); + return; + } + if (!key || !id) { + send(400, 'text/plain', 'The Manager did not return an API key.'); + return; + } + send(200, 'text/plain', 'ok'); + settle.resolve({ key, id, user }); + }, + () => send(400, 'text/plain', 'Bad request'), + ); + return; + } + send(404, 'text/plain', 'Not found'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as { port: number }).port; + + const timer = setTimeout(() => settle.reject(new Error('Timed out waiting for the browser sign-in')), timeoutMs); + const onAbort = (): void => settle.reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + const close = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + server.closeAllConnections(); + server.close(); + }; + return { port, result, close }; +} + +function clientLabel(): string { + const host = osHostname().toLowerCase().replace(/[^a-z0-9._-]/g, '-').slice(0, 40) || 'host'; + return `mieweb-cli@${host}`; +} + +export async function login(ctx: DeployContext, deps: ProviderDeps, hooks: LoginHooks = {}): Promise { + const { logger } = ctx; + const instanceUrl = await loginInstanceUrl(ctx, deps, hooks); + if (deps.env.MIEWEB_OS_TOKEN?.trim()) { + logger.warn('MIEWEB_OS_TOKEN is set in the environment and takes precedence over the login cache.'); + } + + // Fail fast (before opening a browser) if the instance isn't a Manager. + const probe = new ManagerClient({ instanceUrl, token: null, target: ctx.target, signal: ctx.signal, fetch: deps.fetch }); + await probe.request<{ status: string }>('GET', '/health', { auth: false }); + + const state = randomBytes(24).toString('base64url'); + const loop = await startLoopback(state, ctx.signal, hooks.timeoutMs ?? 5 * 60 * 1000); + try { + const params = new URLSearchParams({ port: String(loop.port), state, client: clientLabel() }); + const authUrl = `${instanceUrl}/api/v1/auth/cli/callback?${params.toString()}`; + logger.info(`Opening your browser to sign in to ${instanceUrl}`); + logger.info(`If it doesn't open, visit: ${authUrl}`); + (hooks.openBrowser ?? defaultOpenBrowser)(authUrl); + + const handoff = await loop.result; + + const client = new ManagerClient({ instanceUrl, token: handoff.key, target: ctx.target, signal: ctx.signal, fetch: deps.fetch }); + const session = await client.get('/session'); + + // Revoke the key this login replaces, so repeated logins don't pile up keys. + const previous = await readCredential(deps.env, instanceUrl); + await writeCredential(deps.env, instanceUrl, { + token: handoff.key, + apiKeyId: handoff.id, + user: session.user, + savedAt: new Date().toISOString(), + }); + if (previous && previous.apiKeyId !== handoff.id) { + await revoke(instanceUrl, previous.token, previous.apiKeyId, ctx, deps, logger); + } + logger.info(`Logged in to ${instanceUrl} as ${session.user}`); + } finally { + loop.close(); + } +} + +async function revoke( + instanceUrl: string, + token: string, + apiKeyId: string, + ctx: DeployContext, + deps: ProviderDeps, + logger: DeployLogger, +): Promise { + const client = new ManagerClient({ instanceUrl, token, target: ctx.target, signal: ctx.signal, fetch: deps.fetch }); + try { + await client.delete(`/apikeys/${encodeURIComponent(apiKeyId)}`); + } catch (err) { + const status = (err as { status?: number }).status; + // Already gone or already invalid: nothing to revoke. + if ((err as Error).name === 'AuthError' || status === 404) return; + logger.warn(`Could not revoke API key ${apiKeyId} on ${instanceUrl}: ${(err as Error).message}`); + } +} + +export async function logout(ctx: DeployContext, deps: ProviderDeps): Promise { + const { logger } = ctx; + const explicit = instanceFromArgv(ctx.argv) || deps.env.MIEWEB_OS_URL?.trim(); + const instanceUrl = explicit ? normalizeInstanceUrl(explicit) : resolveInstanceUrl(deps.env, ctx.targetConfig); + if (deps.env.MIEWEB_OS_TOKEN?.trim()) { + logger.warn('MIEWEB_OS_TOKEN is set in the environment; logout cannot clear it. Unset it to fully log out.'); + } + const cred = await readCredential(deps.env, instanceUrl); + if (!cred) { + logger.info(`Not logged in to ${instanceUrl}`); + return; + } + await revoke(instanceUrl, cred.token, cred.apiKeyId, ctx, deps, logger); + await deleteCredential(deps.env, instanceUrl); + logger.info(`Logged out of ${instanceUrl}`); +} diff --git a/packages/os-cloud-provider/src/client.ts b/packages/os-cloud-provider/src/client.ts new file mode 100644 index 00000000..6495a537 --- /dev/null +++ b/packages/os-cloud-provider/src/client.ts @@ -0,0 +1,136 @@ +/** + * Minimal Manager API client: Bearer auth, `{ data }` / `{ error }` envelope + * unwrapping, AbortSignal support, and 401/403 → AuthError. + * + * Only `Authorization: Bearer` is sent — no cookies — so the Manager's CSRF + * guard skips these requests (middlewares/api.js). + */ + +import { AuthError } from '@mieweb/deploy-contract'; +import type { DeployTarget } from '@mieweb/deploy-contract'; +import { LOGIN_HINT, PROVIDER_NAME } from './config.ts'; +import { sleep } from './jobs.ts'; + +const RETRIES = 3; +const RETRY_DELAY_MS = 500; + +/** A non-auth error response from the Manager. */ +export class ManagerApiError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.name = 'ManagerApiError'; + this.status = status; + this.code = code; + } +} + +export interface ClientOptions { + instanceUrl: string; + token: string | null; + target: DeployTarget; + signal?: AbortSignal; + fetch?: typeof fetch; +} + +type Query = Record; + +export class ManagerClient { + readonly instanceUrl: string; + readonly target: DeployTarget; + private readonly token: string | null; + private readonly signal: AbortSignal | undefined; + private readonly fetchImpl: typeof fetch; + + constructor(opts: ClientOptions) { + this.instanceUrl = opts.instanceUrl; + this.target = opts.target; + this.token = opts.token; + this.signal = opts.signal; + this.fetchImpl = opts.fetch ?? globalThis.fetch; + } + + private url(path: string, query?: Query): string { + const u = new URL(`${this.instanceUrl}/api/v1${path}`); + for (const [k, v] of Object.entries(query ?? {})) { + if (v !== undefined) u.searchParams.set(k, String(v)); + } + return u.toString(); + } + + authError(): AuthError { + return new AuthError(PROVIDER_NAME, this.target, LOGIN_HINT); + } + + /** Perform a request and return the unwrapped `data` payload. */ + async request(method: string, path: string, opts: { body?: unknown; query?: Query; auth?: boolean } = {}): Promise { + const needsAuth = opts.auth !== false; + if (needsAuth && !this.token) throw this.authError(); + + const headers: Record = { Accept: 'application/json' }; + if (needsAuth && this.token) headers.Authorization = `Bearer ${this.token}`; + if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; + + // GETs are idempotent, so retry them on connection-level failures (a + // dropped keep-alive socket mid job-poll shouldn't fail a deploy). + // Writes are never retried. + const attempts = method === 'GET' ? RETRIES + 1 : 1; + let res!: Response; + for (let attempt = 1; ; attempt += 1) { + try { + res = await this.fetchImpl(this.url(path, opts.query), { + method, + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + signal: this.signal, + redirect: 'manual', + }); + break; + } catch (err) { + if ((err as Error).name === 'AbortError' || this.signal?.aborted) throw err; + if (attempt < attempts) { + await sleep(RETRY_DELAY_MS * attempt, this.signal ?? new AbortController().signal); + continue; + } + throw new Error(`Cannot reach the Manager at ${this.instanceUrl}: ${(err as Error).message}`, { cause: err }); + } + } + + if (res.status === 401 || res.status === 403) { + await res.body?.cancel(); + throw this.authError(); + } + if (res.status === 204) return undefined as T; + + const text = await res.text(); + let json: { data?: unknown; error?: { code?: string; message?: string } } | undefined; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + if (!res.ok) { + const code = json?.error?.code ?? `http_${res.status}`; + const message = json?.error?.message ?? (text.slice(0, 200) || res.statusText); + throw new ManagerApiError(res.status, code, `${method} ${path} failed (${res.status} ${code}): ${message}`); + } + if (json === undefined || !('data' in json)) { + throw new ManagerApiError(res.status, 'bad_response', `${method} ${path}: unexpected non-JSON response from ${this.instanceUrl}`); + } + return json.data as T; + } + + get(path: string, query?: Query): Promise { + return this.request('GET', path, { query }); + } + post(path: string, body: unknown): Promise { + return this.request('POST', path, { body }); + } + put(path: string, body: unknown): Promise { + return this.request('PUT', path, { body }); + } + delete(path: string): Promise { + return this.request('DELETE', path); + } +} diff --git a/packages/os-cloud-provider/src/config.ts b/packages/os-cloud-provider/src/config.ts new file mode 100644 index 00000000..61de730e --- /dev/null +++ b/packages/os-cloud-provider/src/config.ts @@ -0,0 +1,218 @@ +/** + * Configuration resolution: instance URL, API token, and the non-secret + * `targets.mieweb` block of mieweb.jsonc. + * + * Precedence (issue #475 §3): + * token: env.MIEWEB_OS_TOKEN → machine-local login cache (never config) + * instance URL: env.MIEWEB_OS_URL → targetConfig.instanceUrl → default + */ + +import type { DeployContext, ProviderEnv } from '@mieweb/deploy-contract'; +import { readCredential } from './credentials.ts'; + +export const PROVIDER_NAME = 'opensource-server'; +export const DEFAULT_INSTANCE_URL = 'https://os.mieweb.org'; +export const DEFAULT_IMAGE = 'ghcr.io/mieweb/opensource-server/cloud:latest'; +export const DEFAULT_PORT = 8787; +/** The one rw persistent volume the converged container's datastores live on (#421). */ +export const DATA_VOLUME = { name: 'data', mountPath: '/mnt/data', mode: 'rw' } as const; + +/** Same rule the Manager enforces on `Container.hostname` (models/container.js). */ +export const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +export const LOGIN_HINT = 'set MIEWEB_OS_TOKEN, or run `mieweb login --target mieweb`'; + +/** Thrown for configuration problems the user must fix (not auth, not backend). */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +/** + * Canonical instance URL: scheme + host (+ port) + path without a trailing + * slash or a trailing `/api/v1`. It is also the key for the login cache, so + * `https://OS.mieweb.org/` and `https://os.mieweb.org` share one entry. + */ +export function normalizeInstanceUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw new ConfigError(`Invalid instance URL: ${JSON.stringify(raw)}`); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new ConfigError(`Instance URL must be http(s): ${JSON.stringify(raw)}`); + } + if (url.username || url.password) { + throw new ConfigError('Instance URL must not contain credentials'); + } + let path = url.pathname.replace(/\/+$/, ''); + if (path.endsWith('/api/v1')) path = path.slice(0, -'/api/v1'.length); + return `${url.protocol}//${url.host}${path}`; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +/** Instance URL for all verbs except `login` (see {@link loginInstanceUrl}). */ +export function resolveInstanceUrl(env: ProviderEnv, targetConfig: Readonly>): string { + return normalizeInstanceUrl(str(env.MIEWEB_OS_URL) ?? str(targetConfig.instanceUrl) ?? DEFAULT_INSTANCE_URL); +} + +/** Value of `--instance ` / `--instance=` in passthrough argv, if any. */ +export function instanceFromArgv(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]!; + if (a === '--instance') return str(argv[i + 1]); + if (a.startsWith('--instance=')) return str(a.slice('--instance='.length)); + } + return undefined; +} + +export interface ResolvedToken { + token: string; + method: 'env' | 'login'; +} + +/** Token for `instanceUrl`, or null when neither env nor the login cache has one. */ +export async function resolveToken(env: ProviderEnv, instanceUrl: string): Promise { + const fromEnv = str(env.MIEWEB_OS_TOKEN); + if (fromEnv) return { token: fromEnv, method: 'env' }; + const cached = await readCredential(env, instanceUrl); + return cached ? { token: cached.token, method: 'login' } : null; +} + +/** Extra non-HTTP service declared in `targets.mieweb.services`. */ +export interface ExtraService { + type: 'tcp' | 'udp' | 'srv'; + internalPort: number; + dnsName?: string; +} + +/** The parts of `targets.mieweb` deploy/destroy use. All non-secret. */ +export interface TargetSettings { + instanceUrl: string; + siteId: number; + image: string; + port: number; + /** External hostname label; defaults to the app name. */ + externalHostname: string; + /** External domain, by name or id; defaults to the site's first domain. */ + domain?: string | number; + authRequired: boolean; + nvidia?: boolean; + services: ExtraService[]; + /** Sync the worktree into the container after converging (default true). */ + sync: boolean; + /** SSH login for the sync (default: the Manager account). */ + sshUser?: string; + /** SSH host override (default: the container's published sshHost). */ + sshHost?: string; + /** Start command inside the container (default `npm start`). */ + start?: string; +} + +function posInt(v: unknown, what: string): number { + const n = typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v; + if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) { + throw new ConfigError(`${what} must be a positive integer, got ${JSON.stringify(v)}`); + } + return n; +} + +function port(v: unknown, what: string): number { + const n = posInt(v, what); + if (n > 65535) throw new ConfigError(`${what} must be a TCP port (1-65535), got ${n}`); + return n; +} + +/** App name from wrangler.jsonc `name`; must be a DNS label (it becomes the hostname). */ +export function appName(manifest: Readonly>): string { + const name = manifest.name; + if (typeof name !== 'string' || name === '') { + throw new ConfigError('wrangler.jsonc must set `name` (it is used as the container hostname)'); + } + if (!DNS_LABEL.test(name)) { + throw new ConfigError( + `wrangler.jsonc \`name\` ${JSON.stringify(name)} is not a valid DNS label ` + + '(1-63 chars of a-z, 0-9 and "-", starting and ending with a letter or digit)', + ); + } + return name; +} + +/** Validate `targets.mieweb` for deploy/destroy. */ +export function resolveTargetSettings(ctx: DeployContext, env: ProviderEnv): TargetSettings { + const tc = ctx.targetConfig; + const name = appName(ctx.manifest); + const target = `targets.${ctx.target}`; + + if (tc.siteId === undefined || tc.siteId === null || tc.siteId === '') { + throw new ConfigError(`mieweb.jsonc ${target}.siteId is required (the Manager site to deploy into)`); + } + const siteId = posInt(tc.siteId, `${target}.siteId`); + + const externalHostname = str(tc.externalHostname) ?? name; + if (!DNS_LABEL.test(externalHostname)) { + throw new ConfigError(`${target}.externalHostname ${JSON.stringify(externalHostname)} is not a valid DNS label`); + } + + let domain: string | number | undefined; + if (tc.domain !== undefined) { + if (typeof tc.domain === 'number') domain = posInt(tc.domain, `${target}.domain`); + else if (str(tc.domain)) domain = str(tc.domain); + else throw new ConfigError(`${target}.domain must be a domain name or id`); + } + + if (tc.authRequired !== undefined && typeof tc.authRequired !== 'boolean') { + throw new ConfigError(`${target}.authRequired must be a boolean`); + } + if (tc.sync !== undefined && typeof tc.sync !== 'boolean') { + throw new ConfigError(`${target}.sync must be a boolean`); + } + if (tc.source !== undefined || tc.ref !== undefined) { + throw new ConfigError( + `${target}.source/.ref are no longer used: deploy syncs your local worktree into the container over SSH`, + ); + } + if (tc.nvidia !== undefined && typeof tc.nvidia !== 'boolean') { + throw new ConfigError(`${target}.nvidia must be a boolean`); + } + + const services: ExtraService[] = []; + if (tc.services !== undefined) { + if (!Array.isArray(tc.services)) throw new ConfigError(`${target}.services must be an array`); + for (const [i, raw] of tc.services.entries()) { + const s = (raw ?? {}) as Record; + if (s.type !== 'tcp' && s.type !== 'udp' && s.type !== 'srv') { + throw new ConfigError(`${target}.services[${i}].type must be tcp, udp or srv (the HTTP service is implicit)`); + } + const svc: ExtraService = { type: s.type, internalPort: port(s.internalPort, `${target}.services[${i}].internalPort`) }; + if (s.type === 'srv') { + const dnsName = str(s.dnsName); + if (!dnsName) throw new ConfigError(`${target}.services[${i}].dnsName is required for srv services`); + svc.dnsName = dnsName; + } + services.push(svc); + } + } + + return { + instanceUrl: resolveInstanceUrl(env, tc), + siteId, + image: str(tc.image) ?? DEFAULT_IMAGE, + port: tc.port === undefined ? DEFAULT_PORT : port(tc.port, `${target}.port`), + externalHostname, + domain, + authRequired: tc.authRequired === true, + nvidia: tc.nvidia as boolean | undefined, + services, + sync: tc.sync !== false, + sshUser: str(env.MIEWEB_OS_SSH_USER) ?? str(tc.sshUser), + sshHost: str(tc.sshHost), + start: str(tc.start), + }; +} diff --git a/packages/os-cloud-provider/src/credentials.ts b/packages/os-cloud-provider/src/credentials.ts new file mode 100644 index 00000000..9e98936c --- /dev/null +++ b/packages/os-cloud-provider/src/credentials.ts @@ -0,0 +1,75 @@ +/** + * Machine-local login cache: `~/.mieweb/os.json`, keyed by instance URL so a + * user can be logged into os.mieweb.org and a self-hosted instance at once + * (mirrors wrangler's `~/.wrangler` cache). Written 0600 in a 0700 directory. + * + * `MIEWEB_OS_CREDENTIALS` (from the provider env) overrides the file path. + */ + +import { mkdir, readFile, rename, writeFile, chmod } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { ProviderEnv } from '@mieweb/deploy-contract'; + +export interface StoredCredential { + token: string; + /** API key id, so `logout` can revoke it server-side. */ + apiKeyId: string; + user?: string; + savedAt: string; +} + +interface CredentialFile { + version: 1; + instances: Record; +} + +export function credentialsPath(env: ProviderEnv): string { + const override = env.MIEWEB_OS_CREDENTIALS?.trim(); + return override ? override : join(env.HOME?.trim() || homedir(), '.mieweb', 'os.json'); +} + +async function load(path: string): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, instances: {} }; + throw err; + } + try { + const parsed = JSON.parse(text) as Partial; + return { version: 1, instances: { ...(parsed.instances ?? {}) } }; + } catch { + throw new Error(`Credential cache ${path} is not valid JSON; delete it and run \`mieweb login\` again`); + } +} + +async function save(path: string, data: CredentialFile): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 }); + await chmod(tmp, 0o600); + await rename(tmp, path); +} + +export async function readCredential(env: ProviderEnv, instanceUrl: string): Promise { + const data = await load(credentialsPath(env)); + return data.instances[instanceUrl] ?? null; +} + +export async function writeCredential(env: ProviderEnv, instanceUrl: string, cred: StoredCredential): Promise { + const path = credentialsPath(env); + const data = await load(path); + data.instances[instanceUrl] = cred; + await save(path, data); +} + +export async function deleteCredential(env: ProviderEnv, instanceUrl: string): Promise { + const path = credentialsPath(env); + const data = await load(path); + if (!(instanceUrl in data.instances)) return false; + delete data.instances[instanceUrl]; + await save(path, data); + return true; +} diff --git a/packages/os-cloud-provider/src/deploy.ts b/packages/os-cloud-provider/src/deploy.ts new file mode 100644 index 00000000..62ad725c --- /dev/null +++ b/packages/os-cloud-provider/src/deploy.ts @@ -0,0 +1,520 @@ +/** + * `deploy` / `destroy`: converge one container per app on a Manager site. + * + * Identity is the hostname (= wrangler.jsonc `name`), unique per site, so + * deploy is an upsert: + * + * list_containers?hostname= + * ├─ none → create_container (retry as update on 409 conflict) + * ├─ same image/GPU → update_container (services diff, full env, restart) + * └─ image/GPU drift → delete_container + create_container + * (`template`/`nvidiaRequested` are create-only; the + * /mnt/data volume is retained on delete (#421), so + * datastore state survives the recreate) + * + * then poll the job and read the container back for the URL + VMID. + */ + +import { randomBytes } from 'node:crypto'; +import type { DeployContext, DeployResult, ProviderEnv } from '@mieweb/deploy-contract'; +import type { + Container, + CreateContainerResult, + DeleteContainerResult, + EnvVar, + NewContainerForm, + ServiceUpdate, + UpdateContainerResult, + VolumeAttach, +} from './api-types.ts'; +import { ManagerApiError, ManagerClient } from './client.ts'; +import { + appName, + ConfigError, + DATA_VOLUME, + resolveTargetSettings, + resolveToken, + type ExtraService, + type TargetSettings, +} from './config.ts'; +import { waitForJob } from './jobs.ts'; +import type { SessionInfo } from './api-types.ts'; +import { forgetHostKey, knownHostsPath, SshConnection, waitForSsh, type Prompter, type RemoteShell, type SshTarget } from './ssh.ts'; +import { syncWorktree } from './sync.ts'; + +/** Env keys the provider owns inside the converged container. */ +export const MANAGED_ENV = { + port: 'PORT', + target: 'MIEWEB_TARGET', + start: 'MIEWEB_APP_START', + minioUser: 'MINIO_ROOT_USER', + minioPassword: 'MINIO_ROOT_PASSWORD', + s3Endpoint: 'MIEWEB_S3_ENDPOINT', + s3AccessKey: 'MIEWEB_S3_ACCESS_KEY_ID', + s3SecretKey: 'MIEWEB_S3_SECRET_ACCESS_KEY', + libsqlUrl: 'MIEWEB_LIBSQL_URL', + valkeyUrl: 'MIEWEB_VALKEY_URL', +} as const; + +/** Provider env vars with this prefix are injected (prefix stripped) as app secrets. */ +export const SECRET_ENV_PREFIX = 'MIEWEB_OS_SECRET_'; + +/** + * Same normalization the Manager applies to `template` on create + * (normalizeDockerRef in routers/api/v1/containers.js), so a stored template + * can be compared with the configured image. + */ +export function normalizeImageRef(ref: string): string { + if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('git@')) return ref; + let tag = 'latest'; + let imagePart = ref; + const lastColon = ref.lastIndexOf(':'); + if (lastColon !== -1) { + const potentialTag = ref.substring(lastColon + 1); + if (!potentialTag.includes('/')) { + tag = potentialTag; + imagePart = ref.substring(0, lastColon); + } + } + const parts = imagePart.split('/'); + let host = 'docker.io'; + let org = 'library'; + let image: string; + if (parts.length === 1) { + image = parts[0]!; + } else if (parts.length === 2) { + if (parts[0]!.includes('.') || parts[0]!.includes(':')) { + host = parts[0]!; + image = parts[1]!; + } else { + org = parts[0]!; + image = parts[1]!; + } + } else { + host = parts[0]!; + image = parts[parts.length - 1]!; + org = parts.slice(1, -1).join('/'); + } + return `${host}/${org}/${image}:${tag}`; +} + +/** Pick the external domain to expose the app under. */ +export function pickDomain(form: NewContainerForm, wanted: string | number | undefined): { id: number; name: string } { + const domains = form.externalDomains ?? []; + if (wanted !== undefined) { + const hit = domains.find((d) => (typeof wanted === 'number' ? d.id === wanted : d.name === wanted)); + if (!hit) { + const names = domains.map((d) => `${d.name} (${d.id})`).join(', ') || 'none'; + throw new ConfigError(`External domain ${JSON.stringify(wanted)} is not available on this site (available: ${names})`); + } + return { id: hit.id, name: hit.name }; + } + // The Manager sorts the site's own default domains first. + const first = domains[0]; + if (!first) throw new ConfigError('The site has no external domains; ask an admin to add one, or set targets.mieweb.domain'); + return { id: first.id, name: first.name }; +} + +function envString(v: unknown): string { + return typeof v === 'string' ? v : JSON.stringify(v); +} + +export interface EnvInputs { + manifest: Readonly>; + env: ProviderEnv; + settings: Pick; + /** Current env map of the existing container (read shape is an object). */ + existing?: Record; + warn: (m: string) => void; +} + +/** + * The complete desired env set. `update_container` treats `environmentVars` + * as a full replacement, so this always returns everything. + * + * Order of precedence (later wins): wrangler `vars` → MIEWEB_OS_SECRET_* from + * the provider env → provider-managed keys (warned on collision). + */ +export function buildEnv(inputs: EnvInputs): EnvVar[] { + const out = new Map(); + const vars = inputs.manifest.vars; + if (vars && typeof vars === 'object' && !Array.isArray(vars)) { + for (const [k, v] of Object.entries(vars)) out.set(k, envString(v)); + } + for (const [k, v] of Object.entries(inputs.env)) { + if (k.startsWith(SECRET_ENV_PREFIX) && k.length > SECRET_ENV_PREFIX.length && v !== undefined) { + out.set(k.slice(SECRET_ENV_PREFIX.length), v); + } + } + + // Reuse the generated MinIO secret: the data on /mnt/data was written with + // it, so it must survive redeploys and image-change recreates. + const minioPassword = + inputs.existing?.[MANAGED_ENV.minioPassword] || randomBytes(24).toString('base64url'); + const minioUser = inputs.existing?.[MANAGED_ENV.minioUser] || 'mieweb'; + + const managed: [string, string | undefined][] = [ + [MANAGED_ENV.port, String(inputs.settings.port)], + [MANAGED_ENV.target, 'mieweb'], + [MANAGED_ENV.start, inputs.settings.start], + [MANAGED_ENV.minioUser, minioUser], + [MANAGED_ENV.minioPassword, minioPassword], + [MANAGED_ENV.s3Endpoint, 'http://127.0.0.1:9000'], + [MANAGED_ENV.s3AccessKey, minioUser], + [MANAGED_ENV.s3SecretKey, minioPassword], + [MANAGED_ENV.libsqlUrl, 'http://127.0.0.1:8080'], + [MANAGED_ENV.valkeyUrl, 'redis://127.0.0.1:6379'], + ]; + for (const [k, v] of managed) { + if (v === undefined) continue; + if (out.has(k) && out.get(k) !== v) inputs.warn(`Env var ${k} is managed by the provider; ignoring the app's value`); + out.set(k, v); + } + return [...out.entries()].map(([key, value]) => ({ key, value })); +} + +export interface DesiredHttp { + internalPort: number; + externalHostname: string; + externalDomainId: number; + authRequired: boolean; +} + +/** + * Diff the container's services against the desired set and produce an + * `update_container` services map. + * + * The provider owns the app's HTTP exposure: every HTTP service that doesn't + * match is removed. Non-HTTP services are only added (to match + * `targets.mieweb.services`), never removed — e.g. an SSH port someone added + * in the UI survives a redeploy. + */ +export function planServices( + current: Container['services'], + http: DesiredHttp, + extras: readonly ExtraService[], +): Record { + const plan: Record = {}; + let keptHttp = false; + for (const svc of current ?? []) { + if (svc.type !== 'http' || svc.id === undefined) continue; + const h = svc.httpService; + const matches = + !keptHttp && + svc.internalPort === http.internalPort && + h?.externalHostname === http.externalHostname && + h?.externalDomainId === http.externalDomainId && + (h?.backendProtocol ?? 'http') === 'http'; + if (matches) { + keptHttp = true; + // Existing entries may only toggle authRequired. + plan[`keep-${svc.id}`] = { id: svc.id, type: 'http', internalPort: http.internalPort, authRequired: http.authRequired }; + } else { + plan[`del-${svc.id}`] = { id: svc.id, deleted: true, type: 'http', internalPort: svc.internalPort ?? 0 }; + } + } + if (!keptHttp) { + plan.http = { + type: 'http', + internalPort: http.internalPort, + externalHostname: http.externalHostname, + externalDomainId: http.externalDomainId, + authRequired: http.authRequired, + }; + } + + extras.forEach((want, i) => { + const exists = (current ?? []).some((svc) => + want.type === 'srv' + ? svc.type === 'dns' && svc.internalPort === want.internalPort && svc.dnsService?.dnsName === want.dnsName + : svc.type === 'transport' && svc.internalPort === want.internalPort && svc.transportService?.protocol === want.type, + ); + if (!exists) { + plan[`extra-${i}`] = { type: want.type, internalPort: want.internalPort, ...(want.dnsName ? { dnsName: want.dnsName } : {}) }; + } + }); + return plan; +} + +/** The SSH service the code sync uses; always requested. */ +export const SSH_SERVICE: ExtraService = { type: 'tcp', internalPort: 22 }; + +function withSsh(extras: readonly ExtraService[]): ExtraService[] { + return extras.some((e) => e.type === 'tcp' && e.internalPort === 22) ? [...extras] : [SSH_SERVICE, ...extras]; +} + +/** True when applying `plan` to `current` would change nothing. */ +export function servicesUnchanged(current: Container['services'], plan: Record): boolean { + return Object.entries(plan).every(([key, entry]) => { + if (!key.startsWith('keep-')) return false; + const svc = (current ?? []).find((c) => c.id === entry.id); + return (svc?.httpService?.authRequired ?? false) === (entry.authRequired ?? false); + }); +} + +export function envUnchanged(current: unknown, desired: readonly EnvVar[]): boolean { + const cur = asEnvMap(current); + return ( + Object.keys(cur).length === desired.length && desired.every((e) => cur[e.key ?? ''] === (e.value ?? '')) + ); +} + +function createServices(http: DesiredHttp, extras: readonly ExtraService[]): Record { + return planServices([], http, extras); +} + +function asEnvMap(v: unknown): Record { + if (Array.isArray(v)) return Object.fromEntries(v.map((e: EnvVar) => [e.key ?? '', e.value ?? ''])); + return v && typeof v === 'object' ? (v as Record) : {}; +} + +/** Whether AI is bound in wrangler.jsonc (suggests a GPU node). */ +function wantsAi(manifest: Readonly>): boolean { + return manifest.ai !== undefined && manifest.ai !== null; +} + +export interface ProviderDeps { + env: ProviderEnv; + fetch?: typeof fetch; + /** Job poll interval (tests shorten it). */ + pollIntervalMs?: number; + /** Open the SSH session used for the code sync (tests inject a fake). */ + connectSsh?: (target: SshTarget, opts: { knownHostsFile: string; signal: AbortSignal; logger: DeployContext['logger'] }) => Promise; + /** Wait for the SSH banner (tests inject a fake). */ + waitForSsh?: typeof waitForSsh; + /** Terminal prompt for passphrases/passwords. */ + prompt?: Prompter; +} + +async function clientFor(ctx: DeployContext, deps: ProviderDeps, instanceUrl: string): Promise { + const tok = await resolveToken(deps.env, instanceUrl); + return new ManagerClient({ instanceUrl, token: tok?.token ?? null, target: ctx.target, signal: ctx.signal, fetch: deps.fetch }); +} + +async function findByHostname(client: ManagerClient, siteId: number, hostname: string): Promise { + const list = await client.get(`/sites/${siteId}/containers`, { hostname }); + return list.find((c) => c.hostname === hostname) ?? null; +} + +export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise { + const { logger, signal } = ctx; + const name = appName(ctx.manifest); + const s = resolveTargetSettings(ctx, deps.env); + const client = await clientFor(ctx, deps, s.instanceUrl); + const image = normalizeImageRef(s.image); + const wait = (jobId: number): Promise => + waitForJob(client, jobId, { signal, logger, intervalMs: deps.pollIntervalMs }); + + logger.info(`Deploying "${name}" to site ${s.siteId} on ${s.instanceUrl}`); + logger.info(`Image: ${image}`); + + const form = await client.get(`/sites/${s.siteId}/containers/new`); + const domain = pickDomain(form, s.domain); + let nvidia = s.nvidia ?? false; + if (s.nvidia === undefined && wantsAi(ctx.manifest)) { + nvidia = form.nvidiaAvailable; + logger.info( + nvidia + ? 'AI binding found; requesting an NVIDIA node' + : 'AI binding found but the site has no NVIDIA node; deploying without a GPU', + ); + } + + const extras = withSsh(s.services); + const http: DesiredHttp = { + internalPort: s.port, + externalHostname: s.externalHostname, + externalDomainId: domain.id, + authRequired: s.authRequired, + }; + const envFor = (existing?: Container | null): EnvVar[] => + buildEnv({ + manifest: ctx.manifest, + env: deps.env, + settings: s, + existing: existing ? asEnvMap(existing.environmentVars) : undefined, + warn: (m) => logger.warn(m), + }); + + let existing = await findByHostname(client, s.siteId, name); + let carryEnv: Container | null = existing; + + if (existing) { + // A create still in flight (e.g. a concurrent or interrupted deploy): + // let it finish before deciding anything. + if (!existing.containerId && existing.status === 'creating' && existing.creationJobId) { + logger.info(`Container ${existing.id} is still being created; waiting for job ${existing.creationJobId}`); + await waitForJob(client, existing.creationJobId, { signal, logger, intervalMs: deps.pollIntervalMs }).catch(() => {}); + existing = await findByHostname(client, s.siteId, name); + carryEnv = existing; + } + } + + if (existing) { + const drift: string[] = []; + if (!existing.containerId) { + // Never provisioned (failed/missing create); an update can't fix that. + drift.push(`not provisioned (status ${existing.status ?? 'unknown'})`); + } + if (existing.template && normalizeImageRef(existing.template) !== image) { + drift.push(`image ${existing.template} → ${image}`); + } + if (!!existing.nvidiaRequested !== nvidia) drift.push(`nvidia ${!!existing.nvidiaRequested} → ${nvidia}`); + if (drift.length > 0) { + logger.info(`Recreating container ${existing.id} (${drift.join(', ')}); ${DATA_VOLUME.mountPath} is retained`); + const del = await client.delete(`/sites/${s.siteId}/containers/${existing.id}`); + for (const w of del.dnsWarnings ?? []) logger.warn(w); + existing = null; + } + } + + let id: number; + // Set when this deploy (re)created the container: its SSH host key is new. + let fresh = false; + if (!existing) { + const created = await createOrAdopt(client, s, name, image, nvidia, envFor(carryEnv), http, logger); + if ('created' in created) { + id = created.created.containerId; + fresh = true; + logger.info(`Created container ${id}; waiting for job ${created.created.jobId}`); + await wait(created.created.jobId); + } else { + // Lost a concurrent-create race: someone else created the same hostname + // between our list and create. Converge it with an update instead. + existing = created.adopted; + carryEnv = existing; + } + } + + if (existing) { + id = existing.id!; + const services = planServices(existing.services, http, extras); + const environmentVars = envFor(carryEnv); + const body: Record = { + services, + environmentVars, + entrypoint: existing.entrypoint ?? null, + restart: true, + }; + let changed = !servicesUnchanged(existing.services, services) || !envUnchanged(existing.environmentVars, environmentVars); + if (existing.volumes === undefined) { + // Manager predates volumes (#421): it neither reports nor accepts them. + logger.warn( + `This Manager does not support volumes; ${DATA_VOLUME.mountPath} is not persistent and datastore state will not survive a container recreate`, + ); + } else if (!existing.volumes.some((v) => v.mountPath === DATA_VOLUME.mountPath)) { + body.volumes = [DATA_VOLUME satisfies VolumeAttach]; + changed = true; + logger.info(`Attaching the ${DATA_VOLUME.mountPath} data volume`); + } else if ((existing.volumes ?? []).some((v) => v.mountPath === DATA_VOLUME.mountPath && v.mode !== 'rw')) { + logger.warn(`${DATA_VOLUME.mountPath} is attached read-only; the datastores need it read-write`); + } + // Code-only redeploys skip the Manager entirely and just sync. + if (changed) { + const upd = await client.put(`/sites/${s.siteId}/containers/${id}`, body); + for (const w of upd.dnsWarnings ?? []) logger.warn(w); + if (upd.jobId) { + logger.info(`Updated container ${id}; waiting for job ${upd.jobId}`); + await wait(upd.jobId); + } else { + logger.info(`Updated container ${id}${upd.message ? `: ${upd.message}` : ''}`); + } + } else { + logger.info(`Container ${id} configuration is up to date`); + } + } + + const final = await client.get(`/sites/${s.siteId}/containers/${id!}`); + if (!final.containerId) { + throw new Error(`Container ${id!} has no hypervisor id after the job finished (status: ${final.status ?? 'unknown'})`); + } + const url = + final.httpEntries?.find((e) => e.port === s.port && e.externalUrl)?.externalUrl ?? + final.httpEntries?.find((e) => e.externalUrl)?.externalUrl ?? + undefined; + + if (s.sync) { + const port = final.sshPort; + const host = s.sshHost ?? final.sshHost ?? undefined; + if (!port || !host) { + throw new Error(`Container ${id!} has no published SSH port/host; cannot sync code (set targets.${ctx.target}.sync to false to skip)`); + } + const user = s.sshUser ?? (await client.get('/session')).user; + const target: SshTarget = { host, port, user }; + const knownHostsFile = knownHostsPath(deps.env); + if (fresh) await forgetHostKey(knownHostsFile, host, port); + await (deps.waitForSsh ?? waitForSsh)(host, port, signal, logger); + logger.info(`Connecting to ${user}@${host}:${port}`); + const shell = deps.connectSsh + ? await deps.connectSsh(target, { knownHostsFile, signal, logger }) + : await SshConnection.connect({ target, env: deps.env, knownHostsFile, prompt: deps.prompt, signal, logger }); + try { + await syncWorktree(ctx.root, shell, logger); + } finally { + shell.close(); + } + } else { + logger.info('Code sync disabled (sync: false)'); + } + + if (url) logger.info(`Live at ${url}`); + return { + ...(url ? { url } : {}), + resources: [{ binding: name, kind: 'container', id: String(final.containerId) }], + }; +} + +async function createOrAdopt( + client: ManagerClient, + s: TargetSettings, + name: string, + image: string, + nvidia: boolean, + environmentVars: EnvVar[], + http: DesiredHttp, + logger: DeployContext['logger'], +): Promise<{ created: CreateContainerResult } | { adopted: Container }> { + try { + const created = await client.post(`/sites/${s.siteId}/containers`, { + hostname: name, + template: image, + nvidiaRequested: nvidia, + environmentVars, + volumes: [DATA_VOLUME], + services: createServices(http, withSsh(s.services)), + }); + return { created }; + } catch (err) { + if (!(err instanceof ManagerApiError) || err.status !== 409 || err.code !== 'conflict') throw err; + const adopted = await findByHostname(client, s.siteId, name); + if (!adopted) { + throw new Error( + `Hostname "${name}" is already taken on site ${s.siteId} by a container you cannot manage; ` + + 'rename the app (wrangler.jsonc `name`) or ask its owner to delete it.', + { cause: err }, + ); + } + logger.info(`Container "${name}" was created concurrently; updating it instead`); + return { adopted }; + } +} + +export async function destroy(ctx: DeployContext, deps: ProviderDeps): Promise { + const name = appName(ctx.manifest); + const s = resolveTargetSettings(ctx, deps.env); + const client = await clientFor(ctx, deps, s.instanceUrl); + const existing = await findByHostname(client, s.siteId, name); + if (!existing) { + ctx.logger.info(`No container "${name}" on site ${s.siteId}; nothing to destroy`); + return; + } + const res = await client.delete(`/sites/${s.siteId}/containers/${existing.id}`); + for (const w of res.dnsWarnings ?? []) ctx.logger.warn(w); + const retained = (existing.volumes ?? []).some((v) => v.mountPath === DATA_VOLUME.mountPath); + ctx.logger.info( + `Destroyed container "${name}" (${existing.id}).` + + (retained + ? ` The ${DATA_VOLUME.mountPath} data directory is retained on the node and is reattached if you deploy the same name again.` + : ''), + ); +} diff --git a/packages/os-cloud-provider/src/generated/manager-api.ts b/packages/os-cloud-provider/src/generated/manager-api.ts new file mode 100644 index 00000000..c235e225 --- /dev/null +++ b/packages/os-cloud-provider/src/generated/manager-api.ts @@ -0,0 +1,4184 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/csrf-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a CSRF token */ + get: operations["get_csrf_token"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: operations["get_health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** This OpenAPI specification (JSON) */ + get: operations["get_openapi_json"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.yaml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** This OpenAPI specification (YAML) */ + get: operations["get_openapi_yaml"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current session */ + get: operations["get_session"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Username/password login (disabled when OIDC SSO is enabled) */ + post: operations["login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/oidc/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Begin OIDC single sign-on (redirects to the identity provider) */ + get: operations["oidc_login"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/oidc/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** OIDC authorization-code callback (completes SSO and starts a session) */ + get: operations["oidc_callback"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/cli/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * CLI loopback login — browser confirmation page + * @description Browser-facing (HTML), used by `mieweb login`. Without a session, it redirects through the normal sign-in (OIDC or /login) and comes back here. With a session, it renders a confirmation form that POSTs back to this path. A GET never mints a key. + */ + get: operations["cli_login_authorize"]; + put?: never; + /** + * CLI loopback login — mint an API key and hand it to the loopback listener + * @description Requires a browser session and a CSRF token (`_csrf`). Bearer-only requests are rejected. Mints an API key for the session user and 303-redirects to `http://127.0.0.1:/callback#key=…&id=…&user=…&state=…`. The key is in the URL fragment, so it never appears in a request line. + */ + post: operations["cli_login_mint"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Log out (clears the session; returns an IdP end-session URL when OIDC is enabled) */ + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Self-register (disabled when OIDC SSO is enabled) */ + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/register/invite/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Validate an invitation token */ + get: operations["validate_invite"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/password-reset/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["request_password_reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/password-reset/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["validate_password_reset_token"]; + put?: never; + post: operations["reset_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sites */ + get: operations["list_sites"]; + put?: never; + /** Create a site (admin) */ + post: operations["create_site"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get: operations["get_site"]; + /** Update a site (admin). Full update — omitted optional fields are cleared. */ + put: operations["update_site"]; + post?: never; + /** Delete a site (admin) */ + delete: operations["delete_site"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_containers"]; + put?: never; + post: operations["create_container"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Form bootstrap data (domains + NVIDIA flag) */ + get: operations["get_new_container_form"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fetch Docker image metadata */ + get: operations["get_image_metadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers/{id}": { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + get: operations["get_container"]; + /** Update services/env/entrypoint; enqueues a restart job only when explicitly requested (owner/admin) */ + put: operations["update_container"]; + post?: never; + delete: operations["delete_container"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers/{id}/collaborators": { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + /** List users a container is shared with (owner/collaborator/admin) */ + get: operations["list_container_collaborators"]; + put?: never; + /** Share a container with another user (owner/admin). Idempotent — sharing with an existing collaborator is a no-op. */ + post: operations["add_container_collaborator"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/containers/{id}/collaborators/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Stop sharing a container with a user (owner/admin) */ + delete: operations["remove_container_collaborator"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/nodes": { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + get: operations["list_nodes"]; + put?: never; + /** Create a node (admin) */ + post: operations["create_node"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/nodes/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bulk-import nodes + containers from a Proxmox cluster (admin) */ + post: operations["import_nodes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/nodes/{id}": { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + get: operations["get_node"]; + /** Update a node (admin). Full update — omitted fields are reset to defaults/null (except `secret`, which is kept when blank/omitted, and `nodeType`, which keeps its current value). */ + put: operations["update_node"]; + post?: never; + /** Delete a node (admin) */ + delete: operations["delete_node"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/nodes/{id}/storages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Proxmox CT template storages (empty array when the node has no API credentials or the query fails) */ + get: operations["list_node_storages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/nodes/{id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Live hardware utilization (CPU/memory/swap/rootfs, uptime) plus per-datastore usage */ + get: operations["get_node_stats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sites/{siteId}/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Live per-owner resource usage report (allocated vs used), computed from one Proxmox cluster-resources call per cluster + * @description Admins see every owner on the site; other users see their own containers plus containers shared with them. `findings` (owner-tag vs DB attribution drift, unattributed containers) and `unknownNodeRows` are admin-only. + */ + get: operations["get_site_usage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + /** Job metadata (creator or admin; others receive 404) */ + get: operations["get_job"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_job_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/jobs/{id}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Server-sent events stream of job output */ + get: operations["stream_job_output"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current status of all agents (admin) */ + get: operations["list_agents"]; + put?: never; + /** + * Agent check-in + * @description Site agents POST their system info, per-service status, and per-volume + * directory-provisioning results every 30 seconds. The response carries + * the site's config snapshot with a strong `ETag`; send it back via + * `If-None-Match` to receive `304 Not Modified` when nothing changed. + * Allowed from localhost without credentials (manager bootstrap) or with + * an admin API key. Exempt from the CSRF guard. + */ + post: operations["agent_check_in"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the current user's notifications + * @description Owner-scoped feed for the notification bell. Returned unacknowledged + * first, then newest first, so the client can derive its unread badge from + * the response. + */ + get: operations["list_notifications"]; + put?: never; + /** + * Ingest a node-side event (admin API key) + * @description Inbound webhook for node-side tools (e.g. lxc-oomd) to report events. + * Requires an admin API key (Bearer). The event is persisted and surfaced + * per owner in the UI notification bell. When `owner` is omitted it is + * resolved best-effort from `node` + `ctid` via the Containers table; an + * unresolved owner is stored as null and appears in no user's feed. `ts` + * is epoch seconds and is recorded as `eventAt`. See + * docs/notification-webhook.md for the full contract. + */ + post: operations["ingest_notification"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/all/ack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Acknowledge all of the caller's notifications */ + post: operations["acknowledge_all_notifications"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notifications/{id}/ack": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Acknowledge one notification the caller owns */ + post: operations["acknowledge_notification"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/services/{id}/last-access": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Record service access (proxy accounting) + * @description Called by the site proxy (nginx accounting module) on the first + * request/connection to a service after 10+ minutes of none. Sets the + * service's lastAccessedAt to the current server time. Allowed from + * localhost without credentials (manager bootstrap) or with an admin + * API key. Exempt from the CSRF guard for Bearer/localhost callers. + */ + post: operations["record_service_access"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/external-domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List external domains (admin) */ + get: operations["list_external_domains"]; + put?: never; + /** Create an external domain (admin) */ + post: operations["create_external_domain"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/external-domains/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get: operations["get_external_domain"]; + /** Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted). */ + put: operations["update_external_domain"]; + post?: never; + /** Delete an external domain (admin) */ + delete: operations["delete_external_domain"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List users (admin) */ + get: operations["list_users"]; + put?: never; + /** Create a user (admin) */ + post: operations["create_user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send an email invitation (admin) */ + post: operations["invite_user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/email-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Broadcast an email to every user with an address (admin) */ + post: operations["email_all_users"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{uidNumber}": { + parameters: { + query?: never; + header?: never; + path: { + uidNumber: number; + }; + cookie?: never; + }; + get: operations["get_user"]; + /** Update a user (admin). Partial — omitted/blank fields keep their current values. */ + put: operations["update_user"]; + post?: never; + /** Delete a user (admin) */ + delete: operations["delete_user"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List groups (admin) */ + get: operations["list_groups"]; + put?: never; + /** Create a group (admin) */ + post: operations["create_group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/groups/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description gidNumber */ + id: number; + }; + cookie?: never; + }; + get: operations["get_group"]; + /** Update a group (admin) */ + put: operations["update_group"]; + post?: never; + /** Delete a group (admin) */ + delete: operations["delete_group"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apikeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's API keys */ + get: operations["list_api_keys"]; + put?: never; + /** Mint a new API key (plaintext returned ONCE) */ + post: operations["create_api_key"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apikeys/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get: operations["get_api_key"]; + put?: never; + post?: never; + delete: operations["delete_api_key"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read system settings (admin) */ + get: operations["get_settings"]; + /** Save system settings (admin). Full update — omitted fields are cleared. */ + put: operations["update_settings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resource-requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List resource requests (admins see all; users see only their own) */ + get: operations["list_resource_requests"]; + put?: never; + /** Request a resource adjustment for a container. Auto-approved (and applied to matching provisioned containers) when the caller is an admin or the value is at or below the default (memory 4096 MB, swap 0 MB, cpus 4, rootfs 50 GB); otherwise left pending for admin review. */ + post: operations["create_resource_request"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resource-requests/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Count pending resource requests (for badge display) */ + get: operations["count_pending_resource_requests"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resource-requests/effective/{siteId}/{hostname}/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Effective approved resources for a container identity (defaults merged with the most recent approval per type) */ + get: operations["get_effective_resources"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resource-requests/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Approve a pending request and apply it to matching provisioned containers (admin) */ + put: operations["approve_resource_request"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resource-requests/{id}/deny": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Deny a pending request (admin) */ + put: operations["deny_resource_request"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Error: { + error: { + code: string; + message: string; + fields?: { + [key: string]: string; + }; + }; + }; + DataWrap: { + data: unknown; + }; + Notification: { + id?: number; + /** @description Emitter of the event */ + source?: string; + /** @enum {string} */ + severity?: "info" | "warning" | "critical"; + /** @description Hypervisor node name */ + node?: string | null; + /** @description Container id on the hypervisor (CTID/VMID) */ + ctid?: string | null; + /** @description Owning user (Users.uid); drives per-user visibility */ + owner?: string | null; + /** @description freeze | kill | bump | quarantine | detect | ... */ + action?: string | null; + message?: string; + evidence?: { + [key: string]: unknown; + } | null; + /** + * Format: date-time + * @description When the event happened on the node + */ + eventAt?: string | null; + /** Format: date-time */ + acknowledgedAt?: string | null; + acknowledgedBy?: string | null; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + Site: { + id?: number; + name?: string; + internalDomain?: string; + dhcpRange?: string; + subnetMask?: string; + gateway?: string; + dnsForwarders?: string; + externalIp?: string | null; + /** @description Number of nodes in the site. Present on list/show responses; absent from create/update responses. */ + nodeCount?: number; + }; + SiteInput: { + name?: string; + internalDomain?: string; + dhcpRange?: string; + subnetMask?: string; + gateway?: string; + dnsForwarders?: string; + externalIp?: string | null; + }; + Container: { + id?: number; + hostname?: string; + /** @description Username of the user who created the container */ + owner?: string; + /** @description Usernames the container is shared with */ + collaborators?: string[]; + /** @description Provider container id — Proxmox VMID or Docker container id (null until provisioned) */ + containerId?: string | null; + status?: components["schemas"]["ContainerStatus"]; + template?: string; + ipv4Address?: string | null; + macAddress?: string | null; + creationJobId?: number | null; + entrypoint?: string | null; + environmentVars?: { + [key: string]: string; + }; + nvidiaRequested?: boolean; + /** @description External port of the TCP service on internal port 22, if any */ + sshPort?: number | null; + /** @description Hostname to use with sshPort when connecting over SSH, falling back to the site external IP */ + sshHost?: string | null; + /** @description One entry per HTTP service */ + httpEntries?: { + port?: number; + externalUrl?: string | null; + }[]; + /** + * Format: date-time + * @description Most recent proxy-reported access across the container's services (max of the services' lastAccessedAt). Null when never accessed. + */ + lastAccessedAt?: string | null; + nodeName?: string | null; + nodeApiUrl?: string | null; + volumes?: components["schemas"]["Volume"][]; + services?: components["schemas"]["ContainerService"][]; + /** Format: date-time */ + createdAt?: string; + }; + ContainerService: { + id?: number; + /** @enum {string} */ + type?: "http" | "transport" | "dns"; + internalPort?: number; + /** + * Format: date-time + * @description Set when the site proxy reports traffic to this service — at most once per 10 minutes per service. Null when never accessed. + */ + lastAccessedAt?: string | null; + httpService?: { + id?: number; + externalHostname?: string; + externalDomainId?: number; + /** @enum {string} */ + backendProtocol?: "http" | "https"; + authRequired?: boolean; + domain?: string | null; + } | null; + transportService?: { + id?: number; + /** @enum {string} */ + protocol?: "tcp" | "udp"; + externalPort?: number; + } | null; + dnsService?: { + id?: number; + /** @enum {string} */ + recordType?: "SRV"; + dnsName?: string; + } | null; + }; + /** + * @description Live container status, resolved on read from Proxmox run-state and the create job. running = online in Proxmox; offline = exists in Proxmox but stopped; creating = no Proxmox VM yet, active create job; failed = no Proxmox VM, create job failed; missing = no Proxmox VM, create succeeded or no create job; unknown = Proxmox unreachable / no node credentials. + * @enum {string} + */ + ContainerStatus: "running" | "offline" | "creating" | "failed" | "missing" | "unknown"; + ServiceInput: { + /** + * @description http/https create an HTTP service (https sets backendProtocol to https); srv creates a DNS SRV service; tcp/udp create a transport service with an auto-assigned external port. + * @enum {string} + */ + type: "http" | "https" | "tcp" | "udp" | "srv"; + internalPort: number; + /** @description Required for http/https */ + externalHostname?: string; + /** @description Required for http/https */ + externalDomainId?: number; + /** @description Required for srv */ + dnsName?: string; + authRequired?: boolean; + }; + ServiceUpdate: components["schemas"]["ServiceInput"] & { + /** @description Existing service id. Omit to create a new service. */ + id?: number; + /** @description true deletes the service identified by `id` */ + deleted?: boolean; + }; + EnvVar: { + key?: string; + value?: string; + }; + /** @description A container bind-mount volume (issue #421). Host directories are created by the site agent and attached as Proxmox mpN bind mounts. On Docker nodes, volumes map to Docker binds. */ + Volume: { + id?: number; + /** @description Safe path segment; no traversal/separators */ + name?: string; + /** @description Absolute guest mount point (e.g. /mnt/data) */ + mountPath?: string; + /** @enum {string} */ + mode?: "ro" | "rw"; + /** @description Currently always `container` (per-container). Per-user/site/node is a later extension. */ + scope?: string; + /** @description True only for the retired quick_and_dirty mount recorded on pre-existing containers. Never set on new volumes and never re-applied. */ + builtin?: boolean; + /** + * @description Directory-readiness lifecycle. `pending` until the site agent creates the host directory, then `ready`; `failed` on a mkdir/chown error (see statusMessage). The create job blocks on `ready` before attaching the mount. + * @enum {string} + */ + status?: "pending" | "ready" | "failed"; + /** @description Agent error detail when status is failed */ + statusMessage?: string | null; + /** Format: date-time */ + appliedAt?: string | null; + }; + /** @description A volume to attach. `hostPath` is NOT accepted from clients — it is derived server-side from the node storage's configured path. */ + VolumeAttach: { + /** @description Safe path segment (letters, digits, dot, dash, underscore) */ + name: string; + /** @description Absolute guest mount point; no provider delimiters (comma/colon), backslashes, whitespace, control chars, or . / .. segments */ + mountPath: string; + /** @enum {string} */ + mode: "ro" | "rw"; + }; + /** @description Detach an existing volume by id (update only). */ + VolumeDetach: { + /** @description Existing volume id to detach */ + id: number; + /** + * @description Must be true to detach + * @enum {boolean} + */ + detach: true; + }; + /** @description A single volume change on update: either an attach (`{ name, mountPath, mode }`) or a detach (`{ id, detach: true }`). */ + VolumeChange: components["schemas"]["VolumeAttach"] | components["schemas"]["VolumeDetach"]; + Node: { + id?: number; + name?: string; + /** @enum {string} */ + nodeType?: "proxmox" | "dummy" | "docker"; + siteId?: number; + ipv4Address?: string | null; + /** @description Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376 */ + apiUrl?: string | null; + tokenId?: string | null; + tlsVerify?: boolean | null; + imageStorage?: string; + volumeStorage?: string; + networkBridge?: string; + nvidiaAvailable?: boolean; + hasSecret?: boolean; + /** @description Advisory warnings from the last save (create/update), e.g. the volume storage is not shared across the cluster (issue #421). Present on create/update responses only. */ + warnings?: string[]; + }; + NodeInput: { + name: string; + /** + * @default proxmox + * @enum {string} + */ + nodeType: "proxmox" | "dummy" | "docker"; + ipv4Address?: string | null; + /** @description Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376. Required (and validated) when nodeType is docker. */ + apiUrl?: string | null; + tokenId?: string | null; + /** @description Proxmox API token secret. Never returned (see `hasSecret`). On update, blank/omitted keeps the existing secret. */ + secret?: string; + tlsVerify?: boolean | null; + /** @default local */ + imageStorage: string; + /** @default local-lvm */ + volumeStorage: string; + /** @default vmbr0 */ + networkBridge: string; + /** @default false */ + nvidiaAvailable: boolean; + }; + Job: { + id?: number; + command?: string; + /** @enum {string} */ + status?: "pending" | "running" | "success" | "failure" | "cancelled"; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + JobStatusRow: { + id?: number; + jobId?: number; + output?: string | null; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + ApiKey: { + /** Format: uuid */ + id?: string; + keyPrefix?: string; + description?: string | null; + /** Format: date-time */ + lastUsedAt?: string | null; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + User: { + uidNumber?: number; + /** @description Username */ + uid?: string; + givenName?: string; + sn?: string; + /** @description Full name (derived from givenName + sn) */ + cn?: string; + /** Format: email */ + mail?: string; + /** @description Account status: `pending` until approved, `active` allows login */ + status?: string; + /** @description Present when group memberships are loaded */ + groups?: { + gidNumber?: number; + cn?: string; + isAdmin?: boolean; + }[]; + isAdmin?: boolean; + }; + UserInput: { + uid?: string; + givenName?: string; + sn?: string; + /** Format: email */ + mail?: string; + /** + * Format: password + * @description On update, blank/omitted keeps the existing password. + */ + userPassword?: string; + /** @description Defaults to `pending` on create */ + status?: string; + /** @description gidNumbers. When provided, replaces the user's group memberships. */ + groupIds?: number[]; + }; + Group: { + gidNumber?: number; + cn?: string; + isAdmin?: boolean; + /** @description Present on list/show responses; absent from create/update responses. */ + userCount?: number; + }; + ExternalDomain: { + id?: number; + name?: string; + acmeEmail?: string | null; + acmeDirectoryUrl?: string | null; + cloudflareApiEmail?: string | null; + siteId?: number | null; + site?: { + id?: number; + name?: string; + } | null; + authServer?: string | null; + hasCloudflareApiKey?: boolean; + }; + ExternalDomainInput: { + name?: string; + acmeEmail?: string | null; + acmeDirectoryUrl?: string | null; + cloudflareApiEmail?: string | null; + /** @description Never returned (see `hasCloudflareApiKey`). On update, blank/omitted keeps the existing key. */ + cloudflareApiKey?: string; + siteId?: number | null; + authServer?: string | null; + }; + Settings: { + smtpUrl?: string; + smtpNoreplyAddress?: string; + defaultContainerEnvVars?: { + key?: string; + value?: string; + description?: string; + }[]; + netboxUrl?: string; + netboxToken?: string; + /** @description Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`. */ + bannerMessage?: string; + /** @description Max PSI (pressure stall) probes per usage report, spent on the highest-utilization running containers. Non-negative integer as string; "0" disables PSI, blank uses the default (16). */ + usagePsiProbeLimit?: string; + }; + ResourceRequest: { + id?: number; + siteId?: number; + hostname?: string; + username?: string; + /** @enum {string} */ + resourceType?: "memory" | "swap" | "cpus" | "rootfs"; + /** @description memory/swap in MB, rootfs in GB, cpus as a count */ + value?: number; + /** @enum {string} */ + status?: "pending" | "approved" | "denied"; + comment?: string | null; + adminComment?: string | null; + /** @description Reviewer username, or `system` for auto-approvals */ + reviewedBy?: string | null; + /** Format: date-time */ + reviewedAt?: string | null; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + site?: { + id?: number; + name?: string; + } | null; + }; + CollaboratorList: { + collaborators?: string[]; + }; + }; + responses: { + /** @description Resource not found */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + Forbidden: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid request (code: invalid_request) */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: { + /** @description CSRF token returned by GET /csrf-token. Required for state-changing requests under session auth. */ + XCSRFToken: string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_csrf_token: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + csrfToken?: string; + }; + }; + }; + }; + }; + }; + get_health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + status?: string; + isDev?: boolean; + oidcEnabled?: boolean; + /** @description Admin-configured announcement banner (null when unset) */ + banner?: string | null; + }; + }; + }; + }; + }; + }; + get_openapi_json: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI document */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + get_openapi_yaml: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI document */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/yaml": unknown; + }; + }; + }; + }; + get_session: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Session payload */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + user?: string; + isAdmin?: boolean; + }; + }; + }; + }; + /** @description Not authenticated */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + username: string; + /** Format: password */ + password: string; + redirect?: string; + }; + }; + }; + responses: { + /** @description Logged in */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + user?: string; + isAdmin?: boolean; + redirect?: string; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description Invalid credentials (code: invalid_credentials) */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description OIDC enabled (code: oidc_enabled) — use SSO instead; or account not active (code: account_inactive) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + oidc_login: { + parameters: { + query?: { + redirect?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the identity provider authorization endpoint */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description OIDC not configured (code: oidc_disabled) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + oidc_callback: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the post-login destination on success, or /login?oidc_error= on failure */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description OIDC not configured (code: oidc_disabled) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + cli_login_authorize: { + parameters: { + query: { + /** @description Loopback port the CLI listens on (host is always 127.0.0.1) */ + port: number; + /** @description CLI-generated one-time nonce */ + state: string; + /** @description Label recorded in the minted key's description */ + client?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTML confirmation page */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Redirect to sign-in when there is no session */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description HTML error page for invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + cli_login_mint: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/x-www-form-urlencoded": { + _csrf: string; + port: number; + state: string; + client?: string; + }; + }; + }; + responses: { + /** @description Redirect to the loopback listener with the key in the fragment */ + 303: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description HTML error page for invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No browser session */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing/invalid CSRF token */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Logged out */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + loggedOut?: boolean; + logoutUrl?: string | null; + }; + }; + }; + }; + }; + }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + uid: string; + givenName: string; + sn: string; + /** Format: email */ + mail: string; + /** Format: password */ + userPassword: string; + inviteToken?: string; + }; + }; + }; + responses: { + /** @description Account created (`status` is `active` for the first user or invited users, otherwise `pending`) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + uid?: string; + status?: string; + message?: string; + }; + }; + }; + }; + /** @description invalid_request, invalid_invite, or email_mismatch */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description OIDC enabled (code: oidc_enabled) — self-registration disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + validate_invite: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invite valid, returns email */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + email?: string; + }; + }; + }; + }; + /** @description OIDC enabled (code: oidc_enabled) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid or expired invitation (code: invalid_invite) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + request_password_reset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + usernameOrEmail: string; + }; + }; + }; + responses: { + /** @description Generic OK (does not reveal account existence) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + }; + }; + validate_password_reset_token: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token valid, returns username */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + username?: string; + }; + }; + }; + }; + /** @description Invalid or expired (code: invalid_token) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + reset_password: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Minimum 8 characters */ + password: string; + confirmPassword: string; + }; + }; + }; + responses: { + /** @description Password reset */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_request, mismatch, or weak_password */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid or expired (code: invalid_token) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_sites: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of sites */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Site"][]; + }; + }; + }; + }; + }; + create_site: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SiteInput"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Site"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + get_site: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Site */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Site"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + update_site: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SiteInput"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Site"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete_site: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Site still has nodes (code: has_nodes) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_containers: { + parameters: { + query?: { + hostname?: string; + /** @description Filter to a single node belonging to the site */ + nodeId?: number; + /** @description Owner filter, sent in bracket notation (`user[0]=alice&user[1]=bob`) so it always parses as a list. Omitted/empty returns everything the caller may see: every container on the site for admins, or their own plus any shared with them for non-admins. A list of usernames returns those owners' containers for admins; for non-admins the list is intersected with what they may already see (own plus shared), so it can only narrow visibility. */ + user?: string[]; + }; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of containers */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Container"][]; + }; + }; + }; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + create_container: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + hostname: string; + /** @description Docker image reference, or `custom` to use `customTemplate` */ + template: string; + /** @description Image reference used when `template` is `custom` */ + customTemplate?: string; + entrypoint?: string; + nvidiaRequested?: boolean; + /** @description (Admin only) Create the container attributed to this existing user instead of the authenticated admin */ + username?: string; + /** @description Usernames to share the new container with (must exist) */ + collaborators?: string[]; + environmentVars?: components["schemas"]["EnvVar"][]; + /** @description Volumes (bind mounts) to attach to the new container */ + volumes?: components["schemas"]["VolumeAttach"][]; + /** @description Keyed map of services to create (keys are arbitrary) */ + services?: { + [key: string]: components["schemas"]["ServiceInput"]; + }; + }; + }; + }; + responses: { + /** @description Creation job enqueued */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + /** @description Database id of the new container */ + containerId?: number; + jobId?: number; + hostname?: string; + status?: components["schemas"]["ContainerStatus"]; + }; + }; + }; + }; + /** @description invalid_request, invalid_service, or invalid_volume */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description forbidden — non-admins may not create containers for other users */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description site_not_found, or user_not_found when a collaborator does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description No provisionable node available (code: no_node or no_nvidia_node) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_new_container_form: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bootstrap payload */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + siteId?: number; + externalDomains?: Record[]; + nvidiaAvailable?: boolean; + }; + }; + }; + }; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_image_metadata: { + parameters: { + query: { + image: string; + }; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Image metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + /** @description Image not found (code: image_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Registry lookup failed (code: registry_error) */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_container: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Container */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Container"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + update_container: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description (Admin only) Reassign ownership of this container to the specified existing user */ + username?: string; + /** @description Keyed map of service changes (keys are arbitrary). Entries with `deleted: true` remove the service `id`; entries with an `id` may toggle `authRequired`; entries without an `id` create a new service. */ + services?: { + [key: string]: components["schemas"]["ServiceUpdate"]; + }; + /** @description Full replacement set. Omitting it clears all user env vars (unless the request is restart-only). */ + environmentVars?: components["schemas"]["EnvVar"][]; + /** @description Omitting/blank clears the entrypoint (unless the request is restart-only) */ + entrypoint?: string | null; + /** @description Volume changes. Attach entries are `{ name, mountPath, mode }`; detach entries are `{ id, detach: true }`. A volume mutation on a provisioned container enqueues a reconfigure job to apply it. */ + volumes?: components["schemas"]["VolumeChange"][]; + /** @description A restart job is enqueued only when true — config changes alone never restart the container (they apply on the next restart); alone, it performs a restart-only request */ + restart?: boolean; + }; + }; + }; + responses: { + /** @description Updated, optional restart job */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + containerId?: number; + /** @description Restart job id, when a restart was enqueued */ + jobId?: number | null; + dnsWarnings?: string[]; + /** @description true when env/entrypoint changes were saved but no restart was requested — they apply on the next restart */ + pendingRestart?: boolean; + message?: string; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + delete_container: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted, with DNS cleanup warnings */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + deleted?: boolean; + dnsWarnings?: string[]; + }; + }; + }; + }; + /** @description forbidden — only the owner/admin may delete (collaborators can view but not manage) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 404: components["responses"]["NotFound"]; + /** @description DB/Proxmox hostname mismatch — delete aborted (code: hostname_mismatch) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_container_collaborators: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Collaborator list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["CollaboratorList"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + add_container_collaborator: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + username: string; + }; + }; + }; + responses: { + /** @description Collaborator list */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["CollaboratorList"]; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description forbidden — only the owner/admin may share (collaborators can view but not manage) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description user_not_found when the username does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description already_owner */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + remove_container_collaborator: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Collaborator list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["CollaboratorList"]; + }; + }; + }; + /** @description forbidden — only the owner/admin may unshare (collaborators can view but not manage) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Container or collaborator not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_nodes: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of nodes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Node"][]; + }; + }; + }; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + create_node: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NodeInput"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Node"]; + }; + }; + }; + /** @description invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + import_nodes: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uri */ + apiUrl: string; + username: string; + password: string; + tlsVerify?: boolean; + }; + }; + }; + responses: { + /** @description Imported */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + nodes?: components["schemas"]["Node"][]; + importedContainerCount?: number; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + /** @description Proxmox import failed (code: import_failed) */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_node: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Node */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Node"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + update_node: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NodeInput"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Node"]; + }; + }; + }; + /** @description invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete_node: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Node still has containers (code: has_containers) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_node_storages: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of Proxmox CT template storages */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + name?: string; + total?: number; + available?: number; + }[]; + }; + }; + }; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_node_stats: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Live hardware utilization. `available` is false when the node has no API credentials or the hypervisor is unreachable. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: Record; + }; + }; + }; + /** @description Node not found (code: not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_site_usage: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Per-owner usage rows with per-container detail */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + /** Format: date-time */ + generatedAt?: string; + owners?: Record[]; + /** @description Physical cluster capacity summed from the hypervisor node rows */ + capacity?: { + cpuCores?: number; + memBytes?: number; + diskBytes?: number; + }; + /** @description Admin-only attribution findings (kind drift|unattributed) */ + findings?: Record[]; + /** @description Admin-only count of cluster members not registered in the manager DB */ + unknownNodeRows?: number; + }; + }; + }; + }; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_job: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Job metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Job"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + get_job_status: { + parameters: { + query?: { + offset?: number; + limit?: number; + }; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status rows */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["JobStatusRow"][]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + stream_job_output: { + parameters: { + query?: { + lastId?: number; + }; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SSE stream — `log` events (`{id, output, timestamp}`) and a final `status` event (`{status}`) when the job leaves pending/running. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": unknown; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + list_agents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of agents */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + id?: number; + siteId?: number; + siteName?: string | null; + hostname?: string; + ipv4Address?: string | null; + services?: Record | null; + /** Format: date-time */ + lastCheckinAt?: string | null; + /** @description Computed server-side at response time */ + secondsSinceCheckin?: number | null; + }[]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + agent_check_in: { + parameters: { + query?: never; + header?: { + "If-None-Match"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + siteId: number; + hostname: string; + /** @description Agent time, epoch seconds UTC */ + currentTime?: number; + ipv4Address?: string | null; + services?: { + [key: string]: { + /** @description systemd ActiveState: active, inactive, failed, ... */ + state?: string; + /** @enum {string} */ + lastApply?: "success" | "failure" | "unknown"; + }; + }; + /** @description Per-volume directory-provisioning results keyed by the manager-assigned Volume id. The manager writes these into Volume.status (ready when applied, failed otherwise) at check-in (issue #421). */ + volumes?: { + [key: string]: { + applied?: boolean; + /** @description Failure detail (mkdir/chown error) */ + message?: string; + }; + }; + }; + }; + }; + responses: { + /** @description Config snapshot (`{ data: { site, nginx } }`) with `ETag` header */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Config unchanged since If-None-Match */ + 304: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing siteId/hostname (code: validation_failed) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + list_notifications: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of the caller's notifications */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["Notification"][]; + }; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + ingest_notification: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @example lxc-oomd */ + source: string; + /** @enum {string} */ + severity: "info" | "warning" | "critical"; + /** @example opensource-phxdc-pve1 */ + node?: string | null; + /** @example 392 */ + ctid?: (number | string) | null; + /** @example mbachelder */ + owner?: string | null; + /** @example freeze */ + action?: string | null; + /** @example CT 392 frozen: memory PSI full avg10=83 for 45s */ + message: string; + evidence?: { + [key: string]: unknown; + } | null; + /** + * @description Epoch seconds when the event occurred + * @example 1771234560 + */ + ts?: number; + }; + }; + }; + responses: { + /** @description Created — the persisted notification */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["Notification"]; + }; + }; + }; + /** @description Invalid payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Missing/invalid credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Non-admin credentials */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + acknowledge_all_notifications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Number of notifications acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + /** @description Count of notifications marked read */ + acknowledged: number; + }; + }; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + acknowledge_notification: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The updated notification */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["Notification"]; + }; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found or not owned by the caller */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + record_service_access: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Access recorded */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Non-numeric id (code: invalid_request) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Missing/invalid credentials from a non-localhost caller */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + list_external_domains: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ExternalDomain"][]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + create_external_domain: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ExternalDomainInput"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ExternalDomain"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + get_external_domain: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Item */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ExternalDomain"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + update_external_domain: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ExternalDomainInput"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ExternalDomain"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete_external_domain: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + list_users: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["User"][]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + create_user: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserInput"] & unknown; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["User"]; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + }; + }; + invite_user: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: email */ + email: string; + }; + }; + }; + responses: { + /** @description Invitation sent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + email?: string; + message?: string; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + /** @description smtp_not_configured or duplicate_email */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Email delivery failed (code: email_failed) */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + email_all_users: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + subject: string; + message: string; + }; + }; + }; + responses: { + /** @description Broadcast result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + sent?: number; + failed?: number; + recipients?: number; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 403: components["responses"]["Forbidden"]; + /** @description smtp_not_configured or no_recipients */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + get_user: { + parameters: { + query?: never; + header?: never; + path: { + uidNumber: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["User"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + update_user: { + parameters: { + query?: never; + header?: never; + path: { + uidNumber: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserInput"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["User"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete_user: { + parameters: { + query?: never; + header?: never; + path: { + uidNumber: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + list_groups: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Group"][]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + create_group: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + gidNumber: number; + cn: string; + isAdmin?: boolean; + }; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Group"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + get_group: { + parameters: { + query?: never; + header?: never; + path: { + /** @description gidNumber */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Group */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Group"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + update_group: { + parameters: { + query?: never; + header?: never; + path: { + /** @description gidNumber */ + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + cn?: string; + isAdmin?: boolean; + }; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Group"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete_group: { + parameters: { + query?: never; + header?: never; + path: { + /** @description gidNumber */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + }; + }; + list_api_keys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of current user's keys */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ApiKey"][]; + }; + }; + }; + }; + }; + create_api_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + description?: string | null; + }; + }; + }; + responses: { + /** @description Created with plaintext `key` */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ApiKey"] & { + /** @description Plaintext API key — this is the only time it is returned */ + key?: string; + warning?: string; + }; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + }; + }; + get_api_key: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Key metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ApiKey"]; + }; + }; + }; + /** @description Malformed id — must be a UUID (code: invalid_request) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + delete_api_key: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Malformed id — must be a UUID (code: invalid_request) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + get_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description System settings */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["Settings"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + update_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Settings"]; + }; + }; + responses: { + /** @description Saved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + saved?: boolean; + }; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + }; + }; + list_resource_requests: { + parameters: { + query?: { + /** @description `closed` matches approved + denied */ + status?: "pending" | "approved" | "denied" | "closed"; + siteId?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of resource requests */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ResourceRequest"][]; + }; + }; + }; + }; + }; + create_resource_request: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + siteId: number; + hostname: string; + /** @enum {string} */ + resourceType: "memory" | "swap" | "cpus" | "rootfs"; + /** @description memory/swap in MB, rootfs in GB, cpus as a count */ + value: number; + comment?: string; + }; + }; + }; + responses: { + /** @description Request created (`status` reflects any auto-approval) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ResourceRequest"]; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description Site not found (code: site_not_found) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + count_pending_resource_requests: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pending count */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + count?: number; + }; + }; + }; + }; + }; + }; + get_effective_resources: { + parameters: { + query?: never; + header?: never; + path: { + siteId: number; + hostname: string; + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Effective resource values */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: { + memory?: number; + swap?: number; + cpus?: number; + rootfs?: number; + }; + }; + }; + }; + /** @description forbidden — non-admins may only query their own username */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + approve_resource_request: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + adminComment?: string; + }; + }; + }; + responses: { + /** @description Approved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ResourceRequest"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Request already reviewed (code: already_reviewed) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description No provisioned container matches the request (code: container_not_found) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deny_resource_request: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + adminComment?: string; + }; + }; + }; + responses: { + /** @description Denied */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data?: components["schemas"]["ResourceRequest"]; + }; + }; + }; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Request already reviewed (code: already_reviewed) */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; +} diff --git a/packages/os-cloud-provider/src/index.ts b/packages/os-cloud-provider/src/index.ts new file mode 100644 index 00000000..1268ac1b --- /dev/null +++ b/packages/os-cloud-provider/src/index.ts @@ -0,0 +1,67 @@ +/** + * `@mieweb/os-cloud-provider` — the opensource-server (os.mieweb.org) + * implementation of the `@mieweb/deploy-contract` DeployProvider. + * + * `mieweb deploy --target mieweb` resolves this package from the app's + * node_modules via `targets.mieweb.provider` and calls `createProvider(env)`. + * + * Verbs: deploy, destroy, whoami, login, logout. `deploy` converges the + * container through the Manager API (skipped when nothing changed) and then + * syncs the local worktree into it over its published SSH port (pure-JS + * SSH via `ssh2`; no local rsync/ssh binaries needed). + * - `dev` is intentionally omitted: there is no remote analogue; local dev + * uses the CLI's own host harness. + * - `tail` is omitted for v1: the Manager streams job output only, not the + * running app's stdout/stderr. + * + * Secrets come only from `env` (MIEWEB_OS_TOKEN, MIEWEB_OS_SECRET_*) or the + * machine-local login cache, never from the DeployContext. + */ + +import type { DeployContext, DeployProvider, DeployTarget, ProviderEnv } from '@mieweb/deploy-contract'; +import { login, logout, whoami, type LoginHooks } from './auth.ts'; +import { PROVIDER_NAME } from './config.ts'; +import { deploy, destroy, type ProviderDeps } from './deploy.ts'; +import type { Prompter, waitForSsh } from './ssh.ts'; + +export { PROVIDER_NAME, DEFAULT_IMAGE, DEFAULT_INSTANCE_URL, DATA_VOLUME, ConfigError } from './config.ts'; +export { ManagerApiError } from './client.ts'; +export { JobFailedError } from './jobs.ts'; + +export interface ProviderOptions { + /** Injected fetch (tests). */ + fetch?: typeof fetch; + /** Job poll interval in ms (default 2000). */ + pollIntervalMs?: number; + /** Browser/prompt hooks for `login` (tests). */ + login?: LoginHooks; + /** Open the sync SSH session (tests). */ + connectSsh?: ProviderDeps['connectSsh']; + /** Terminal prompt for SSH passphrases/passwords. */ + prompt?: Prompter; + /** SSH readiness probe (tests). */ + waitForSsh?: typeof waitForSsh; +} + +/** The targets this provider serves. */ +export const SUPPORTED_TARGETS: readonly DeployTarget[] = ['mieweb']; + +export function createProvider(env: ProviderEnv, options: ProviderOptions = {}): DeployProvider { + const deps: ProviderDeps = { + env, + fetch: options.fetch, + pollIntervalMs: options.pollIntervalMs, + connectSsh: options.connectSsh, + prompt: options.prompt, + waitForSsh: options.waitForSsh, + }; + return { + name: PROVIDER_NAME, + supports: (target: DeployTarget) => SUPPORTED_TARGETS.includes(target), + deploy: (ctx: DeployContext) => deploy(ctx, deps), + destroy: (ctx: DeployContext) => destroy(ctx, deps), + whoami: (ctx: DeployContext) => whoami(ctx, deps), + login: (ctx: DeployContext) => login(ctx, deps, options.login), + logout: (ctx: DeployContext) => logout(ctx, deps), + }; +} diff --git a/packages/os-cloud-provider/src/jobs.ts b/packages/os-cloud-provider/src/jobs.ts new file mode 100644 index 00000000..a6ddce82 --- /dev/null +++ b/packages/os-cloud-provider/src/jobs.ts @@ -0,0 +1,89 @@ +/** + * Job polling. `get_job` (`GET /jobs/{id}`) carries the terminal verdict + * (`Job.status`); `GET /jobs/{id}/status` only returns output log rows, which + * we forward to the logger as they arrive. + */ + +import type { DeployLogger } from '@mieweb/deploy-contract'; +import type { Job, JobLogRow } from './api-types.ts'; +import type { ManagerClient } from './client.ts'; + +export interface WaitOptions { + signal: AbortSignal; + logger: DeployLogger; + intervalMs?: number; + timeoutMs?: number; + /** Forward the job's output rows to the logger (default true). */ + streamLogs?: boolean; +} + +export class JobFailedError extends Error { + readonly jobId: number; + readonly status: string; + constructor(jobId: number, status: string, tail: string[]) { + super(`Manager job ${jobId} ended with status "${status}"${tail.length ? `:\n${tail.join('\n')}` : ''}`); + this.name = 'JobFailedError'; + this.jobId = jobId; + this.status = status; + } +} + +/** Resolve after `ms`, or reject with the signal's reason on abort. */ +export function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +const TERMINAL = new Set(['success', 'failure', 'cancelled']); + +/** Poll a job until it reaches a terminal status; throw unless it succeeded. */ +export async function waitForJob(client: ManagerClient, jobId: number, opts: WaitOptions): Promise { + const interval = opts.intervalMs ?? 2000; + const deadline = Date.now() + (opts.timeoutMs ?? 30 * 60 * 1000); + const stream = opts.streamLogs !== false; + const tail: string[] = []; + let offset = 0; + + const drainLogs = async (): Promise => { + if (!stream) return; + for (;;) { + const rows = await client.get(`/jobs/${jobId}/status`, { offset, limit: 500 }); + for (const row of rows) { + for (const line of String(row.output ?? '').split('\n')) { + if (line.trim() === '') continue; + opts.logger.info(` [job ${jobId}] ${line}`); + tail.push(line); + if (tail.length > 20) tail.shift(); + } + } + offset += rows.length; + if (rows.length < 500) return; + } + }; + + for (;;) { + const job = await client.get(`/jobs/${jobId}`); + await drainLogs(); + if (TERMINAL.has(job.status)) { + if (job.status === 'success') return; + throw new JobFailedError(jobId, job.status, tail); + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for Manager job ${jobId} (last status "${job.status}")`); + } + await sleep(interval, opts.signal); + } +} diff --git a/packages/os-cloud-provider/src/ssh.ts b/packages/os-cloud-provider/src/ssh.ts new file mode 100644 index 00000000..3b83b560 --- /dev/null +++ b/packages/os-cloud-provider/src/ssh.ts @@ -0,0 +1,415 @@ +/** + * Pure-JS SSH (the `ssh2` package): no `ssh`/`rsync` binaries needed. + * + * Authentication uses the user's own local credentials, in order: + * 1. ssh-agent (`SSH_AUTH_SOCK`; Pageant on Windows) + * 2. default keys in ~/.ssh (id_ed25519, id_ecdsa, id_rsa), prompting for a + * passphrase on the terminal when a key is encrypted + * 3. keyboard-interactive / password, prompted on the terminal + * Prompts go to stderr and only happen when stdin is a TTY. + * + * Host keys are pinned trust-on-first-use in ~/.mieweb/known_hosts + * (`[host]:port SHA256:`). A changed key is an error unless the + * caller cleared the pin first (it does after recreating the container). + */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { connect as netConnect } from 'node:net'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { DeployLogger, ProviderEnv } from '@mieweb/deploy-contract'; +import ssh2 from 'ssh2'; +import type { AnyAuthMethod, AuthenticationType, ClientChannel, ConnectConfig, Prompt } from 'ssh2'; +import { sleep } from './jobs.ts'; + +const { Client, utils } = ssh2; + +export interface SshTarget { + host: string; + port: number; + user: string; +} + +export interface ExecResult { + code: number; + stdout: Buffer; + stderr: string; +} + +/** The remote operations sync needs. `SshConnection` implements it over ssh2. */ +export interface RemoteShell { + exec(command: string, stdin?: Buffer | NodeJS.ReadableStream): Promise; + close(): void; +} + +/** Terminal prompt; `hidden` suppresses echo. Returns null when not interactive. */ +export type Prompter = (question: string, hidden: boolean) => Promise; + +export const ttyPrompter: Prompter = (question, hidden) => + new Promise((resolve) => { + const stdin = process.stdin; + if (!stdin.isTTY) { + resolve(null); + return; + } + process.stderr.write(question); + let answer = ''; + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + const onData = (chunk: string): void => { + for (const ch of chunk) { + if (ch === '\r' || ch === '\n') { + done(answer); + return; + } + if (ch === '\u0003') { + done(null); + return; + } + if (ch === '\u007f' || ch === '\b') { + if (answer.length > 0) { + answer = answer.slice(0, -1); + if (!hidden) process.stderr.write('\b \b'); + } + continue; + } + answer += ch; + if (!hidden) process.stderr.write(ch); + } + }; + const done = (value: string | null): void => { + stdin.off('data', onData); + stdin.setRawMode(wasRaw); + stdin.pause(); + process.stderr.write('\n'); + resolve(value); + }; + stdin.on('data', onData); + }); + +// --- known hosts ------------------------------------------------------------ + +export function knownHostsPath(env: ProviderEnv): string { + return join(env.HOME?.trim() || homedir(), '.mieweb', 'known_hosts'); +} + +function hostKeyId(host: string, port: number): string { + return `[${host}]:${port}`; +} + +export function fingerprint(key: Buffer): string { + return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/, '')}`; +} + +function readKnownHosts(file: string): Map { + const map = new Map(); + if (!existsSync(file)) return map; + for (const line of readFileSync(file, 'utf8').split('\n')) { + const [id, fp] = line.trim().split(/\s+/); + if (id && fp) map.set(id, fp); + } + return map; +} + +async function writeKnownHosts(file: string, map: Map): Promise { + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + await writeFile(file, [...map].map(([id, fp]) => `${id} ${fp}\n`).join(''), { mode: 0o600 }); +} + +/** Drop the pinned key for host:port (the container behind it was replaced). */ +export async function forgetHostKey(file: string, host: string, port: number): Promise { + const map = readKnownHosts(file); + if (map.delete(hostKeyId(host, port))) await writeKnownHosts(file, map); +} + +// --- auth ------------------------------------------------------------------- + +const DEFAULT_KEYS = ['id_ed25519', 'id_ecdsa', 'id_rsa']; + +interface KeyCandidate { + path: string; + data: Buffer; + encrypted: boolean; +} + +function localKeys(env: ProviderEnv): KeyCandidate[] { + const dir = join(env.HOME?.trim() || homedir(), '.ssh'); + const out: KeyCandidate[] = []; + for (const name of DEFAULT_KEYS) { + const path = join(dir, name); + let data: Buffer; + try { + data = readFileSync(path); + } catch { + continue; + } + const parsed = utils.parseKey(data); + if (parsed instanceof Error) { + if (/encrypted|passphrase/i.test(parsed.message)) out.push({ path, data, encrypted: true }); + continue; + } + out.push({ path, data, encrypted: false }); + } + return out; +} + +/** + * Build the ssh2 authHandler. Methods are tried in order; ones the server + * doesn't offer are skipped. The password is asked for at most once and + * reused for keyboard-interactive. + */ +export function makeAuthHandler( + user: string, + env: ProviderEnv, + prompt: Prompter, + target: string, + interactive: boolean = !!process.stdin.isTTY, +) { + let password: string | null | undefined; + const askPassword = async (): Promise => { + if (password === undefined) password = await prompt(`${user}@${target}'s password: `, true); + return password; + }; + + type Step = () => Promise; + const steps: { type: AuthenticationType; step: Step }[] = []; + + const agent = env.SSH_AUTH_SOCK?.trim() || (process.platform === 'win32' ? 'pageant' : ''); + if (agent) steps.push({ type: 'publickey', step: async () => ({ type: 'agent', username: user, agent }) }); + + for (const key of localKeys(env)) { + steps.push({ + type: 'publickey', + step: async () => { + if (!key.encrypted) return { type: 'publickey', username: user, key: key.data }; + if (!interactive) return null; + const passphrase = await prompt(`Enter passphrase for key '${key.path}': `, true); + if (!passphrase) return null; + if (utils.parseKey(key.data, passphrase) instanceof Error) return null; + return { type: 'publickey', username: user, key: key.data, passphrase }; + }, + }); + } + + steps.push({ + type: 'keyboard-interactive', + step: async () => (!interactive ? null : { + type: 'keyboard-interactive', + username: user, + prompt: (_name: string, _instr: string, _lang: string, prompts: Prompt[], finish: (answers: string[]) => void) => { + void (async () => { + const answers: string[] = []; + for (const p of prompts) { + const isPassword = /password/i.test(p.prompt) && !p.echo; + const answer = isPassword ? await askPassword() : await prompt(p.prompt, !p.echo); + answers.push(answer ?? ''); + } + finish(answers); + })(); + }, + }), + }); + steps.push({ + type: 'password', + step: async () => { + if (!interactive) return null; + const pw = await askPassword(); + return pw === null ? null : { type: 'password', username: user, password: pw }; + }, + }); + + let i = 0; + return (authsLeft: AuthenticationType[] | null, _partial: boolean, next: (m: AnyAuthMethod | false) => void): void => { + void (async () => { + while (i < steps.length) { + const s = steps[i++]!; + if (authsLeft && !authsLeft.includes(s.type)) continue; + const method = await s.step(); + if (method) { + next(method); + return; + } + } + next(false); + })(); + }; +} + +// --- connection ------------------------------------------------------------- + +export interface ConnectOptions { + target: SshTarget; + env: ProviderEnv; + knownHostsFile: string; + prompt?: Prompter; + /** Whether prompting is possible (default: stdin is a TTY). */ + interactive?: boolean; + signal: AbortSignal; + logger: DeployLogger; + timeoutMs?: number; +} + +export class SshConnection implements RemoteShell { + private readonly client: InstanceType; + private constructor(client: InstanceType) { + this.client = client; + } + + static connect(opts: ConnectOptions): Promise { + const { target, logger } = opts; + const id = hostKeyId(target.host, target.port); + const known = readKnownHosts(opts.knownHostsFile); + let pinned: { fp: string } | null = null; + let mismatch: string | null = null; + + return new Promise((resolve, reject) => { + const client = new Client(); + const onAbort = (): void => { + client.end(); + reject(opts.signal.reason); + }; + opts.signal.addEventListener('abort', onAbort, { once: true }); + const fail = (err: Error): void => { + opts.signal.removeEventListener('abort', onAbort); + if (mismatch) { + reject( + new Error( + `SSH host key for ${id} changed (expected ${known.get(id)}, got ${mismatch}). ` + + `If the container was rebuilt, remove that line from ${opts.knownHostsFile}.`, + ), + ); + } else if (/authentication methods failed/i.test(err.message)) { + reject( + new Error( + `SSH authentication as ${target.user}@${target.host}:${target.port} failed. ` + + 'Add your public key to your account, load it into ssh-agent, or run deploy in a terminal to enter your password.', + ), + ); + } else reject(new Error(`SSH connection to ${target.host}:${target.port} failed: ${err.message}`, { cause: err })); + }; + client.once('error', fail); + client.once('ready', () => { + opts.signal.removeEventListener('abort', onAbort); + client.off('error', fail); + client.on('error', () => {}); + const done = (): void => resolve(new SshConnection(client)); + if (pinned) { + known.set(id, pinned.fp); + logger.info(`Trusting SSH host key ${pinned.fp} for ${id}`); + writeKnownHosts(opts.knownHostsFile, known).then(done, done); + } else done(); + }); + + const config: ConnectConfig = { + host: target.host, + port: target.port, + username: target.user, + readyTimeout: opts.timeoutMs ?? 20_000, + keepaliveInterval: 15_000, + hostVerifier: (key: Buffer) => { + const fp = fingerprint(key); + const expected = known.get(id); + if (!expected) { + pinned = { fp }; + return true; + } + if (expected === fp) return true; + mismatch = fp; + return false; + }, + authHandler: makeAuthHandler( + target.user, + opts.env, + opts.prompt ?? ttyPrompter, + `${target.host}:${target.port}`, + opts.interactive ?? !!process.stdin.isTTY, + ) as ConnectConfig['authHandler'], + }; + client.connect(config); + }); + } + + exec(command: string, stdin?: Buffer | NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + this.client.exec(command, (err: Error | undefined, stream: ClientChannel) => { + if (err) { + reject(err); + return; + } + const out: Buffer[] = []; + let stderr = ''; + let code = -1; + stream.on('data', (d: Buffer) => out.push(d)); + stream.stderr.on('data', (d: Buffer) => { + stderr = (stderr + d.toString('utf8')).slice(-8192); + }); + stream.on('exit', (c: number | null) => { + code = c ?? -1; + }); + stream.on('close', () => resolve({ code, stdout: Buffer.concat(out), stderr })); + stream.on('error', reject); + if (stdin === undefined) stream.end(); + else if (Buffer.isBuffer(stdin)) stream.end(stdin); + else { + stdin.on('error', () => stream.close()); + stdin.pipe(stream); + } + }); + }); + } + + close(): void { + this.client.end(); + } +} + +// --- readiness -------------------------------------------------------------- + +/** Resolve once host:port answers with an SSH banner. */ +export async function waitForSsh( + host: string, + port: number, + signal: AbortSignal, + logger: DeployLogger, + timeoutMs = 5 * 60 * 1000, +): Promise { + const deadline = Date.now() + timeoutMs; + let announced = false; + for (;;) { + if (await probeSsh(host, port, signal)) return; + if (!announced) { + logger.info(`Waiting for SSH on ${host}:${port}…`); + announced = true; + } + if (Date.now() > deadline) { + throw new Error(`SSH on ${host}:${port} did not become available within ${Math.round(timeoutMs / 1000)}s`); + } + await sleep(2000, signal); + } +} + +function probeSsh(host: string, port: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const sock = netConnect({ host, port, timeout: 5000 }); + let buf = ''; + const done = (ok: boolean): void => { + signal.removeEventListener('abort', onAbort); + sock.destroy(); + resolve(ok); + }; + const onAbort = (): void => done(false); + signal.addEventListener('abort', onAbort, { once: true }); + sock.setEncoding('latin1'); + sock.on('data', (d: string) => { + buf += d; + if (buf.includes('\n') || buf.length >= 4) done(buf.startsWith('SSH-')); + }); + sock.on('timeout', () => done(false)); + sock.on('error', () => done(false)); + sock.on('end', () => done(false)); + }); +} diff --git a/packages/os-cloud-provider/src/sync.ts b/packages/os-cloud-provider/src/sync.ts new file mode 100644 index 00000000..3e844c7e --- /dev/null +++ b/packages/os-cloud-provider/src/sync.ts @@ -0,0 +1,218 @@ +/** + * Code delivery: copy the local worktree into the converged container over + * SSH and restart `app.service`. rsync-like, implemented in JS (no local + * rsync/ssh binaries): + * + * 1. Scan the worktree. `.gitignore` rules are honored (nested files too, via + * the `ignore` package), `.git/` is skipped. Staged vs. committed status + * doesn't matter; the files on disk are what gets sent. + * 2. List the remote tree (`find -printf`) and diff by size + mtime. + * 3. Stream a tar of the changed files into `sudo tar -x` (owned by the + * `mieweb` service account, mtimes preserved so the next diff is exact). + * 4. Delete remote files that no longer exist locally. Remote paths matching + * the ignore rules (node_modules, build output) are left alone. + * 5. `sudo systemctl restart app.service`. + * + * LDAP users have passwordless sudo in the base image. + */ + +import { createReadStream } from 'node:fs'; +import { lstat, readdir, readFile, readlink } from 'node:fs/promises'; +import { posix, join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { DeployLogger } from '@mieweb/deploy-contract'; +import ignore, { type Ignore } from 'ignore'; +import tarStream from 'tar-stream'; +import type { RemoteShell } from './ssh.ts'; + +export const REMOTE_APP_DIR = '/opt/app/src'; +const OWNER = 'mieweb'; + +export interface FileEntry { + /** POSIX path relative to the root. */ + path: string; + type: 'file' | 'symlink'; + size: number; + /** Seconds since the epoch (whole seconds; tar precision). */ + mtime: number; + mode: number; + linkname?: string; +} + +/** `.gitignore` rules scoped to the directories that declare them. */ +export class IgnoreRules { + private readonly scopes: { prefix: string; ig: Ignore }[] = []; + + add(dir: string, content: string): void { + this.scopes.push({ prefix: dir === '' ? '' : `${dir}/`, ig: ignore().add(content) }); + } + + /** Whether `path` itself (not its ancestors) matches a rule. */ + private matches(path: string, isDir: boolean): boolean { + let ignored = false; + for (const { prefix, ig } of this.scopes) { + if (!path.startsWith(prefix)) continue; + const rel = path.slice(prefix.length); + if (rel === '') continue; + const r = ig.test(isDir ? `${rel}/` : rel); + if (r.ignored) ignored = true; + else if (r.unignored) ignored = false; + } + return ignored; + } + + /** Whether `path` or any directory above it is ignored (or is `.git`). */ + ignores(path: string, isDir = false): boolean { + const parts = path.split('/'); + for (let i = 1; i <= parts.length; i += 1) { + const sub = parts.slice(0, i).join('/'); + const subIsDir = i < parts.length || isDir; + if (parts[i - 1] === '.git' && subIsDir) return true; + if (this.matches(sub, subIsDir)) return true; + } + return false; + } +} + +/** Walk the worktree, honoring .gitignore. */ +export async function scanLocal(root: string): Promise<{ files: Map; rules: IgnoreRules }> { + const rules = new IgnoreRules(); + const files = new Map(); + + const walk = async (dir: string): Promise => { + const abs = join(root, dir); + try { + rules.add(dir, await readFile(join(abs, '.gitignore'), 'utf8')); + } catch { + // no .gitignore here + } + const entries = await readdir(abs, { withFileTypes: true }); + entries.sort((a, b) => (a.name < b.name ? -1 : 1)); + for (const e of entries) { + const rel = dir === '' ? e.name : `${dir}/${e.name}`; + if (e.isDirectory()) { + if (!rules.ignores(rel, true)) await walk(rel); + continue; + } + if (rules.ignores(rel)) continue; + const st = await lstat(join(root, rel)); + if (st.isSymbolicLink()) { + const linkname = await readlink(join(root, rel)); + files.set(rel, { path: rel, type: 'symlink', size: Buffer.byteLength(linkname), mtime: Math.floor(st.mtimeMs / 1000), mode: 0o777, linkname }); + } else if (st.isFile()) { + files.set(rel, { path: rel, type: 'file', size: st.size, mtime: Math.floor(st.mtimeMs / 1000), mode: st.mode & 0o777 }); + } + // sockets, fifos, devices: skipped + } + }; + await walk(''); + return { files, rules }; +} + +/** Parse `find -printf '%P\0%s\0%T@\0'` output. */ +export function parseRemoteListing(out: Buffer): Map { + const map = new Map(); + const parts = out.toString('utf8').split('\0'); + for (let i = 0; i + 2 < parts.length; i += 3) { + map.set(parts[i]!, { size: Number(parts[i + 1]), mtime: Math.floor(Number(parts[i + 2])) }); + } + return map; +} + +export interface SyncPlan { + upload: FileEntry[]; + remove: string[]; +} + +export function planSync( + local: Map, + remote: Map, + rules: IgnoreRules, +): SyncPlan { + const upload: FileEntry[] = []; + for (const f of local.values()) { + const r = remote.get(f.path); + if (!r || r.size !== f.size || r.mtime !== f.mtime) upload.push(f); + } + const remove = [...remote.keys()].filter((p) => !local.has(p) && !rules.ignores(p)).sort(); + return { upload, remove }; +} + +function ancestors(path: string): string[] { + const out: string[] = []; + for (let d = posix.dirname(path); d !== '.' && d !== '/'; d = posix.dirname(d)) out.push(d); + return out; +} + +/** A tar stream of `files` (plus their parent dirs), all owned by OWNER. */ +export function packTar(root: string, files: readonly FileEntry[]): Readable { + const pack = tarStream.pack(); + const owner = { uname: OWNER, gname: OWNER, uid: 0, gid: 0 }; + void (async () => { + try { + const dirs = new Set(); + for (const f of files) for (const d of ancestors(f.path)) dirs.add(d); + for (const d of [...dirs].sort()) { + pack.entry({ name: d, type: 'directory', mode: 0o755, ...owner }); + } + for (const f of files) { + const mtime = new Date(f.mtime * 1000); + if (f.type === 'symlink') { + pack.entry({ name: f.path, type: 'symlink', linkname: f.linkname, mode: 0o777, mtime, ...owner }); + continue; + } + const entry = pack.entry({ name: f.path, type: 'file', size: f.size, mode: f.mode, mtime, ...owner }); + await pipeline(createReadStream(join(root, f.path)), entry); + } + pack.finalize(); + } catch (err) { + pack.destroy(err as Error); + } + })(); + return Readable.from(pack); +} + +const q = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`; + +export const REMOTE = { + list: `sudo mkdir -p ${q(REMOTE_APP_DIR)} && cd ${q(REMOTE_APP_DIR)} && sudo find . -mindepth 1 \\( -type f -o -type l \\) -printf '%P\\0%s\\0%T@\\0'`, + extract: `sudo tar -x -f - -C ${q(REMOTE_APP_DIR)}`, + remove: `cd ${q(REMOTE_APP_DIR)} && sudo xargs -0 -r rm -f --`, + pruneDirs: `cd ${q(REMOTE_APP_DIR)} && sudo xargs -0 -r rmdir -p --ignore-fail-on-non-empty -- 2>/dev/null; true`, + restart: 'sudo systemctl restart app.service', +}; + +async function run(shell: RemoteShell, what: string, cmd: string, stdin?: Buffer | NodeJS.ReadableStream): Promise { + const res = await shell.exec(cmd, stdin); + if (res.code !== 0) { + throw new Error(`Remote ${what} failed (exit ${res.code})${res.stderr.trim() ? `: ${res.stderr.trim()}` : ''}`); + } + return res.stdout; +} + +function human(n: number): string { + return n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : `${(n / 1024 ** 2).toFixed(1)} MiB`; +} + +/** Sync `root` into the container and restart the app. */ +export async function syncWorktree(root: string, shell: RemoteShell, logger: DeployLogger): Promise { + const [{ files, rules }, listing] = await Promise.all([scanLocal(root), run(shell, 'listing', REMOTE.list)]); + const plan = planSync(files, parseRemoteListing(listing), rules); + const bytes = plan.upload.reduce((n, f) => n + (f.type === 'file' ? f.size : 0), 0); + logger.info( + `Syncing ${root} → ${REMOTE_APP_DIR}: ${files.size} files, ` + + `${plan.upload.length} to upload (${human(bytes)}), ${plan.remove.length} to delete`, + ); + + if (plan.upload.length > 0) await run(shell, 'extract', REMOTE.extract, packTar(root, plan.upload)); + if (plan.remove.length > 0) { + const nul = (xs: string[]): Buffer => Buffer.from(xs.map((x) => `${x}\0`).join('')); + await run(shell, 'delete', REMOTE.remove, nul(plan.remove)); + const dirs = [...new Set(plan.remove.map((p) => posix.dirname(p)).filter((d) => d !== '.'))]; + if (dirs.length > 0) await run(shell, 'cleanup', REMOTE.pruneDirs, nul(dirs)); + } + await run(shell, 'restart', REMOTE.restart); + logger.info('Code synced; app restarted'); + return plan; +} diff --git a/packages/os-cloud-provider/test/config.test.ts b/packages/os-cloud-provider/test/config.test.ts new file mode 100644 index 00000000..783be00e --- /dev/null +++ b/packages/os-cloud-provider/test/config.test.ts @@ -0,0 +1,111 @@ +import { strict as assert } from 'node:assert'; +import { describe, test } from 'node:test'; +import type { DeployContext } from '@mieweb/deploy-contract'; +import { + appName, + ConfigError, + DEFAULT_IMAGE, + instanceFromArgv, + normalizeInstanceUrl, + resolveInstanceUrl, + resolveTargetSettings, +} from '../src/config.ts'; + +function ctx(manifest: Record, targetConfig: Record = {}): DeployContext { + return { + root: '/tmp', + target: 'mieweb', + manifest, + mieweb: {}, + targetConfig, + argv: [], + logger: { info() {}, warn() {}, error() {} }, + signal: new AbortController().signal, + }; +} + +describe('normalizeInstanceUrl', () => { + test('canonicalizes host case, trailing slash and /api/v1', () => { + assert.equal(normalizeInstanceUrl('https://OS.mieweb.org/'), 'https://os.mieweb.org'); + assert.equal(normalizeInstanceUrl('http://localhost:3000/api/v1/'), 'http://localhost:3000'); + assert.equal(normalizeInstanceUrl('https://x.test/manager'), 'https://x.test/manager'); + }); + test('rejects non-http and credentials', () => { + assert.throws(() => normalizeInstanceUrl('ftp://x'), ConfigError); + assert.throws(() => normalizeInstanceUrl('https://u:p@x'), ConfigError); + assert.throws(() => normalizeInstanceUrl('not a url'), ConfigError); + }); +}); + +test('instance URL precedence: env → targetConfig → default', () => { + assert.equal(resolveInstanceUrl({ MIEWEB_OS_URL: 'http://a.test' }, { instanceUrl: 'http://b.test' }), 'http://a.test'); + assert.equal(resolveInstanceUrl({}, { instanceUrl: 'http://b.test' }), 'http://b.test'); + assert.equal(resolveInstanceUrl({}, {}), 'https://os.mieweb.org'); +}); + +test('instanceFromArgv', () => { + assert.equal(instanceFromArgv(['--instance', 'http://a.test']), 'http://a.test'); + assert.equal(instanceFromArgv(['-x', '--instance=http://b.test']), 'http://b.test'); + assert.equal(instanceFromArgv([]), undefined); +}); + +describe('appName', () => { + test('must be a DNS label', () => { + assert.equal(appName({ name: 'my-app1' }), 'my-app1'); + for (const bad of [undefined, '', 'My_App', '-x', 'x-', 'a'.repeat(64)]) { + assert.throws(() => appName({ name: bad }), ConfigError, String(bad)); + } + }); +}); + +describe('resolveTargetSettings', () => { + test('siteId is required and must be an integer', () => { + assert.throws(() => resolveTargetSettings(ctx({ name: 'app' }), {}), /siteId is required/); + assert.throws(() => resolveTargetSettings(ctx({ name: 'app' }, { siteId: 'one' }), {}), /positive integer/); + }); + + test('defaults', () => { + const s = resolveTargetSettings(ctx({ name: 'app' }, { siteId: 3 }), {}); + assert.equal(s.siteId, 3); + assert.equal(s.image, DEFAULT_IMAGE); + assert.equal(s.port, 8787); + assert.equal(s.externalHostname, 'app'); + assert.equal(s.authRequired, false); + assert.deepEqual(s.services, []); + assert.equal(s.instanceUrl, 'https://os.mieweb.org'); + }); + + test('overrides and extra services', () => { + const s = resolveTargetSettings( + ctx( + { name: 'app' }, + { + siteId: '4', + image: 'ghcr.io/x/y:pr-1', + port: 3000, + externalHostname: 'www-app', + domain: 'apps.example.test', + authRequired: true, + services: [{ type: 'tcp', internalPort: 22 }, { type: 'srv', internalPort: 5060, dnsName: '_sip._udp' }], + }, + ), + { MIEWEB_OS_URL: 'http://localhost:3000' }, + ); + assert.equal(s.siteId, 4); + assert.equal(s.image, 'ghcr.io/x/y:pr-1'); + assert.equal(s.port, 3000); + assert.equal(s.externalHostname, 'www-app'); + assert.equal(s.domain, 'apps.example.test'); + assert.equal(s.authRequired, true); + assert.equal(s.services.length, 2); + assert.equal(s.instanceUrl, 'http://localhost:3000'); + }); + + test('rejects bad service/port config', () => { + const base = { siteId: 1 }; + assert.throws(() => resolveTargetSettings(ctx({ name: 'a' }, { ...base, port: 70000 }), {}), /TCP port/); + assert.throws(() => resolveTargetSettings(ctx({ name: 'a' }, { ...base, services: [{ type: 'http', internalPort: 1 }] }), {}), /tcp, udp or srv/); + assert.throws(() => resolveTargetSettings(ctx({ name: 'a' }, { ...base, services: [{ type: 'srv', internalPort: 1 }] }), {}), /dnsName/); + assert.throws(() => resolveTargetSettings(ctx({ name: 'a' }, { ...base, authRequired: 'yes' }), {}), /boolean/); + }); +}); diff --git a/packages/os-cloud-provider/test/deploy-helpers.test.ts b/packages/os-cloud-provider/test/deploy-helpers.test.ts new file mode 100644 index 00000000..6cee7d21 --- /dev/null +++ b/packages/os-cloud-provider/test/deploy-helpers.test.ts @@ -0,0 +1,134 @@ +import { strict as assert } from 'node:assert'; +import { describe, test } from 'node:test'; +import type { Container } from '../src/api-types.ts'; +import { buildEnv, envUnchanged, MANAGED_ENV, normalizeImageRef, pickDomain, planServices, servicesUnchanged } from '../src/deploy.ts'; + +describe('normalizeImageRef (mirrors the Manager)', () => { + const cases: [string, string][] = [ + ['nginx', 'docker.io/library/nginx:latest'], + ['nginx:1.27', 'docker.io/library/nginx:1.27'], + ['bitnami/redis', 'docker.io/bitnami/redis:latest'], + ['ghcr.io/mieweb/opensource-server/cloud', 'ghcr.io/mieweb/opensource-server/cloud:latest'], + ['ghcr.io/mieweb/opensource-server/cloud:sha-abc', 'ghcr.io/mieweb/opensource-server/cloud:sha-abc'], + ['localhost:5000/app', 'localhost:5000/library/app:latest'], + ]; + for (const [input, want] of cases) { + test(input, () => assert.equal(normalizeImageRef(input), want)); + } + test('idempotent', () => { + for (const [, want] of cases) assert.equal(normalizeImageRef(want), want); + }); +}); + +describe('pickDomain', () => { + const form = { siteId: 1, nvidiaAvailable: false, externalDomains: [{ id: 3, name: 'a.test' }, { id: 4, name: 'b.test' }] }; + test('defaults to the first (site default) domain', () => assert.deepEqual(pickDomain(form, undefined), { id: 3, name: 'a.test' })); + test('by name or id', () => { + assert.equal(pickDomain(form, 'b.test').id, 4); + assert.equal(pickDomain(form, 3).name, 'a.test'); + }); + test('unknown / none', () => { + assert.throws(() => pickDomain(form, 'c.test'), /not available.*a\.test \(3\)/); + assert.throws(() => pickDomain({ ...form, externalDomains: [] }, undefined), /no external domains/); + }); +}); + +describe('buildEnv', () => { + const base = { + settings: { port: 8787, start: undefined }, + }; + + test('vars + secrets + managed keys, full set', () => { + const warnings: string[] = []; + const env = buildEnv({ + ...base, + manifest: { vars: { GREETING: 'hi', FLAGS: { a: 1 }, PORT: '1' } }, + env: { MIEWEB_OS_SECRET_API_KEY: 's3cret', MIEWEB_OS_SECRET_: 'ignored', OTHER: 'x' }, + warn: (m) => warnings.push(m), + }); + const map = Object.fromEntries(env.map((e) => [e.key, e.value])); + assert.equal(map.GREETING, 'hi'); + assert.equal(map.FLAGS, '{"a":1}'); + assert.equal(map.API_KEY, 's3cret'); + assert.equal(map.OTHER, undefined); + assert.equal(map[''], undefined); + assert.equal(map.PORT, '8787'); + assert.equal(map.MIEWEB_S3_SECRET_ACCESS_KEY, map.MINIO_ROOT_PASSWORD); + assert.ok(map.MINIO_ROOT_PASSWORD!.length >= 24); + assert.equal(map.MIEWEB_LIBSQL_URL, 'http://127.0.0.1:8080'); + assert.equal(map.MIEWEB_APP_START, undefined); + assert.deepEqual(warnings, ['Env var PORT is managed by the provider; ignoring the app\'s value']); + }); + + test('reuses the existing MinIO credentials', () => { + const env = buildEnv({ + ...base, + manifest: {}, + env: {}, + existing: { [MANAGED_ENV.minioPassword]: 'keep-me', [MANAGED_ENV.minioUser]: 'u' }, + warn: () => {}, + }); + const map = Object.fromEntries(env.map((e) => [e.key, e.value])); + assert.equal(map.MINIO_ROOT_PASSWORD, 'keep-me'); + assert.equal(map.MINIO_ROOT_USER, 'u'); + assert.equal(map.MIEWEB_S3_ACCESS_KEY_ID, 'u'); + }); +}); + +describe('planServices', () => { + const http = { internalPort: 8787, externalHostname: 'app', externalDomainId: 7, authRequired: false }; + const svc = (id: number, port: number, host = 'app', domainId = 7): NonNullable[number] => ({ + id, + type: 'http', + internalPort: port, + httpService: { externalHostname: host, externalDomainId: domainId, backendProtocol: 'http', authRequired: true }, + }); + + test('empty → create http', () => { + assert.deepEqual(planServices([], http, []), { http: { type: 'http', ...http } }); + }); + + test('matching http is kept (authRequired toggled)', () => { + assert.deepEqual(planServices([svc(1, 8787)], http, []), { + 'keep-1': { id: 1, type: 'http', internalPort: 8787, authRequired: false }, + }); + }); + + test('changed port / host / duplicates are replaced', () => { + const plan = planServices([svc(1, 3000), svc(2, 8787, 'other'), svc(3, 8787), svc(4, 8787)], http, []); + assert.deepEqual(Object.keys(plan).sort(), ['del-1', 'del-2', 'del-4', 'keep-3']); + assert.equal(plan['del-1']!.deleted, true); + }); + + test('non-http services are preserved; missing extras added', () => { + const current: Container['services'] = [ + svc(1, 8787), + { id: 2, type: 'transport', internalPort: 22, transportService: { protocol: 'tcp', externalPort: 2222 } }, + ]; + const plan = planServices(current, http, [ + { type: 'tcp', internalPort: 22 }, + { type: 'udp', internalPort: 53 }, + { type: 'srv', internalPort: 5060, dnsName: '_sip._udp' }, + ]); + assert.deepEqual(plan['extra-1'], { type: 'udp', internalPort: 53 }); + assert.deepEqual(plan['extra-2'], { type: 'srv', internalPort: 5060, dnsName: '_sip._udp' }); + assert.equal(plan['extra-0'], undefined); + assert.equal(Object.values(plan).some((p) => p.id === 2), false); + }); +}); + +describe('change detection', () => { + test('servicesUnchanged', () => { + const cur: Container['services'] = [ + { id: 1, type: 'http', internalPort: 8787, httpService: { externalHostname: 'a', externalDomainId: 7, backendProtocol: 'http', authRequired: false } }, + ]; + assert.equal(servicesUnchanged(cur, { 'keep-1': { id: 1, type: 'http', internalPort: 8787, authRequired: false } }), true); + assert.equal(servicesUnchanged(cur, { 'keep-1': { id: 1, type: 'http', internalPort: 8787, authRequired: true } }), false); + assert.equal(servicesUnchanged(cur, { http: { type: 'http', internalPort: 1 } }), false); + }); + test('envUnchanged', () => { + assert.equal(envUnchanged({ A: '1', B: '2' }, [{ key: 'B', value: '2' }, { key: 'A', value: '1' }]), true); + assert.equal(envUnchanged({ A: '1', B: '2' }, [{ key: 'A', value: '1' }]), false); + assert.equal(envUnchanged({ A: '1' }, [{ key: 'A', value: '2' }]), false); + }); +}); diff --git a/packages/os-cloud-provider/test/fake-manager.ts b/packages/os-cloud-provider/test/fake-manager.ts new file mode 100644 index 00000000..66bb8e0b --- /dev/null +++ b/packages/os-cloud-provider/test/fake-manager.ts @@ -0,0 +1,299 @@ +/** + * In-memory fake of the Manager API surface the provider uses, served over + * real HTTP so the provider's fetch/abort/error handling is exercised as-is. + * Shapes follow create-a-container/openapi.v1.yaml. + */ + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +export interface FakeService { + id: number; + type: 'http' | 'transport' | 'dns'; + internalPort: number; + httpService?: { externalHostname: string; externalDomainId: number; backendProtocol: 'http' | 'https'; authRequired: boolean }; + transportService?: { protocol: 'tcp' | 'udp'; externalPort: number }; + dnsService?: { recordType: 'SRV'; dnsName: string }; +} + +export interface FakeContainer { + id: number; + hostname: string; + owner: string; + template: string; + containerId: string | null; + nvidiaRequested: boolean; + entrypoint: string | null; + environmentVars: Record; + services: FakeService[]; + volumes: { id: number; name: string; mountPath: string; mode: 'ro' | 'rw' }[]; + status?: string; + creationJobId?: number | null; +} + +export interface FakeJob { + id: number; + status: 'pending' | 'running' | 'success' | 'failure' | 'cancelled'; + polls: number; + logs: string[]; + onSuccess?: () => void; +} + +export interface RequestLog { + method: string; + path: string; + body?: any; +} + +export class FakeManager { + readonly tokens = new Map(); + readonly containers: FakeContainer[] = []; + readonly jobs = new Map(); + readonly requests: RequestLog[] = []; + readonly domains = [{ id: 7, name: 'apps.example.test', siteId: 1 }]; + readonly siteId = 1; + nvidiaAvailable = false; + /** Emulate a Manager that predates volumes (#421). */ + noVolumes = false; + /** Drop the connection for this many upcoming GET /jobs/:id requests. */ + dropJobPolls = 0; + /** Status new jobs end in. */ + jobOutcome: FakeJob['status'] = 'success'; + /** Polls before a job leaves `running`. */ + jobPolls = 1; + /** Called before POST /containers is processed (to simulate races). */ + beforeCreate?: (hostname: string) => void; + /** Resolve GET /jobs/:id only after this promise (to test aborts). */ + jobGate?: Promise; + /** Handoff params the fake CLI-auth route will embed. */ + nextKey = { key: 'minted-key', id: 'key-2', user: 'alice' }; + + private nextId = 100; + private nextVmid = 1000; + private nextPort = 2000; + private server = createServer((req, res) => { + this.handle(req, res).catch((err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'internal_error', message: String(err) } })); + }); + }); + url = ''; + + async start(): Promise { + await new Promise((r) => this.server.listen(0, '127.0.0.1', () => r())); + const addr = this.server.address() as { port: number }; + this.url = `http://127.0.0.1:${addr.port}`; + return this; + } + + async stop(): Promise { + this.server.closeAllConnections(); + await new Promise((r) => this.server.close(() => r())); + } + + addToken(token: string, user = 'alice', keyId = 'key-1'): void { + this.tokens.set(token, { user, keyId }); + } + + seedContainer(c: Partial & { hostname: string }): FakeContainer { + const full: FakeContainer = { + id: this.nextId++, + owner: 'alice', + template: 'ghcr.io/mieweb/opensource-server/cloud:latest', + containerId: String(this.nextVmid++), + nvidiaRequested: false, + entrypoint: null, + environmentVars: {}, + services: [], + volumes: [], + ...c, + }; + this.containers.push(full); + return full; + } + + private newJob(): FakeJob { + const job: FakeJob = { id: this.nextId++, status: 'pending', polls: 0, logs: ['starting', 'done'] }; + this.jobs.set(job.id, job); + return job; + } + + private serialize(c: FakeContainer) { + const { volumes, ...rest } = c; + return { + ...rest, + ...(this.noVolumes ? {} : { volumes }), + status: c.status ?? (c.containerId ? 'running' : 'creating'), + sshPort: c.services.find((s) => s.type === 'transport' && s.internalPort === 22)?.transportService?.externalPort ?? null, + sshHost: 'ssh.example.test', + httpEntries: c.services + .filter((s) => s.type === 'http') + .map((s) => { + const d = this.domains.find((x) => x.id === s.httpService!.externalDomainId); + return { port: s.internalPort, externalUrl: d ? `https://${s.httpService!.externalHostname}.${d.name}` : null }; + }), + services: c.services.map((s) => ({ ...s, lastAccessedAt: null })), + }; + } + + private addServices(c: FakeContainer, services: Record): void { + for (const s of Object.values(services ?? {})) { + if (s.id || s.deleted) continue; + const id = this.nextId++; + if (s.type === 'http' || s.type === 'https') { + c.services.push({ + id, + type: 'http', + internalPort: s.internalPort, + httpService: { + externalHostname: s.externalHostname, + externalDomainId: s.externalDomainId, + backendProtocol: s.type === 'https' ? 'https' : 'http', + authRequired: !!s.authRequired, + }, + }); + } else if (s.type === 'srv') { + c.services.push({ id, type: 'dns', internalPort: s.internalPort, dnsService: { recordType: 'SRV', dnsName: s.dnsName } }); + } else { + c.services.push({ id, type: 'transport', internalPort: s.internalPort, transportService: { protocol: s.type, externalPort: this.nextPort++ } }); + } + } + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url!, 'http://x'); + const path = url.pathname.replace(/^\/api\/v1/, ''); + let raw = ''; + for await (const chunk of req) raw += chunk; + const ctype = req.headers['content-type'] ?? ''; + const body = raw ? (ctype.includes('json') ? JSON.parse(raw) : raw) : undefined; + this.requests.push({ method: req.method!, path: `${path}${url.search}`, body }); + + const send = (status: number, payload: unknown): void => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + const ok = (data: unknown, status = 200): void => send(status, { data }); + const fail = (status: number, code: string, message = code): void => send(status, { error: { code, message } }); + + if (req.method === 'GET' && path === '/health') return ok({ status: 'ok', oidcEnabled: false }); + + // Browser-facing CLI handoff (the real route requires a session + confirm; + // the fake just "approves" immediately). + if (path === '/auth/cli/callback' && req.method === 'GET') { + const port = url.searchParams.get('port'); + const state = url.searchParams.get('state')!; + const frag = new URLSearchParams({ ...this.nextKey, state }); + this.tokens.set(this.nextKey.key, { user: this.nextKey.user, keyId: this.nextKey.id }); + res.writeHead(303, { Location: `http://127.0.0.1:${port}/callback#${frag}` }); + res.end(); + return; + } + + const auth = req.headers.authorization ?? ''; + const who = auth.startsWith('Bearer ') ? this.tokens.get(auth.slice(7)) : undefined; + if (!who) return fail(401, 'unauthorized', 'Authentication required'); + + if (req.method === 'GET' && path === '/session') return ok({ user: who.user, isAdmin: false }); + + let m = /^\/apikeys\/([^/]+)$/.exec(path); + if (m && req.method === 'DELETE') { + const id = decodeURIComponent(m[1]!); + for (const [tok, v] of this.tokens) if (v.keyId === id) this.tokens.delete(tok); + res.writeHead(204); + res.end(); + return; + } + + m = /^\/jobs\/(\d+)(\/status)?$/.exec(path); + if (m && req.method === 'GET' && !m[2] && this.dropJobPolls > 0) { + this.dropJobPolls -= 1; + req.socket.destroy(); + return; + } + if (m && req.method === 'GET') { + const job = this.jobs.get(Number(m[1])); + if (!job) return fail(404, 'not_found'); + if (m[2]) { + const offset = Number(url.searchParams.get('offset') ?? 0); + const rows = job.status === 'pending' ? [] : job.logs.map((output, i) => ({ id: i + 1, output })); + return ok(rows.slice(offset)); + } + if (this.jobGate) await this.jobGate; + job.polls += 1; + if (job.status === 'pending') job.status = 'running'; + else if (job.status === 'running' && job.polls > this.jobPolls) { + job.status = this.jobOutcome; + if (job.status === 'success') job.onSuccess?.(); + } + return ok({ id: job.id, status: job.status }); + } + + m = /^\/sites\/(\d+)\/containers(?:\/(new|\d+))?$/.exec(path); + if (!m) return fail(404, 'not_found'); + if (Number(m[1]) !== this.siteId) return fail(404, 'site_not_found'); + const sub = m[2]; + + if (sub === 'new' && req.method === 'GET') { + return ok({ siteId: this.siteId, externalDomains: this.domains, nvidiaAvailable: this.nvidiaAvailable }); + } + if (!sub && req.method === 'GET') { + const hostname = url.searchParams.get('hostname'); + return ok( + this.containers + .filter((c) => c.owner === who.user && (!hostname || c.hostname === hostname)) + .map((c) => this.serialize(c)), + ); + } + if (!sub && req.method === 'POST') { + this.beforeCreate?.(body.hostname); + if (this.containers.some((c) => c.hostname === body.hostname)) return fail(409, 'conflict', 'hostname taken'); + const c: FakeContainer = { + id: this.nextId++, + hostname: body.hostname, + owner: who.user, + template: body.template, + containerId: null, + nvidiaRequested: !!body.nvidiaRequested, + entrypoint: body.entrypoint ?? null, + environmentVars: Object.fromEntries((body.environmentVars ?? []).map((e: any) => [e.key, e.value])), + services: [], + volumes: this.noVolumes ? [] : (body.volumes ?? []).map((v: any) => ({ id: this.nextId++, ...v })), + }; + this.addServices(c, body.services); + this.containers.push(c); + const job = this.newJob(); + // Provisioning "completes" (a VMID appears) when the job succeeds. + const vmid = String(this.nextVmid++); + job.onSuccess = () => { + c.containerId = vmid; + }; + return ok({ containerId: c.id, jobId: job.id, hostname: c.hostname, status: 'creating' }, 201); + } + + const c = this.containers.find((x) => x.id === Number(sub)); + if (!c || c.owner !== who.user) return fail(404, 'not_found'); + if (req.method === 'GET') return ok(this.serialize(c)); + if (req.method === 'DELETE') { + this.containers.splice(this.containers.indexOf(c), 1); + return ok({ deleted: true, dnsWarnings: [] }); + } + if (req.method === 'PUT') { + for (const s of Object.values(body.services ?? {})) { + if (s.id && s.deleted) c.services = c.services.filter((x) => x.id !== s.id); + } + for (const s of Object.values(body.services ?? {})) { + if (s.id && !s.deleted) { + const hit = c.services.find((x) => x.id === s.id); + if (hit?.httpService) hit.httpService.authRequired = !!s.authRequired; + } + } + this.addServices(c, body.services); + c.environmentVars = Object.fromEntries((body.environmentVars ?? []).map((e: any) => [e.key, e.value])); + c.entrypoint = body.entrypoint ?? null; + for (const v of body.volumes ?? []) c.volumes.push({ id: this.nextId++, ...v }); + const job = body.restart ? this.newJob() : null; + return ok({ containerId: c.id, jobId: job?.id ?? null, dnsWarnings: [], pendingRestart: !body.restart }); + } + return fail(405, 'method_not_allowed'); + } +} diff --git a/packages/os-cloud-provider/test/fake-shell.ts b/packages/os-cloud-provider/test/fake-shell.ts new file mode 100644 index 00000000..f906dbea --- /dev/null +++ b/packages/os-cloud-provider/test/fake-shell.ts @@ -0,0 +1,105 @@ +/** + * RemoteShell fake that executes the sync's remote commands against a local + * directory, so sync tests exercise the real tar/diff/delete logic. + */ + +import { lstat, mkdir, readdir, rm, rmdir, symlink, utimes, lutimes, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { Readable } from 'node:stream'; +import tarStream from 'tar-stream'; +import type { ExecResult, RemoteShell } from '../src/ssh.ts'; +import { REMOTE } from '../src/sync.ts'; + +async function toBuffer(stdin: Buffer | NodeJS.ReadableStream | undefined): Promise { + if (!stdin) return Buffer.alloc(0); + if (Buffer.isBuffer(stdin)) return stdin; + const chunks: Buffer[] = []; + for await (const c of stdin as AsyncIterable) chunks.push(Buffer.from(c)); + return Buffer.concat(chunks); +} + +export class FakeShell implements RemoteShell { + readonly commands: string[] = []; + readonly owners = new Map(); + closed = false; + readonly dir: string; + constructor(dir: string) { + this.dir = dir; + } + + async exec(command: string, stdin?: Buffer | NodeJS.ReadableStream): Promise { + this.commands.push(command); + const input = await toBuffer(stdin); + const ok = (stdout = Buffer.alloc(0)): ExecResult => ({ code: 0, stdout, stderr: '' }); + + if (command === REMOTE.list) { + await mkdir(this.dir, { recursive: true }); + const out: string[] = []; + const walk = async (rel: string): Promise => { + for (const e of await readdir(join(this.dir, rel), { withFileTypes: true })) { + const p = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) await walk(p); + else { + const st = await lstat(join(this.dir, p)); + out.push(p, String(st.size), String(st.mtimeMs / 1000)); + } + } + }; + await walk(''); + return ok(Buffer.from(out.map((x) => `${x}\0`).join(''))); + } + if (command === REMOTE.extract) { + const extract = tarStream.extract(); + const done = (async () => { + for await (const entry of extract) { + const h = entry.header; + const target = join(this.dir, h.name); + this.owners.set(h.name, `${h.uname}:${h.gname}`); + if (h.type === 'directory') { + await mkdir(target, { recursive: true }); + entry.resume(); + continue; + } + await mkdir(dirname(target), { recursive: true }); + await rm(target, { force: true }); + if (h.type === 'symlink') { + await symlink(h.linkname!, target); + entry.resume(); + await lutimes(target, h.mtime!, h.mtime!); + continue; + } + const chunks: Buffer[] = []; + for await (const c of entry) chunks.push(c as Buffer); + await writeFile(target, Buffer.concat(chunks), { mode: h.mode }); + await utimes(target, h.mtime!, h.mtime!); + } + })(); + Readable.from(input).pipe(extract as unknown as NodeJS.WritableStream); + await done; + return ok(); + } + if (command === REMOTE.remove) { + for (const p of input.toString().split('\0').filter(Boolean)) await rm(join(this.dir, p), { force: true }); + return ok(); + } + if (command === REMOTE.pruneDirs) { + for (let d of input.toString().split('\0').filter(Boolean)) { + while (d && d !== '.') { + try { + await rmdir(join(this.dir, d)); + } catch { + break; + } + d = dirname(d); + } + } + return ok(); + } + if (command === REMOTE.restart) return ok(); + return { code: 127, stdout: Buffer.alloc(0), stderr: `unknown command: ${command}` }; + } + + close(): void { + this.closed = true; + } +} diff --git a/packages/os-cloud-provider/test/live.test.ts b/packages/os-cloud-provider/test/live.test.ts new file mode 100644 index 00000000..f889f53a --- /dev/null +++ b/packages/os-cloud-provider/test/live.test.ts @@ -0,0 +1,88 @@ +/** + * Live inner-loop test against a real Manager (`make dev`: SQLite + the + * DummyApi mock hypervisor at http://localhost:3000). Skipped unless + * MIEWEB_OS_LIVE=1. + * + * MIEWEB_OS_LIVE=1 MIEWEB_OS_URL=http://localhost:3000 \ + * MIEWEB_OS_TOKEN= MIEWEB_OS_SITE_ID=1 \ + * pnpm test:live + * + * MIEWEB_OS_LIVE_IMAGE / MIEWEB_OS_LIVE_IMAGE2 pick the images for the create + * and image-change steps (default: the public `nodejs` and `base` images, + * since `cloud:latest` only exists after the first release). + * + * The code sync is disabled here (DummyApi containers have no SSH). + * Exercises the real API mapping + job polling end to end (create → update → + * image-change recreate → destroy) and runs the contract's live conformance + * suite with an `applyIds` hook. It creates and deletes a container named + * `os-provider-live-` on that site. + */ + +import { strict as assert } from 'node:assert'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { describe, test } from 'node:test'; +import type { DeployContext, ResourceHandle } from '@mieweb/deploy-contract'; +import { runProviderConformance } from '@mieweb/deploy-contract/testkit'; +import { createProvider } from '../src/index.ts'; + +const live = process.env.MIEWEB_OS_LIVE === '1'; +const siteId = Number(process.env.MIEWEB_OS_SITE_ID ?? '1'); +const name = `os-provider-live-${randomBytes(3).toString('hex')}`; +const image = process.env.MIEWEB_OS_LIVE_IMAGE ?? 'ghcr.io/mieweb/opensource-server/nodejs:latest'; +const image2 = process.env.MIEWEB_OS_LIVE_IMAGE2 ?? 'ghcr.io/mieweb/opensource-server/base:latest'; +// The DummyApi hypervisor has no real SSH endpoint, so skip the code sync. +const targetConfig = { siteId, image, sync: false }; + +function ctx(overrides: Partial = {}): DeployContext { + return { + root: tmpdir(), + target: 'mieweb', + manifest: { name, vars: { HELLO: 'world' } }, + mieweb: {}, + targetConfig, + argv: [], + logger: { info: (m) => console.log(m), warn: (m) => console.warn(m), error: (m) => console.error(m) }, + signal: AbortSignal.timeout(10 * 60 * 1000), + ...overrides, + }; +} + +describe('live Manager', { skip: !live && 'set MIEWEB_OS_LIVE=1 (see file header)' }, () => { + const provider = createProvider(process.env, { pollIntervalMs: 500 }); + + test('whoami', async () => { + const who = await provider.whoami!(ctx()); + assert.equal(who.authenticated, true); + }); + + test('deploy → redeploy (same handle) → image change → destroy', async () => { + const first = await provider.deploy(ctx()); + assert.equal(first.resources.length, 1); + assert.equal(first.resources[0]!.kind, 'container'); + assert.match(first.resources[0]!.id, /\S/); + + const second = await provider.deploy(ctx({ manifest: { name, vars: { HELLO: 'again' } } })); + assert.deepEqual(second.resources, first.resources); + + const third = await provider.deploy( + ctx({ targetConfig: { ...targetConfig, image: image2 } }), + ); + assert.equal(third.resources[0]!.binding, name); + + await provider.destroy!(ctx()); + }); + + test('conformance (live, with applyIds)', async () => { + const report = await runProviderConformance(provider, { + target: 'mieweb', + manifest: { name }, + targetConfig, + root: tmpdir(), + live: true, + applyIds: (m: Readonly>, _r: readonly ResourceHandle[]) => ({ ...m }), + }); + await provider.destroy!(ctx()); + assert.deepEqual(report.failures, []); + }); +}); diff --git a/packages/os-cloud-provider/test/provider.test.ts b/packages/os-cloud-provider/test/provider.test.ts new file mode 100644 index 00000000..4c26abae --- /dev/null +++ b/packages/os-cloud-provider/test/provider.test.ts @@ -0,0 +1,468 @@ +import { strict as assert } from 'node:assert'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, afterEach, before, beforeEach, describe, test } from 'node:test'; +import { AuthError } from '@mieweb/deploy-contract'; +import type { DeployContext, ProviderEnv, ResourceHandle } from '@mieweb/deploy-contract'; +import { runProviderConformance } from '@mieweb/deploy-contract/testkit'; +import { createProvider, type ProviderOptions } from '../src/index.ts'; +import type { SshTarget } from '../src/ssh.ts'; +import { FakeManager } from './fake-manager.ts'; +import { FakeShell } from './fake-shell.ts'; + +const TOKEN = 'test-token'; + +let fake: FakeManager; +let dir: string; +/** The app worktree deployed by the tests, and the fake container's /opt/app/src. */ +let appRoot: string; +let remoteDir: string; +/** SSH sessions the provider opened, and SSH readiness waits. */ +let sessions: { target: SshTarget; shell: FakeShell }[]; +let sshWaits: string[]; +let connectError: Error | null; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), 'os-cloud-provider-')); +}); +after(async () => { + await rm(dir, { recursive: true, force: true }); +}); +beforeEach(async () => { + fake = await new FakeManager().start(); + fake.addToken(TOKEN); + appRoot = await mkdtemp(join(dir, 'app-')); + remoteDir = await mkdtemp(join(dir, 'remote-')); + await writeFile(join(appRoot, 'package.json'), '{"name":"myapp"}'); + await writeFile(join(appRoot, '.gitignore'), 'node_modules/\n'); + sessions = []; + sshWaits = []; + connectError = null; +}); +afterEach(async () => { + await fake.stop(); +}); + +interface Harness { + ctx: DeployContext; + logs: string[]; + abort: AbortController; +} + +function harness(opts: { manifest?: Record; targetConfig?: Record; argv?: string[] } = {}): Harness { + const logs: string[] = []; + const abort = new AbortController(); + return { + logs, + abort, + ctx: { + root: appRoot, + target: 'mieweb', + manifest: opts.manifest ?? { name: 'myapp', vars: { GREETING: 'hi' } }, + mieweb: {}, + targetConfig: opts.targetConfig ?? { siteId: 1 }, + argv: opts.argv ?? [], + logger: { + info: (m) => logs.push(`info:${m}`), + warn: (m) => logs.push(`warn:${m}`), + error: (m) => logs.push(`error:${m}`), + }, + signal: abort.signal, + }, + }; +} + +function provider(env: ProviderEnv = {}, options: ProviderOptions = {}) { + return createProvider( + { HOME: dir, MIEWEB_OS_URL: fake.url, MIEWEB_OS_TOKEN: TOKEN, MIEWEB_OS_CREDENTIALS: join(dir, `creds-${Math.random()}.json`), ...env }, + { + pollIntervalMs: 5, + connectSsh: async (target) => { + if (connectError) throw connectError; + const shell = new FakeShell(remoteDir); + sessions.push({ target, shell }); + return shell; + }, + waitForSsh: async (host, port) => { + sshWaits.push(`${host}:${port}`); + }, + ...options, + }, + ); +} + +describe('contract', () => { + test('passes structural conformance', async () => { + const report = await runProviderConformance(provider(), { target: 'mieweb', manifest: { name: 'myapp' } }); + assert.deepEqual(report.failures, []); + }); + + test('passes live conformance (handle stability) against the fake Manager', async () => { + const report = await runProviderConformance(provider(), { + target: 'mieweb', + manifest: { name: 'myapp' }, + targetConfig: { siteId: 1 }, + root: dir, + live: true, + // Identity is the hostname, which is already in the manifest. + applyIds: (m: Readonly>, _r: readonly ResourceHandle[]) => ({ ...m }), + }); + assert.deepEqual(report.failures, []); + assert.equal(fake.containers.length, 1); + }); + + test('supports only the mieweb target; dev and tail are omitted', () => { + const p = provider(); + assert.equal(p.name, 'opensource-server'); + assert.equal(p.supports('mieweb'), true); + assert.equal(p.supports('cloudflare'), false); + assert.equal(p.dev, undefined); + assert.equal(p.tail, undefined); + }); +}); + +describe('deploy', () => { + test('creates the container with http + ssh services, env, and the data volume', async () => { + await mkdir(join(dir, '.mieweb'), { recursive: true }); + await writeFile(join(dir, '.mieweb', 'known_hosts'), '[ssh.example.test]:2000 SHA256:stale\n[other]:22 SHA256:keep\n'); + const h = harness(); + const result = await provider({ MIEWEB_OS_SECRET_API_KEY: 'k' }).deploy(h.ctx); + + assert.equal(fake.containers.length, 1); + const c = fake.containers[0]!; + assert.equal(c.hostname, 'myapp'); + assert.equal(c.template, 'ghcr.io/mieweb/opensource-server/cloud:latest'); + assert.deepEqual(c.volumes.map(({ name, mountPath, mode }) => ({ name, mountPath, mode })), [ + { name: 'data', mountPath: '/mnt/data', mode: 'rw' }, + ]); + assert.equal(c.services.length, 2); + assert.deepEqual( + c.services.filter((x) => x.type === 'transport').map((x) => [x.internalPort, x.transportService!.protocol]), + [[22, 'tcp']], + ); + assert.deepEqual(c.services[0]!.httpService, { + externalHostname: 'myapp', + externalDomainId: 7, + backendProtocol: 'http', + authRequired: false, + }); + assert.equal(c.services[0]!.internalPort, 8787); + assert.equal(c.environmentVars.GREETING, 'hi'); + assert.equal(c.environmentVars.API_KEY, 'k'); + assert.equal(c.environmentVars.MIEWEB_APP_SOURCE, undefined); + + // id is the hypervisor VMID read back after the job, not the DB id. + assert.deepEqual(result, { + url: 'https://myapp.apps.example.test', + resources: [{ binding: 'myapp', kind: 'container', id: c.containerId }], + }); + assert.notEqual(c.containerId, String(c.id)); + assert.ok(h.logs.some((l) => l.includes('[job ')), 'job output is forwarded to the logger'); + assert.ok(!fake.requests.some((r) => r.path.startsWith('/jobs/') && r.path.endsWith('/stream'))); + + // Fresh container: forget the stale host key, wait for SSH, sync, restart. + const sshPort = c.services.find((x) => x.internalPort === 22)!.transportService!.externalPort; + assert.equal(sshPort, 2000); + assert.equal(await readFile(join(dir, '.mieweb', 'known_hosts'), 'utf8'), '[other]:22 SHA256:keep\n'); + assert.deepEqual(sshWaits, [`ssh.example.test:${sshPort}`]); + assert.equal(sessions.length, 1); + assert.deepEqual(sessions[0]!.target, { host: 'ssh.example.test', port: sshPort, user: 'alice' }, 'ssh user = Manager account'); + assert.equal(await readFile(join(remoteDir, 'package.json'), 'utf8'), '{"name":"myapp"}'); + assert.match(sessions[0]!.shell.commands.at(-1)!, /systemctl restart app\.service/); + assert.equal(sessions[0]!.shell.closed, true); + }); + + test('redeploy with no config change only syncs code (no Manager writes)', async () => { + const p = provider(); + const first = await p.deploy(harness().ctx); + const writes = fake.requests.filter((r) => r.method !== 'GET').length; + await writeFile(join(appRoot, 'new.js'), 'x'); + + const h = harness(); + const second = await p.deploy(h.ctx); + assert.deepEqual(second, first); + assert.equal(fake.requests.filter((r) => r.method !== 'GET').length, writes); + assert.equal(sessions.length, 2); + assert.equal(await readFile(join(remoteDir, 'new.js'), 'utf8'), 'x'); + assert.ok(h.logs.some((l) => l.includes('configuration is up to date'))); + }); + + test('sync user / host overrides and sync: false', async () => { + const p = provider({ MIEWEB_OS_SSH_USER: 'root' }); + await p.deploy(harness({ targetConfig: { siteId: 1, sshHost: '10.0.0.5' } }).ctx); + assert.deepEqual(sessions[0]!.target, { host: '10.0.0.5', port: 2000, user: 'root' }); + + const h = harness({ targetConfig: { siteId: 1, sync: false } }); + await p.deploy(h.ctx); + assert.equal(sessions.length, 1); + assert.ok(h.logs.some((l) => l.includes('sync disabled'))); + }); + + test('an SSH failure fails the deploy', async () => { + connectError = new Error('SSH authentication as alice@ssh.example.test:2000 failed.'); + await assert.rejects(provider().deploy(harness().ctx), /SSH authentication as alice/); + }); + + test('old source/ref settings are rejected with an explanation', async () => { + await assert.rejects( + provider().deploy(harness({ targetConfig: { siteId: 1, source: 'https://x/y' } }).ctx), + /syncs your local worktree/, + ); + }); + + test('redeploy with the same image updates in place and keeps the MinIO secret', async () => { + const p = provider(); + const first = await p.deploy(harness().ctx); + const pw = fake.containers[0]!.environmentVars.MINIO_ROOT_PASSWORD; + + const h = harness({ + manifest: { name: 'myapp', vars: { GREETING: 'hello' } }, + targetConfig: { siteId: 1, authRequired: true }, + }); + const second = await p.deploy(h.ctx); + + assert.deepEqual(second, first); + assert.equal(fake.containers.length, 1); + const c = fake.containers[0]!; + assert.equal(c.environmentVars.MINIO_ROOT_PASSWORD, pw); + assert.equal(c.environmentVars.GREETING, 'hello'); + assert.equal(c.services.length, 2); + assert.equal(c.services.find((x) => x.type === 'http')!.httpService!.authRequired, true); + const put = fake.requests.find((r) => r.method === 'PUT')!; + assert.equal(put.body.restart, true); + assert.equal(put.body.volumes, undefined, 'volume already attached'); + assert.ok(!fake.requests.some((r) => r.method === 'DELETE')); + }); + + test('changing the port replaces the http service', async () => { + const p = provider(); + await p.deploy(harness().ctx); + const oldId = fake.containers[0]!.services[0]!.id; + await p.deploy(harness({ targetConfig: { siteId: 1, port: 3000 } }).ctx); + const svcs = fake.containers[0]!.services.filter((x) => x.type === 'http'); + assert.equal(svcs.length, 1); + assert.notEqual(svcs[0]!.id, oldId); + assert.equal(svcs[0]!.internalPort, 3000); + }); + + test('an existing container without the data volume gets it attached', async () => { + fake.seedContainer({ hostname: 'myapp' }); + await provider().deploy(harness().ctx); + const put = fake.requests.find((r) => r.method === 'PUT')!; + assert.deepEqual(put.body.volumes, [{ name: 'data', mountPath: '/mnt/data', mode: 'rw' }]); + }); + + test('image change → delete + recreate, carrying the MinIO secret', async () => { + fake.seedContainer({ + hostname: 'myapp', + template: 'ghcr.io/mieweb/opensource-server/cloud:old', + environmentVars: { MINIO_ROOT_USER: 'mieweb', MINIO_ROOT_PASSWORD: 'persisted' }, + volumes: [{ id: 1, name: 'data', mountPath: '/mnt/data', mode: 'rw' }], + }); + const h = harness({ targetConfig: { siteId: 1, image: 'ghcr.io/mieweb/opensource-server/cloud:sha-new' } }); + await provider().deploy(h.ctx); + const methods = fake.requests.filter((r) => r.method !== 'GET').map((r) => r.method); + assert.deepEqual(methods, ['DELETE', 'POST']); + const c = fake.containers[0]!; + assert.equal(c.template, 'ghcr.io/mieweb/opensource-server/cloud:sha-new'); + assert.equal(c.environmentVars.MINIO_ROOT_PASSWORD, 'persisted'); + assert.ok(h.logs.some((l) => l.includes('Recreating'))); + }); + + test('a container whose create failed is recreated, not updated', async () => { + fake.seedContainer({ hostname: 'myapp', containerId: null, status: 'failed' }); + const h = harness(); + const result = await provider().deploy(h.ctx); + const methods = fake.requests.filter((r) => r.method !== 'GET').map((r) => r.method); + assert.deepEqual(methods, ['DELETE', 'POST']); + assert.equal(result.resources[0]!.id, fake.containers[0]!.containerId); + assert.ok(h.logs.some((l) => l.includes('not provisioned (status failed)'))); + }); + + test('AI binding requests a GPU only when the site has one; nvidia drift recreates', async () => { + fake.nvidiaAvailable = true; + fake.seedContainer({ hostname: 'myapp' }); + await provider().deploy(harness({ manifest: { name: 'myapp', ai: { binding: 'AI' } } }).ctx); + assert.equal(fake.containers[0]!.nvidiaRequested, true); + assert.ok(fake.requests.some((r) => r.method === 'DELETE')); + }); + + test('a lost create race (409 conflict) is converged with an update', async () => { + fake.beforeCreate = (hostname) => { + fake.beforeCreate = undefined; + fake.seedContainer({ hostname }); + }; + await provider().deploy(harness().ctx); + assert.equal(fake.containers.length, 1); + assert.ok(fake.requests.some((r) => r.method === 'PUT')); + }); + + test('a hostname owned by someone else is a clear error', async () => { + fake.seedContainer({ hostname: 'myapp', owner: 'bob' }); + await assert.rejects(provider().deploy(harness().ctx), /already taken on site 1/); + }); + + test('dropped connections while polling are retried', async () => { + fake.dropJobPolls = 2; + const result = await provider().deploy(harness().ctx); + assert.equal(result.resources.length, 1); + assert.equal(fake.dropJobPolls, 0); + }); + + test('a Manager without volume support: warn, and redeploys stay sync-only', async () => { + fake.noVolumes = true; + const p = provider(); + await p.deploy(harness().ctx); + const writes = fake.requests.filter((r) => r.method !== 'GET').length; + const h = harness(); + await p.deploy(h.ctx); + assert.equal(fake.requests.filter((r) => r.method !== 'GET').length, writes, 'no PUT/restart'); + assert.ok(h.logs.some((l) => l.startsWith('warn:This Manager does not support volumes'))); + assert.equal(sessions.length, 2); + }); + + test('a failed job fails the deploy with its output', async () => { + fake.jobOutcome = 'failure'; + await assert.rejects(provider().deploy(harness().ctx), /job \d+ ended with status "failure":\nstarting\ndone/); + }); + + test('abort cancels job polling', async () => { + let release!: () => void; + fake.jobGate = new Promise((r) => (release = r)); + const h = harness(); + const run = provider().deploy(h.ctx); + setTimeout(() => h.abort.abort(new Error('user cancelled')), 50); + await assert.rejects(run, /user cancelled/); + release(); + }); + + test('config errors are reported before any request', async () => { + await assert.rejects(provider().deploy(harness({ targetConfig: {} }).ctx), /siteId is required/); + await assert.rejects(provider().deploy(harness({ manifest: { name: 'Bad_Name' } }).ctx), /DNS label/); + await assert.rejects(provider().deploy(harness({ targetConfig: { siteId: 1, domain: 'nope.test' } }).ctx), /not available/); + assert.ok(fake.requests.every((r) => r.method === 'GET')); + }); + + test('401 → AuthError with a login hint; no token → AuthError without a request', async () => { + const bad = provider({ MIEWEB_OS_TOKEN: 'wrong' }); + await assert.rejects(bad.deploy(harness().ctx), (err: unknown) => { + assert.ok(err instanceof AuthError); + assert.match((err as AuthError).hint ?? '', /MIEWEB_OS_TOKEN/); + return true; + }); + const before = fake.requests.length; + await assert.rejects(provider({ MIEWEB_OS_TOKEN: '' }).deploy(harness().ctx), AuthError); + assert.equal(fake.requests.length, before); + }); + + test('never reads secrets from targetConfig', async () => { + const h = harness({ targetConfig: { siteId: 1, token: TOKEN, apiKey: TOKEN } }); + await assert.rejects(provider({ MIEWEB_OS_TOKEN: '' }).deploy(h.ctx), AuthError); + }); +}); + +describe('destroy', () => { + test('deletes by hostname; no-op when absent', async () => { + const p = provider(); + await p.deploy(harness().ctx); + const h = harness(); + await p.destroy!(h.ctx); + assert.equal(fake.containers.length, 0); + assert.ok(h.logs.some((l) => l.includes('retained'))); + + const h2 = harness(); + await p.destroy!(h2.ctx); + assert.ok(h2.logs.some((l) => l.includes('nothing to destroy'))); + }); +}); + +describe('whoami', () => { + test('env token', async () => { + assert.deepEqual(await provider().whoami!(harness().ctx), { authenticated: true, account: 'alice', method: 'env' }); + }); + test('bad token / no token', async () => { + assert.deepEqual(await provider({ MIEWEB_OS_TOKEN: 'nope' }).whoami!(harness().ctx), { authenticated: false, method: 'env' }); + assert.deepEqual(await provider({ MIEWEB_OS_TOKEN: '' }).whoami!(harness().ctx), { authenticated: false }); + }); +}); + +describe('login / logout', () => { + /** Plays the browser: follow the Manager redirect, load the loopback page, post the fragment. */ + async function browser(url: string): Promise { + const res = await fetch(url, { redirect: 'manual' }); + const loc = new URL(res.headers.get('location')!); + const page = await fetch(`${loc.origin}${loc.pathname}`); + assert.match(await page.text(), /Finishing sign-in/); + const post = await fetch(`${loc.origin}/token`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain', Origin: loc.origin }, + body: loc.hash.slice(1), + }); + assert.equal(post.status, 200); + } + + test('loopback handoff stores the key per instance; whoami uses it; logout revokes it', async () => { + const creds = join(dir, 'login-creds.json'); + const env = { MIEWEB_OS_TOKEN: '', MIEWEB_OS_CREDENTIALS: creds }; + let opened = ''; + const p = provider(env, { + login: { + openBrowser: (u) => { + opened = u; + void browser(u); + }, + }, + }); + + const h = harness({ argv: ['--instance', `${fake.url}/`] }); + await p.login!(h.ctx); + const q = new URL(opened).searchParams; + assert.equal(new URL(opened).pathname, '/api/v1/auth/cli/callback'); + assert.match(q.get('state')!, /^[A-Za-z0-9_-]{32}$/); + assert.match(q.get('client')!, /^mieweb-cli@/); + + const stored = JSON.parse(await readFile(creds, 'utf8')); + assert.deepEqual(Object.keys(stored.instances), [fake.url]); + assert.equal(stored.instances[fake.url].token, 'minted-key'); + assert.equal(stored.instances[fake.url].apiKeyId, 'key-2'); + assert.equal((await stat(creds)).mode & 0o777, 0o600); + + assert.deepEqual(await p.whoami!(harness().ctx), { authenticated: true, account: 'alice', method: 'login' }); + + // Logging in again revokes the key it replaces. + fake.nextKey = { key: 'minted-key-2', id: 'key-3', user: 'alice' }; + await p.login!(h.ctx); + assert.equal(fake.tokens.has('minted-key'), false); + + await p.logout!(harness().ctx); + assert.equal(fake.tokens.has('minted-key-2'), false, 'key revoked server-side'); + assert.deepEqual(JSON.parse(await readFile(creds, 'utf8')).instances, {}); + assert.deepEqual(await p.whoami!(harness().ctx), { authenticated: false }); + }); + + test('a handoff with the wrong state is rejected', async () => { + const env = { MIEWEB_OS_TOKEN: '', MIEWEB_OS_CREDENTIALS: join(dir, 'state-creds.json') }; + let status = 0; + const p = provider(env, { + login: { + timeoutMs: 300, + openBrowser: (u) => { + const port = new URL(u).searchParams.get('port'); + void fetch(`http://127.0.0.1:${port}/token`, { method: 'POST', body: 'key=k&id=i&state=forged' }).then( + (r) => (status = r.status), + ); + }, + }, + }); + await assert.rejects(p.login!(harness().ctx), /Timed out/); + assert.equal(status, 400); + }); + + test('logout warns that an env token cannot be cleared', async () => { + const h = harness(); + await provider({ MIEWEB_OS_CREDENTIALS: join(dir, 'none.json') }).logout!(h.ctx); + assert.ok(h.logs.some((l) => l.startsWith('warn:MIEWEB_OS_TOKEN'))); + assert.ok(h.logs.some((l) => l.includes('Not logged in'))); + }); +}); diff --git a/packages/os-cloud-provider/test/ssh.test.ts b/packages/os-cloud-provider/test/ssh.test.ts new file mode 100644 index 00000000..3e55af74 --- /dev/null +++ b/packages/os-cloud-provider/test/ssh.test.ts @@ -0,0 +1,198 @@ +/** + * SshConnection against an in-process ssh2 server: auth fallbacks, host-key + * pinning, and exec with stdin. + */ + +import { strict as assert } from 'node:assert'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { timingSafeEqual } from 'node:crypto'; +import ssh2 from 'ssh2'; +import type { AuthContext, Connection } from 'ssh2'; +import { forgetHostKey, SshConnection, waitForSsh } from '../src/ssh.ts'; + +const { Server, utils } = ssh2; +const logger = { info() {}, warn() {}, error() {} }; + +interface ServerHandle { + port: number; + close: () => Promise; + authAttempts: string[]; +} + +function startServer(opts: { password?: string; publicKey?: Buffer; hostKey: string }): Promise { + const authAttempts: string[] = []; + const allowed = opts.publicKey ? utils.parseKey(opts.publicKey) : null; + const server = new Server({ hostKeys: [opts.hostKey] }, (client: Connection) => { + client.on('authentication', (ctx: AuthContext) => { + authAttempts.push(ctx.method); + if (ctx.method === 'password' && opts.password && ctx.password === opts.password) return ctx.accept(); + if (ctx.method === 'publickey' && allowed && !(allowed instanceof Error)) { + const same = + ctx.key.algo === allowed.type && timingSafeEqual(ctx.key.data, allowed.getPublicSSH()); + if (same && (!ctx.signature || allowed.verify(ctx.blob!, ctx.signature, ctx.hashAlgo))) return ctx.accept(); + } + ctx.reject(['password', 'publickey']); + }); + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept(); + session.on('exec', (acceptExec, _reject, info) => { + const stream = acceptExec(); + const chunks: Buffer[] = []; + stream.on('data', (d: Buffer) => chunks.push(d)); + stream.on('end', () => { + if (info.command === 'fail') { + stream.stderr.write('boom'); + stream.exit(3); + } else { + stream.write(`${info.command}:${Buffer.concat(chunks).toString()}`); + stream.exit(0); + } + stream.end(); + }); + }); + }); + }); + client.on('error', () => {}); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + port: (server.address() as { port: number }).port, + authAttempts, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +let home: string; +const hostKeyA = utils.generateKeyPairSync('ed25519').private; +const hostKeyB = utils.generateKeyPairSync('ed25519').private; +const userKey = utils.generateKeyPairSync('ed25519'); + +before(async () => { + home = await mkdtemp(join(tmpdir(), 'os-ssh-')); +}); +after(async () => { + await rm(home, { recursive: true, force: true }); +}); + +const signal = (): AbortSignal => AbortSignal.timeout(20_000); + +describe('SshConnection', () => { + test('password fallback via prompt (asked once), exec with stdin, host key pinned', async () => { + const srv = await startServer({ password: 's3cret', hostKey: hostKeyA }); + const knownHostsFile = join(home, 'kh1'); + const prompts: string[] = []; + const env = { HOME: join(home, 'nokeys'), SSH_AUTH_SOCK: '' }; + try { + const conn = await SshConnection.connect({ + target: { host: '127.0.0.1', port: srv.port, user: 'alice' }, + env, + knownHostsFile, + interactive: true, + prompt: async (q) => { + prompts.push(q); + return 's3cret'; + }, + signal: signal(), + logger, + }); + const res = await conn.exec('echo', Buffer.from('hello')); + assert.equal(res.code, 0); + assert.equal(res.stdout.toString(), 'echo:hello'); + const bad = await conn.exec('fail'); + assert.deepEqual([bad.code, bad.stderr], [3, 'boom']); + conn.close(); + assert.deepEqual(prompts, [`alice@127.0.0.1:${srv.port}'s password: `]); + assert.match(await readFile(knownHostsFile, 'utf8'), new RegExp(`^\\[127\\.0\\.0\\.1\\]:${srv.port} SHA256:\\S+\\n$`)); + } finally { + await srv.close(); + } + }); + + test('uses ~/.ssh keys without prompting; non-interactive password auth is skipped', async () => { + const keyHome = join(home, 'withkey'); + await mkdir(join(keyHome, '.ssh'), { recursive: true }); + await writeFile(join(keyHome, '.ssh', 'id_ed25519'), userKey.private); + const srv = await startServer({ publicKey: Buffer.from(userKey.public), hostKey: hostKeyA }); + try { + const conn = await SshConnection.connect({ + target: { host: '127.0.0.1', port: srv.port, user: 'alice' }, + env: { HOME: keyHome, SSH_AUTH_SOCK: '' }, + knownHostsFile: join(home, 'kh2'), + interactive: false, + prompt: async () => assert.fail('must not prompt'), + signal: signal(), + logger, + }); + conn.close(); + assert.ok(srv.authAttempts.includes('publickey')); + + await assert.rejects( + SshConnection.connect({ + target: { host: '127.0.0.1', port: srv.port, user: 'alice' }, + env: { HOME: join(home, 'nokeys'), SSH_AUTH_SOCK: '' }, + knownHostsFile: join(home, 'kh2'), + interactive: false, + signal: signal(), + logger, + }), + /SSH authentication as alice@127\.0\.0\.1:\d+ failed/, + ); + } finally { + await srv.close(); + } + }); + + test('a changed host key is rejected until the pin is forgotten', async () => { + const knownHostsFile = join(home, 'kh3'); + const opts = (port: number) => ({ + target: { host: '127.0.0.1', port, user: 'alice' }, + env: { HOME: join(home, 'nokeys'), SSH_AUTH_SOCK: '' }, + knownHostsFile, + interactive: true, + prompt: async () => 'pw', + signal: signal(), + logger, + }); + const a = await startServer({ password: 'pw', hostKey: hostKeyA }); + const port = a.port; + (await SshConnection.connect(opts(port))).close(); + await a.close(); + + const b = await new Promise((resolve) => { + const tryListen = async (): Promise => { + // Reuse the same port so the pin applies. + const srv = new Server({ hostKeys: [hostKeyB] }, (c: Connection) => { + c.on('authentication', (ctx: AuthContext) => (ctx.method === 'password' ? ctx.accept() : ctx.reject(['password']))); + c.on('error', () => {}); + }); + srv.listen(port, '127.0.0.1', () => + resolve({ port, authAttempts: [], close: () => new Promise((r) => srv.close(() => r())) }), + ); + }; + void tryListen(); + }); + try { + await assert.rejects(SshConnection.connect(opts(port)), /host key for \[127\.0\.0\.1\]:\d+ changed/); + await forgetHostKey(knownHostsFile, '127.0.0.1', port); + (await SshConnection.connect(opts(port))).close(); + } finally { + await b.close(); + } + }); + + test('waitForSsh sees the banner', async () => { + const srv = await startServer({ password: 'x', hostKey: hostKeyA }); + try { + await waitForSsh('127.0.0.1', srv.port, signal(), logger, 5000); + } finally { + await srv.close(); + } + }); +}); diff --git a/packages/os-cloud-provider/test/sync.test.ts b/packages/os-cloud-provider/test/sync.test.ts new file mode 100644 index 00000000..9ceebac4 --- /dev/null +++ b/packages/os-cloud-provider/test/sync.test.ts @@ -0,0 +1,142 @@ +import { strict as assert } from 'node:assert'; +import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { IgnoreRules, planSync, REMOTE, scanLocal, syncWorktree } from '../src/sync.ts'; +import { FakeShell } from './fake-shell.ts'; + +const logger = { info() {}, warn() {}, error() {} }; +let base: string; +let local: string; +let remote: string; + +async function put(root: string, rel: string, content: string): Promise { + await mkdir(join(root, rel, '..'), { recursive: true }); + await writeFile(join(root, rel), content); +} + +beforeEach(async () => { + base = await mkdtemp(join(tmpdir(), 'os-sync-')); + local = join(base, 'local'); + remote = join(base, 'remote'); + await put(local, '.gitignore', 'node_modules/\n*.log\ndist\n!keep.log\n'); + await put(local, 'package.json', '{}'); + await put(local, 'src/index.js', 'v1'); + await put(local, 'src/.gitignore', 'secret.txt\n'); + await put(local, 'src/secret.txt', 'nope'); + await put(local, 'untracked.txt', 'not staged, still synced'); + await put(local, 'debug.log', 'ignored'); + await put(local, 'keep.log', 'unignored by negation'); + await put(local, 'node_modules/x/index.js', 'local deps'); + await put(local, 'dist/out.js', 'build'); + await put(local, '.git/HEAD', 'ref: refs/heads/main'); + await symlink('src/index.js', join(local, 'link.js')); +}); +afterEach(async () => { + await rm(base, { recursive: true, force: true }); +}); + +describe('IgnoreRules', () => { + test('scoped, nested, negation, ancestors, .git', () => { + const r = new IgnoreRules(); + r.add('', 'node_modules/\n*.log\n!keep.log\n/build\n'); + r.add('pkg', 'tmp/\n'); + assert.equal(r.ignores('node_modules/a/b.js'), true); + assert.equal(r.ignores('pkg/node_modules/a.js'), true); + assert.equal(r.ignores('a/b.log'), true); + assert.equal(r.ignores('keep.log'), false); + assert.equal(r.ignores('build/x'), true); + assert.equal(r.ignores('pkg/build/x'), false); + assert.equal(r.ignores('pkg/tmp/x'), true); + assert.equal(r.ignores('tmp/x'), false); + assert.equal(r.ignores('.git/HEAD'), true); + assert.equal(r.ignores('src/.gitkeep'), false); + }); +}); + +describe('scanLocal', () => { + test('honors .gitignore, skips .git, includes untracked files and symlinks', async () => { + const { files } = await scanLocal(local); + assert.deepEqual([...files.keys()].sort(), [ + '.gitignore', + 'keep.log', + 'link.js', + 'package.json', + 'src/.gitignore', + 'src/index.js', + 'untracked.txt', + ]); + assert.equal(files.get('link.js')!.type, 'symlink'); + }); +}); + +describe('syncWorktree', () => { + test('first sync uploads everything, owned by mieweb, and restarts', async () => { + const shell = new FakeShell(remote); + const plan = await syncWorktree(local, shell, logger); + assert.equal(plan.upload.length, 7); + assert.equal(await readFile(join(remote, 'src/index.js'), 'utf8'), 'v1'); + assert.equal(await readlink(join(remote, 'link.js')), 'src/index.js'); + await assert.rejects(lstat(join(remote, 'src/secret.txt'))); + await assert.rejects(lstat(join(remote, '.git'))); + assert.equal(shell.owners.get('src/index.js'), 'mieweb:mieweb'); + assert.equal(shell.owners.get('src'), 'mieweb:mieweb'); + assert.equal(shell.commands.at(-1), REMOTE.restart); + }); + + test('second sync is incremental; deletes removed files; keeps remote ignored paths', async () => { + await syncWorktree(local, new FakeShell(remote), logger); + await put(remote, 'node_modules/installed/index.js', 'remote deps'); + await put(remote, 'dist/built.js', 'remote build'); + + const same = await syncWorktree(local, new FakeShell(remote), logger); + assert.deepEqual(same, { upload: [], remove: [] }); + + await writeFile(join(local, 'src/index.js'), 'v2!'); + const future = new Date(Date.now() + 5000); + await utimes(join(local, 'src/index.js'), future, future); + await rm(join(local, 'untracked.txt')); + await rm(join(local, 'src/.gitignore')); + const shell = new FakeShell(remote); + const plan = await syncWorktree(local, shell, logger); + assert.deepEqual(plan.upload.map((f) => f.path).sort(), ['src/index.js', 'src/secret.txt']); + assert.deepEqual(plan.remove, ['src/.gitignore', 'untracked.txt']); + assert.equal(await readFile(join(remote, 'src/index.js'), 'utf8'), 'v2!'); + await assert.rejects(lstat(join(remote, 'untracked.txt'))); + assert.equal(await readFile(join(remote, 'node_modules/installed/index.js'), 'utf8'), 'remote deps'); + assert.equal(await readFile(join(remote, 'dist/built.js'), 'utf8'), 'remote build'); + }); + + test('empty directories left by deletions are pruned', async () => { + await put(local, 'old/deep/file.txt', 'x'); + await syncWorktree(local, new FakeShell(remote), logger); + await rm(join(local, 'old'), { recursive: true }); + await syncWorktree(local, new FakeShell(remote), logger); + await assert.rejects(lstat(join(remote, 'old'))); + }); + + test('a failing remote command fails the sync with its stderr', async () => { + const shell = new FakeShell(remote); + shell.exec = async () => ({ code: 1, stdout: Buffer.alloc(0), stderr: 'sudo: a password is required' }); + await assert.rejects(syncWorktree(local, shell, logger), /listing failed \(exit 1\): sudo: a password is required/); + }); +}); + +test('planSync diff', () => { + const rules = new IgnoreRules(); + rules.add('', 'node_modules/\n'); + const localFiles = new Map([ + ['a', { path: 'a', type: 'file' as const, size: 1, mtime: 10, mode: 0o644 }], + ['b', { path: 'b', type: 'file' as const, size: 2, mtime: 10, mode: 0o644 }], + ]); + const remoteFiles = new Map([ + ['a', { size: 1, mtime: 10 }], + ['b', { size: 2, mtime: 9 }], + ['c', { size: 1, mtime: 1 }], + ['node_modules/x', { size: 1, mtime: 1 }], + ]); + const plan = planSync(localFiles, remoteFiles, rules); + assert.deepEqual(plan.upload.map((f) => f.path), ['b']); + assert.deepEqual(plan.remove, ['c']); +}); diff --git a/packages/os-cloud-provider/tsconfig.build.json b/packages/os-cloud-provider/tsconfig.build.json new file mode 100644 index 00000000..e58c0a6d --- /dev/null +++ b/packages/os-cloud-provider/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/os-cloud-provider/tsconfig.json b/packages/os-cloud-provider/tsconfig.json new file mode 100644 index 00000000..115d8e36 --- /dev/null +++ b/packages/os-cloud-provider/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + // Node runs the sources directly (native type stripping): erasable syntax + // only, type-only imports marked, `.ts` extensions on relative imports. + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test"] +} From df9fbefc1af311d608d0916f1abe6b642d33782a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 30 Sep 2026 09:51:09 -0400 Subject: [PATCH 05/16] docs: document mieweb CLI deploys and the cloud image (#475) --- .../docs/developers/docker-images.md | 8 ++ .../docs/users/mieweb-cli-deploy.md | 121 ++++++++++++++++++ mie-opensource-landing/zensical.toml | 1 + 3 files changed, 130 insertions(+) create mode 100644 mie-opensource-landing/docs/users/mieweb-cli-deploy.md diff --git a/mie-opensource-landing/docs/developers/docker-images.md b/mie-opensource-landing/docs/developers/docker-images.md index f6077319..55a9489a 100644 --- a/mie-opensource-landing/docs/developers/docker-images.md +++ b/mie-opensource-landing/docs/developers/docker-images.md @@ -34,6 +34,12 @@ The same Dockerfile as `docker`, built on top of the `nodejs` image instead of t **Registry:** `ghcr.io/mieweb/opensource-server/docker-nodejs` · **Source:** [`images/docker/`](https://github.com/mieweb/opensource-server/tree/main/images/docker) +### Converged app (`cloud`) + +Extends nodejs for `mieweb deploy --target mieweb` ([Deploying with the mieweb CLI](../users/mieweb-cli-deploy.md)). One container per app runs the app plus its datastores as systemd units, each bound to `127.0.0.1`: MinIO (`:9000`, R2), libSQL server `sqld` (`:8080`, D1/Vectorize), and Valkey (`:6379`, KV/Queues). Their data lives under the persistent `/mnt/data` [volume](../admins/core-concepts/volumes.md). `mieweb deploy` syncs the app's worktree into `/opt/app/src` over the container's SSH port and restarts `app.service`, which installs dependencies (only when `package.json` or the lockfile changed), runs the `build` script if present, and starts `MIEWEB_APP_START` (default `npm start`) on `$PORT`. Until the first sync, `app.service` is skipped. MinIO is built from source at a pinned release (MinIO no longer publishes binaries), and `sqld` is a checksum-verified release download. + +**Registry:** `ghcr.io/mieweb/opensource-server/cloud` · **Source:** [`images/cloud/`](https://github.com/mieweb/opensource-server/tree/main/images/cloud) + ### Agent (`agent`) Extends nodejs with the `opensource-agent` package (check-in agent, nginx, dnsmasq) and [acme.sh](https://github.com/acmesh-official/acme.sh) for ACME certificate management. Used as the networking layer for each site — handles reverse proxy, DNS, and TLS. See [Deploying Agents](../admins/deploying-agents.md). @@ -61,6 +67,8 @@ images/ │ └── ldapusers ├── nodejs/ │ └── Dockerfile # Extends base image +├── cloud/ +│ └── Dockerfile # Extends nodejs (MinIO + sqld + Valkey + app units) ├── builder/ │ └── Dockerfile # Builds the .deb packages (artifact-only image) ├── docs/ diff --git a/mie-opensource-landing/docs/users/mieweb-cli-deploy.md b/mie-opensource-landing/docs/users/mieweb-cli-deploy.md new file mode 100644 index 00000000..987e8492 --- /dev/null +++ b/mie-opensource-landing/docs/users/mieweb-cli-deploy.md @@ -0,0 +1,121 @@ +# Deploying with the mieweb CLI + +`mieweb deploy --target mieweb` deploys a [`@mieweb/cloud`](https://github.com/mieweb/cloud) app (a Cloudflare-Workers-style app described by `wrangler.jsonc`) to an opensource-server site such as os.mieweb.org. The CLI drives the [`@mieweb/os-cloud-provider`](https://github.com/mieweb/opensource-server/tree/main/packages/os-cloud-provider) package, which creates or updates one container per app through the Manager API. + +## What gets deployed + +Each app gets **one container** on the site, named after `wrangler.jsonc` `name` (it must be a valid DNS label). The container runs the [`cloud` image](../developers/docker-images.md#converged-app-cloud): + +- your app, copied from your local worktree and started with `npm start` on `$PORT` (default `8787`) +- MinIO, libSQL (`sqld`) and Valkey on `127.0.0.1`, backing the R2, D1/Vectorize and KV/Queue bindings +- a persistent read-write [volume](../admins/core-concepts/volumes.md) at `/mnt/data` for all datastore state + +The app is exposed through one HTTP service at `https://.`; the site's nginx terminates TLS in front of it. The container also publishes SSH (port 22) on a site port, which `deploy` uses to copy your code. + +### What `deploy` does + +1. **Converge the container** through the Manager API. The first deploy creates it and waits for it to be provisioned. Later deploys change the container only when its configuration differs (services, environment variables, the data volume); otherwise this step makes no changes. If the image (or the GPU requirement) changes, the container is deleted and recreated. `/mnt/data` is kept across that recreate, so datastore contents survive. +2. **Wait for SSH**, then **sync your worktree** into `/opt/app/src` in the container (rsync-style: only changed files are sent): + - Files are sent exactly as they are on disk. Whether a file is committed, staged, or untracked doesn't matter. + - `.gitignore` rules are honored (including nested `.gitignore` files) and `.git/` is skipped. `.git/info/exclude` and your global git excludes are **not** applied. + - Files you deleted locally are deleted in the container. Ignored paths in the container, such as `node_modules` and build output, are left alone. +3. **Restart the app** over the same SSH connection. On start it installs dependencies if `package.json` or the lockfile changed, runs the `build` script if there is one, then runs the start command. + +So a code-only redeploy is just a file sync and a restart. + +### SSH credentials + +The sync uses a built-in SSH client, so you don't need `ssh` or `rsync` installed. It logs in as your Manager username (override with `targets.mieweb.sshUser` or `MIEWEB_OS_SSH_USER`) and tries your local credentials in this order: + +1. your ssh-agent (`SSH_AUTH_SOCK`, or Pageant on Windows) +2. `~/.ssh/id_ed25519`, `id_ecdsa` and `id_rsa`, asking for the passphrase if a key is encrypted +3. your password, asked for in the terminal + +Keys work when the public key is on your account. The whole sync uses one connection, so you're asked for a password at most once. With no terminal (e.g. in CI), password login is skipped and deploy fails with a hint unless a key or agent works. + +The container's host key is trusted on first connection and pinned in `~/.mieweb/known_hosts`. A changed key is an error, except that the pin is cleared when `deploy` itself recreates the container. + +## Setup + +Install the provider in your app: + +```sh +pnpm add -D @mieweb/os-cloud-provider +``` + +Point the `mieweb` target at it in `mieweb.jsonc`: + +```jsonc +{ + "targets": { + "mieweb": { + "provider": "@mieweb/os-cloud-provider", + "siteId": 1, // required: the Manager site to deploy into + "instanceUrl": "https://os.mieweb.org", // optional (default) + // Everything below is optional. + "image": "ghcr.io/mieweb/opensource-server/cloud:latest", // e.g. a :/:sha- tag to test + "port": 8787, // port the app listens on ($PORT) + "domain": "os.mieweb.org", // external domain (name or id); default: the site's first + "externalHostname": "my-app", // default: wrangler.jsonc `name` + "authRequired": false, // put the site's auth proxy in front of the app + "start": "npm start", // start command inside the container + "sshUser": "alice", // default: your Manager username + "sshHost": "203.0.113.10", // default: the container's published SSH host + "sync": true, // false: converge the container only, don't copy code + "services": [{ "type": "tcp", "internalPort": 22 }] // extra tcp/udp/srv services + } + } +} +``` + +`mieweb.jsonc` holds **no secrets**. + +## Authentication + +Either set a token in the environment (CI): + +```sh +export MIEWEB_OS_TOKEN= # create one under API Keys in the web UI +export MIEWEB_OS_URL=https://os.mieweb.org # optional; overrides instanceUrl +mieweb deploy --target mieweb +``` + +or log in interactively: + +```sh +mieweb login --target mieweb [--instance https://os.mieweb.org] +``` + +`login` opens your browser to the Manager. After you sign in and confirm, the Manager creates an API key and hands it back to the CLI through a one-time listener on `127.0.0.1`. The key is stored in `~/.mieweb/os.json` (mode `0600`), keyed by instance, so you can stay logged in to several instances at once. `mieweb logout --target mieweb` revokes the key and removes it from that file. `mieweb whoami --target mieweb` shows who you are signed in as. + +`MIEWEB_OS_TOKEN` always takes precedence over the stored login. + +## Environment variables in the container + +The container's environment is replaced on every deploy with: + +| Source | Variables | +| --- | --- | +| `wrangler.jsonc` `vars` | as declared (non-string values are JSON-encoded) | +| `MIEWEB_OS_SECRET_` in the deploying shell | `` (for secrets, e.g. `MIEWEB_OS_SECRET_API_KEY` → `API_KEY`) | +| Provider-managed | `PORT`, `MIEWEB_TARGET`, `MIEWEB_APP_START`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MIEWEB_S3_ENDPOINT`, `MIEWEB_S3_ACCESS_KEY_ID`, `MIEWEB_S3_SECRET_ACCESS_KEY`, `MIEWEB_LIBSQL_URL`, `MIEWEB_VALKEY_URL` | + +The MinIO password is generated on the first deploy and reused after that. Variables added to the container through the web UI are removed on the next deploy. + +## Other commands + +| Command | Behavior | +| --- | --- | +| `mieweb destroy --target mieweb` | Deletes the container. The `/mnt/data` directory is retained on the node and reattached if you deploy the same name again. | +| `mieweb dev --target mieweb` | Not provided by this provider (there's no remote dev mode). Use the local host harness. | +| `mieweb tail --target mieweb` | Not supported yet: the Manager streams job output, not app logs. | + +## Testing against a local Manager + +`make dev` runs the Manager at `http://localhost:3000` on SQLite with a simulated hypervisor and a seeded `localhost` site (id `1`). Create an API key in its UI, then: + +```sh +MIEWEB_OS_URL=http://localhost:3000 MIEWEB_OS_TOKEN= mieweb deploy --target mieweb +``` + +The simulated hypervisor has no real SSH endpoint, so set `"sync": false` for this loop. The provider's live test suite (`pnpm test:live` in `packages/os-cloud-provider`) runs the same loop. diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml index 6461d144..33100bf8 100644 --- a/mie-opensource-landing/zensical.toml +++ b/mie-opensource-landing/zensical.toml @@ -28,6 +28,7 @@ nav = [ ] }, { "VS Code Setup" = "users/vscode-setup.md" }, { "MCP Server" = "users/mcp-server.md" }, + { "Deploying with the mieweb CLI" = "users/mieweb-cli-deploy.md" }, ] }, { "Admins" = [ { "Overview" = "admins/index.md" }, From a43730d2fcf03bea8a5c2d89ff37abd0d34df78b Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 30 Sep 2026 11:19:58 -0400 Subject: [PATCH 06/16] os-cloud-provider: pin pnpm 12, consume deploy-contract preview from GitHub Packages, publish PR previews (#475) --- .../workflows/os-cloud-provider-preview.yml | 61 +++++++ .github/workflows/os-cloud-provider.yml | 8 +- packages/os-cloud-provider/.npmrc | 3 + packages/os-cloud-provider/package.json | 3 +- packages/os-cloud-provider/pnpm-lock.yaml | 169 +++++++++++++++++- .../os-cloud-provider/pnpm-workspace.yaml | 7 + 6 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/os-cloud-provider-preview.yml create mode 100644 packages/os-cloud-provider/.npmrc create mode 100644 packages/os-cloud-provider/pnpm-workspace.yaml diff --git a/.github/workflows/os-cloud-provider-preview.yml b/.github/workflows/os-cloud-provider-preview.yml new file mode 100644 index 00000000..ccb26b71 --- /dev/null +++ b/.github/workflows/os-cloud-provider-preview.yml @@ -0,0 +1,61 @@ +name: os-cloud-provider preview package + +# On every push to a same-repo pull request that touches the provider, publish +# @mieweb/os-cloud-provider to the GitHub Packages npm registry as +# `-pr.` under dist-tag `pr`, so cross-repo PRs (e.g. +# @mieweb/cli in mieweb/cloud) can depend on it with a plain semver reference +# plus `@mieweb:registry=https://npm.pkg.github.com` in .npmrc. Once merged and +# released to npmjs, consumers switch to the real version and drop that line. + +on: + pull_request: + paths: + - 'packages/os-cloud-provider/**' + - '.github/workflows/os-cloud-provider-preview.yml' + +concurrency: + group: os-cloud-provider-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + publish: + # GITHUB_TOKEN is read-only on fork PRs. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/os-cloud-provider + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + package_json_file: packages/os-cloud-provider/package.json + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: packages/os-cloud-provider/pnpm-lock.yaml + registry-url: https://npm.pkg.github.com + scope: '@mieweb' + - run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Stamp preview version + id: stamp + env: + SUFFIX: pr${{ github.event.pull_request.number }}.${{ github.run_number }} + run: | + v=$(node -p 'require("./package.json").version.replace(/-.*$/, "")')-$SUFFIX + npm pkg set version="$v" + echo "version=$v" >> "$GITHUB_OUTPUT" + # prepack builds dist/. + - run: pnpm publish --no-git-checks --tag pr${{ github.event.pull_request.number }} + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: echo "Published \`@mieweb/os-cloud-provider@${{ steps.stamp.outputs.version }}\` to npm.pkg.github.com" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/os-cloud-provider.yml b/.github/workflows/os-cloud-provider.yml index 010dcbba..eed3a7d8 100644 --- a/.github/workflows/os-cloud-provider.yml +++ b/.github/workflows/os-cloud-provider.yml @@ -19,6 +19,8 @@ on: permissions: contents: read + # @mieweb/deploy-contract preview versions come from GitHub Packages. + packages: read jobs: test: @@ -35,13 +37,17 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@v4 with: - version: 10 + package_json_file: packages/os-cloud-provider/package.json - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: pnpm cache-dependency-path: packages/os-cloud-provider/pnpm-lock.yaml + registry-url: https://npm.pkg.github.com + scope: '@mieweb' - run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generated Manager types are up to date run: pnpm gen:types && git diff --exit-code -- src/generated - run: pnpm typecheck diff --git a/packages/os-cloud-provider/.npmrc b/packages/os-cloud-provider/.npmrc new file mode 100644 index 00000000..8c7deef7 --- /dev/null +++ b/packages/os-cloud-provider/.npmrc @@ -0,0 +1,3 @@ +# Preview: @mieweb/* resolve from GitHub Packages while cross-repo PRs are in review. +# Remove once @mieweb/deploy-contract is released to npmjs. +@mieweb:registry=https://npm.pkg.github.com diff --git a/packages/os-cloud-provider/package.json b/packages/os-cloud-provider/package.json index 1c043880..0da8be2b 100644 --- a/packages/os-cloud-provider/package.json +++ b/packages/os-cloud-provider/package.json @@ -1,6 +1,7 @@ { "name": "@mieweb/os-cloud-provider", "version": "0.1.0", + "packageManager": "pnpm@12.8.1", "description": "DeployProvider (@mieweb/deploy-contract) for opensource-server / os.mieweb.org: `mieweb deploy --target mieweb` converges one container per app through the Manager API.", "type": "module", "license": "MIT", @@ -36,7 +37,7 @@ "gen:types": "openapi-typescript ../../create-a-container/openapi.v1.yaml -o src/generated/manager-api.ts" }, "dependencies": { - "@mieweb/deploy-contract": "github:mieweb/cloud#2bd2f789cb177168a3d18941a3bf12263eebfe64&path:/packages/deploy-contract", + "@mieweb/deploy-contract": "0.2.1-pr14.1", "ignore": "^7.0.10", "ssh2": "^1.17.0", "tar-stream": "^3.2.1" diff --git a/packages/os-cloud-provider/pnpm-lock.yaml b/packages/os-cloud-provider/pnpm-lock.yaml index ae792642..4abf8cf4 100644 --- a/packages/os-cloud-provider/pnpm-lock.yaml +++ b/packages/os-cloud-provider/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + cpu: [x64] + os: [win32] + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.8.1': + optional: true + + '@pnpm/exe.android-x64@12.8.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.8.1': + optional: true + + '@pnpm/exe.darwin-x64@12.8.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64@12.8.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.8.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.8.1': + optional: true + + '@pnpm/exe.linux-s390x@12.8.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + '@pnpm/exe.win32-arm64@12.8.1': + optional: true + + '@pnpm/exe.win32-x64@12.8.1': + optional: true + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + +--- lockfileVersion: '9.0' settings: @@ -9,8 +167,8 @@ importers: .: dependencies: '@mieweb/deploy-contract': - specifier: github:mieweb/cloud#2bd2f789cb177168a3d18941a3bf12263eebfe64&path:/packages/deploy-contract - version: https://codeload.github.com/mieweb/cloud/tar.gz/2bd2f789cb177168a3d18941a3bf12263eebfe64#path:/packages/deploy-contract + specifier: 0.2.1-pr14.1 + version: 0.2.1-pr14.1 ignore: specifier: ^7.0.10 version: 7.0.10 @@ -47,9 +205,8 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@mieweb/deploy-contract@https://codeload.github.com/mieweb/cloud/tar.gz/2bd2f789cb177168a3d18941a3bf12263eebfe64#path:/packages/deploy-contract': - resolution: {path: /packages/deploy-contract, tarball: https://codeload.github.com/mieweb/cloud/tar.gz/2bd2f789cb177168a3d18941a3bf12263eebfe64} - version: 0.2.1 + '@mieweb/deploy-contract@0.2.1-pr14.1': + resolution: {integrity: sha512-SGTx/dmgLc+9vb3k/ilOqORa7VBlf3MiMmoaHkgvGLR4CHYkbdFPaYBfvLtnFfaqLD9Mta2E5kOHmAFkvGBCkA==, tarball: https://npm.pkg.github.com/download/@mieweb/deploy-contract/0.2.1-pr14.1/653c8e1927998f1779a2d334558d79f8389fb2a7} '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -291,7 +448,7 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@mieweb/deploy-contract@https://codeload.github.com/mieweb/cloud/tar.gz/2bd2f789cb177168a3d18941a3bf12263eebfe64#path:/packages/deploy-contract': {} + '@mieweb/deploy-contract@0.2.1-pr14.1': {} '@redocly/ajv@8.11.2': dependencies: diff --git a/packages/os-cloud-provider/pnpm-workspace.yaml b/packages/os-cloud-provider/pnpm-workspace.yaml new file mode 100644 index 00000000..ae8145b6 --- /dev/null +++ b/packages/os-cloud-provider/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# Standalone package (not part of a repo-wide workspace); this file only holds pnpm settings. +# ssh2 works in pure JS; skip its optional native add-ons so installs need no compiler. +allowBuilds: + ssh2: false + cpu-features: false +minimumReleaseAgeExclude: + - '@mieweb/deploy-contract@0.2.1-pr14.1' From 816825ca2dac39346693d96179944f603a91f5b9 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Oct 2026 14:16:10 -0400 Subject: [PATCH 07/16] os-cloud-provider: pick the site at deploy time when siteId is unset (#475) Uses the only visible site, otherwise prompts on the terminal; non-interactive runs error with the list. MIEWEB_OS_SITE_ID overrides targets.mieweb.siteId. --- .../os-cloud-provider/examples/mieweb.jsonc | 2 + packages/os-cloud-provider/src/config.ts | 10 +-- packages/os-cloud-provider/src/deploy.ts | 76 +++++++++++++++---- .../os-cloud-provider/test/config.test.ts | 6 +- .../os-cloud-provider/test/fake-manager.ts | 4 + .../os-cloud-provider/test/provider.test.ts | 24 +++++- 6 files changed, 99 insertions(+), 23 deletions(-) diff --git a/packages/os-cloud-provider/examples/mieweb.jsonc b/packages/os-cloud-provider/examples/mieweb.jsonc index 32bbde89..d337d197 100644 --- a/packages/os-cloud-provider/examples/mieweb.jsonc +++ b/packages/os-cloud-provider/examples/mieweb.jsonc @@ -4,6 +4,7 @@ // Non-secret configuration only. Credentials come from the environment: // MIEWEB_OS_TOKEN API key (or run `mieweb login --target mieweb`) // MIEWEB_OS_URL Manager URL; overrides targets.mieweb.instanceUrl +// MIEWEB_OS_SITE_ID Manager site; overrides targets.mieweb.siteId // MIEWEB_OS_SECRET_ injected into the container as // MIEWEB_OS_SSH_USER SSH login for the code sync (default: your Manager username) // @@ -14,6 +15,7 @@ "targets": { "mieweb": { "provider": "@mieweb/os-cloud-provider", + // Optional: without it deploy uses your only site, or asks which one. "siteId": 1, "instanceUrl": "https://os.mieweb.org", // Optional; defaults shown. diff --git a/packages/os-cloud-provider/src/config.ts b/packages/os-cloud-provider/src/config.ts index 61de730e..a603ac39 100644 --- a/packages/os-cloud-provider/src/config.ts +++ b/packages/os-cloud-provider/src/config.ts @@ -95,7 +95,8 @@ export interface ExtraService { /** The parts of `targets.mieweb` deploy/destroy use. All non-secret. */ export interface TargetSettings { instanceUrl: string; - siteId: number; + /** Manager site; undefined → chosen at deploy time (see resolveSiteId in deploy.ts). */ + siteId?: number; image: string; port: number; /** External hostname label; defaults to the app name. */ @@ -150,10 +151,9 @@ export function resolveTargetSettings(ctx: DeployContext, env: ProviderEnv): Tar const name = appName(ctx.manifest); const target = `targets.${ctx.target}`; - if (tc.siteId === undefined || tc.siteId === null || tc.siteId === '') { - throw new ConfigError(`mieweb.jsonc ${target}.siteId is required (the Manager site to deploy into)`); - } - const siteId = posInt(tc.siteId, `${target}.siteId`); + // MIEWEB_OS_SITE_ID → targets.mieweb.siteId → (deploy time) the only site, or a prompt. + const rawSite = str(env.MIEWEB_OS_SITE_ID) ?? (tc.siteId === null || tc.siteId === '' ? undefined : tc.siteId); + const siteId = rawSite === undefined ? undefined : posInt(rawSite, str(env.MIEWEB_OS_SITE_ID) ? 'MIEWEB_OS_SITE_ID' : `${target}.siteId`); const externalHostname = str(tc.externalHostname) ?? name; if (!DNS_LABEL.test(externalHostname)) { diff --git a/packages/os-cloud-provider/src/deploy.ts b/packages/os-cloud-provider/src/deploy.ts index 62ad725c..89c9a870 100644 --- a/packages/os-cloud-provider/src/deploy.ts +++ b/packages/os-cloud-provider/src/deploy.ts @@ -39,7 +39,7 @@ import { } from './config.ts'; import { waitForJob } from './jobs.ts'; import type { SessionInfo } from './api-types.ts'; -import { forgetHostKey, knownHostsPath, SshConnection, waitForSsh, type Prompter, type RemoteShell, type SshTarget } from './ssh.ts'; +import { forgetHostKey, knownHostsPath, SshConnection, ttyPrompter, waitForSsh, type Prompter, type RemoteShell, type SshTarget } from './ssh.ts'; import { syncWorktree } from './sync.ts'; /** Env keys the provider owns inside the converged container. */ @@ -296,19 +296,63 @@ async function findByHostname(client: ManagerClient, siteId: number, hostname: s return list.find((c) => c.hostname === hostname) ?? null; } +interface SiteSummary { + id: number; + name: string; +} + +/** + * The site to deploy into when none is configured: the only site the user can + * see, otherwise ask on the terminal. Non-interactive runs get an error that + * lists the choices. + */ +export async function resolveSiteId( + configured: number | undefined, + client: ManagerClient, + deps: ProviderDeps, + logger: DeployContext['logger'], + target: string, +): Promise { + if (configured !== undefined) return configured; + const sites = await client.get('/sites'); + const hint = (id: number): string => + `set targets.${target}.siteId to ${id} in mieweb.jsonc (or MIEWEB_OS_SITE_ID) to skip this`; + if (sites.length === 0) throw new ConfigError('No Manager sites are visible to your account'); + const [only] = sites; + if (sites.length === 1 && only) { + logger.info(`Using site ${only.id} (${only.name}), the only one available; ${hint(only.id)}`); + return only.id; + } + const list = sites.map((x) => ` ${x.id}) ${x.name}`).join('\n'); + const prompt = deps.prompt ?? ttyPrompter; + for (;;) { + const answer = await prompt(`Manager sites:\n${list}\nSite to deploy into: `, false); + if (answer === null) { + throw new ConfigError(`targets.${target}.siteId is required (the Manager site to deploy into). Available:\n${list}`); + } + const pick = sites.find((x) => String(x.id) === answer.trim() || x.name === answer.trim()); + if (pick) { + logger.info(`Using site ${pick.id} (${pick.name}); ${hint(pick.id)}`); + return pick.id; + } + process.stderr.write(`"${answer.trim()}" is not one of the listed sites\n`); + } +} + export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise { const { logger, signal } = ctx; const name = appName(ctx.manifest); const s = resolveTargetSettings(ctx, deps.env); const client = await clientFor(ctx, deps, s.instanceUrl); + const siteId = await resolveSiteId(s.siteId, client, deps, ctx.logger, ctx.target); const image = normalizeImageRef(s.image); const wait = (jobId: number): Promise => waitForJob(client, jobId, { signal, logger, intervalMs: deps.pollIntervalMs }); - logger.info(`Deploying "${name}" to site ${s.siteId} on ${s.instanceUrl}`); + logger.info(`Deploying "${name}" to site ${siteId} on ${s.instanceUrl}`); logger.info(`Image: ${image}`); - const form = await client.get(`/sites/${s.siteId}/containers/new`); + const form = await client.get(`/sites/${siteId}/containers/new`); const domain = pickDomain(form, s.domain); let nvidia = s.nvidia ?? false; if (s.nvidia === undefined && wantsAi(ctx.manifest)) { @@ -336,7 +380,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise logger.warn(m), }); - let existing = await findByHostname(client, s.siteId, name); + let existing = await findByHostname(client, siteId, name); let carryEnv: Container | null = existing; if (existing) { @@ -345,7 +389,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise {}); - existing = await findByHostname(client, s.siteId, name); + existing = await findByHostname(client, siteId, name); carryEnv = existing; } } @@ -362,7 +406,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise 0) { logger.info(`Recreating container ${existing.id} (${drift.join(', ')}); ${DATA_VOLUME.mountPath} is retained`); - const del = await client.delete(`/sites/${s.siteId}/containers/${existing.id}`); + const del = await client.delete(`/sites/${siteId}/containers/${existing.id}`); for (const w of del.dnsWarnings ?? []) logger.warn(w); existing = null; } @@ -372,7 +416,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise(`/sites/${s.siteId}/containers/${id}`, body); + const upd = await client.put(`/sites/${siteId}/containers/${id}`, body); for (const w of upd.dnsWarnings ?? []) logger.warn(w); if (upd.jobId) { logger.info(`Updated container ${id}; waiting for job ${upd.jobId}`); @@ -424,7 +468,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise(`/sites/${s.siteId}/containers/${id!}`); + const final = await client.get(`/sites/${siteId}/containers/${id!}`); if (!final.containerId) { throw new Error(`Container ${id!} has no hypervisor id after the job finished (status: ${final.status ?? 'unknown'})`); } @@ -466,6 +510,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise { try { - const created = await client.post(`/sites/${s.siteId}/containers`, { + const created = await client.post(`/sites/${siteId}/containers`, { hostname: name, template: image, nvidiaRequested: nvidia, @@ -486,10 +531,10 @@ async function createOrAdopt( return { created }; } catch (err) { if (!(err instanceof ManagerApiError) || err.status !== 409 || err.code !== 'conflict') throw err; - const adopted = await findByHostname(client, s.siteId, name); + const adopted = await findByHostname(client, siteId, name); if (!adopted) { throw new Error( - `Hostname "${name}" is already taken on site ${s.siteId} by a container you cannot manage; ` + + `Hostname "${name}" is already taken on site ${siteId} by a container you cannot manage; ` + 'rename the app (wrangler.jsonc `name`) or ask its owner to delete it.', { cause: err }, ); @@ -503,12 +548,13 @@ export async function destroy(ctx: DeployContext, deps: ProviderDeps): Promise(`/sites/${s.siteId}/containers/${existing.id}`); + const res = await client.delete(`/sites/${siteId}/containers/${existing.id}`); for (const w of res.dnsWarnings ?? []) ctx.logger.warn(w); const retained = (existing.volumes ?? []).some((v) => v.mountPath === DATA_VOLUME.mountPath); ctx.logger.info( diff --git a/packages/os-cloud-provider/test/config.test.ts b/packages/os-cloud-provider/test/config.test.ts index 783be00e..c79907d4 100644 --- a/packages/os-cloud-provider/test/config.test.ts +++ b/packages/os-cloud-provider/test/config.test.ts @@ -59,8 +59,10 @@ describe('appName', () => { }); describe('resolveTargetSettings', () => { - test('siteId is required and must be an integer', () => { - assert.throws(() => resolveTargetSettings(ctx({ name: 'app' }), {}), /siteId is required/); + test('siteId is optional (chosen at deploy time), env overrides config, must be an integer', () => { + assert.equal(resolveTargetSettings(ctx({ name: 'app' }), {}).siteId, undefined); + assert.equal(resolveTargetSettings(ctx({ name: 'app' }, { siteId: 3 }), { MIEWEB_OS_SITE_ID: '5' }).siteId, 5); + assert.throws(() => resolveTargetSettings(ctx({ name: 'app' }), { MIEWEB_OS_SITE_ID: 'x' }), /MIEWEB_OS_SITE_ID must be/); assert.throws(() => resolveTargetSettings(ctx({ name: 'app' }, { siteId: 'one' }), {}), /positive integer/); }); diff --git a/packages/os-cloud-provider/test/fake-manager.ts b/packages/os-cloud-provider/test/fake-manager.ts index 66bb8e0b..7c651769 100644 --- a/packages/os-cloud-provider/test/fake-manager.ts +++ b/packages/os-cloud-provider/test/fake-manager.ts @@ -51,6 +51,8 @@ export class FakeManager { readonly requests: RequestLog[] = []; readonly domains = [{ id: 7, name: 'apps.example.test', siteId: 1 }]; readonly siteId = 1; + /** What GET /sites returns. */ + sites: { id: number; name: string }[] = [{ id: 1, name: 'site-one' }]; nvidiaAvailable = false; /** Emulate a Manager that predates volumes (#421). */ noVolumes = false; @@ -228,6 +230,8 @@ export class FakeManager { return ok({ id: job.id, status: job.status }); } + if (path === '/sites' && req.method === 'GET') return ok(this.sites); + m = /^\/sites\/(\d+)\/containers(?:\/(new|\d+))?$/.exec(path); if (!m) return fail(404, 'not_found'); if (Number(m[1]) !== this.siteId) return fail(404, 'site_not_found'); diff --git a/packages/os-cloud-provider/test/provider.test.ts b/packages/os-cloud-provider/test/provider.test.ts index 4c26abae..a43a03e5 100644 --- a/packages/os-cloud-provider/test/provider.test.ts +++ b/packages/os-cloud-provider/test/provider.test.ts @@ -338,12 +338,34 @@ describe('deploy', () => { }); test('config errors are reported before any request', async () => { - await assert.rejects(provider().deploy(harness({ targetConfig: {} }).ctx), /siteId is required/); await assert.rejects(provider().deploy(harness({ manifest: { name: 'Bad_Name' } }).ctx), /DNS label/); await assert.rejects(provider().deploy(harness({ targetConfig: { siteId: 1, domain: 'nope.test' } }).ctx), /not available/); assert.ok(fake.requests.every((r) => r.method === 'GET')); }); + test('no siteId: uses the only site, prompts among several, errors when not interactive', async () => { + const only = harness({ targetConfig: {} }); + await provider().deploy(only.ctx); + assert.ok(fake.requests.some((r) => r.path.startsWith('/sites/1/containers'))); + + fake.sites = [{ id: 1, name: 'site-one' }, { id: 2, name: 'site-two' }]; + try { + const asked: string[] = []; + const answers = ['nope', 'site-one']; + const p = provider({}, { prompt: async (q) => { asked.push(q); return answers.shift() ?? null; } }); + await p.deploy(harness({ targetConfig: {} }).ctx); + assert.equal(asked.length, 2); + assert.match(asked[0] ?? '', /1\) site-one\n 2\) site-two/); + + await assert.rejects( + provider({}, { prompt: async () => null }).deploy(harness({ targetConfig: {} }).ctx), + /siteId is required[\s\S]*2\) site-two/, + ); + } finally { + fake.sites = [{ id: 1, name: 'site-one' }]; + } + }); + test('401 → AuthError with a login hint; no token → AuthError without a request', async () => { const bad = provider({ MIEWEB_OS_TOKEN: 'wrong' }); await assert.rejects(bad.deploy(harness().ctx), (err: unknown) => { From 33c147977a142dba65a44f39e1a38d1989a32999 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Oct 2026 14:23:52 -0400 Subject: [PATCH 08/16] os-cloud-provider: save the chosen site to mieweb.jsonc via persistTargetConfig (#475) --- .../os-cloud-provider/examples/mieweb.jsonc | 3 +- packages/os-cloud-provider/package.json | 2 +- packages/os-cloud-provider/pnpm-lock.yaml | 10 +++--- .../os-cloud-provider/pnpm-workspace.yaml | 2 +- packages/os-cloud-provider/src/deploy.ts | 31 ++++++++++--------- .../os-cloud-provider/test/provider.test.ts | 8 +++++ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/os-cloud-provider/examples/mieweb.jsonc b/packages/os-cloud-provider/examples/mieweb.jsonc index d337d197..5a03b74d 100644 --- a/packages/os-cloud-provider/examples/mieweb.jsonc +++ b/packages/os-cloud-provider/examples/mieweb.jsonc @@ -15,7 +15,8 @@ "targets": { "mieweb": { "provider": "@mieweb/os-cloud-provider", - // Optional: without it deploy uses your only site, or asks which one. + // Optional: without it deploy uses your only site, or asks which one, and + // saves the choice here. "siteId": 1, "instanceUrl": "https://os.mieweb.org", // Optional; defaults shown. diff --git a/packages/os-cloud-provider/package.json b/packages/os-cloud-provider/package.json index 0da8be2b..775f8de9 100644 --- a/packages/os-cloud-provider/package.json +++ b/packages/os-cloud-provider/package.json @@ -37,7 +37,7 @@ "gen:types": "openapi-typescript ../../create-a-container/openapi.v1.yaml -o src/generated/manager-api.ts" }, "dependencies": { - "@mieweb/deploy-contract": "0.2.1-pr14.1", + "@mieweb/deploy-contract": "0.2.1-pr14.5", "ignore": "^7.0.10", "ssh2": "^1.17.0", "tar-stream": "^3.2.1" diff --git a/packages/os-cloud-provider/pnpm-lock.yaml b/packages/os-cloud-provider/pnpm-lock.yaml index 4abf8cf4..236849f7 100644 --- a/packages/os-cloud-provider/pnpm-lock.yaml +++ b/packages/os-cloud-provider/pnpm-lock.yaml @@ -167,8 +167,8 @@ importers: .: dependencies: '@mieweb/deploy-contract': - specifier: 0.2.1-pr14.1 - version: 0.2.1-pr14.1 + specifier: 0.2.1-pr14.5 + version: 0.2.1-pr14.5 ignore: specifier: ^7.0.10 version: 7.0.10 @@ -205,8 +205,8 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@mieweb/deploy-contract@0.2.1-pr14.1': - resolution: {integrity: sha512-SGTx/dmgLc+9vb3k/ilOqORa7VBlf3MiMmoaHkgvGLR4CHYkbdFPaYBfvLtnFfaqLD9Mta2E5kOHmAFkvGBCkA==, tarball: https://npm.pkg.github.com/download/@mieweb/deploy-contract/0.2.1-pr14.1/653c8e1927998f1779a2d334558d79f8389fb2a7} + '@mieweb/deploy-contract@0.2.1-pr14.5': + resolution: {integrity: sha512-OgTpvGm8iUp0JKjsr1k9kIVsjSMzmJbr5VXNA+QNDXen6ItKOhR9fe/D2T9SzrFyO/0+qbLenz5yT0CUAr9AEA==, tarball: https://npm.pkg.github.com/download/@mieweb/deploy-contract/0.2.1-pr14.5/18d12fabb58f4b7350f4b0065ed3fd46f716d6fc} '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -448,7 +448,7 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@mieweb/deploy-contract@0.2.1-pr14.1': {} + '@mieweb/deploy-contract@0.2.1-pr14.5': {} '@redocly/ajv@8.11.2': dependencies: diff --git a/packages/os-cloud-provider/pnpm-workspace.yaml b/packages/os-cloud-provider/pnpm-workspace.yaml index ae8145b6..feeb5b32 100644 --- a/packages/os-cloud-provider/pnpm-workspace.yaml +++ b/packages/os-cloud-provider/pnpm-workspace.yaml @@ -4,4 +4,4 @@ allowBuilds: ssh2: false cpu-features: false minimumReleaseAgeExclude: - - '@mieweb/deploy-contract@0.2.1-pr14.1' + - '@mieweb/deploy-contract@0.2.1-pr14.1 || 0.2.1-pr14.5' diff --git a/packages/os-cloud-provider/src/deploy.ts b/packages/os-cloud-provider/src/deploy.ts index 89c9a870..f8cfc618 100644 --- a/packages/os-cloud-provider/src/deploy.ts +++ b/packages/os-cloud-provider/src/deploy.ts @@ -310,19 +310,25 @@ export async function resolveSiteId( configured: number | undefined, client: ManagerClient, deps: ProviderDeps, - logger: DeployContext['logger'], - target: string, + ctx: DeployContext, ): Promise { if (configured !== undefined) return configured; + const { logger, target } = ctx; const sites = await client.get('/sites'); - const hint = (id: number): string => - `set targets.${target}.siteId to ${id} in mieweb.jsonc (or MIEWEB_OS_SITE_ID) to skip this`; + // Save the choice to mieweb.jsonc when the host supports it (CLI >= this + // contract); otherwise tell the user what to set. + const remember = async (site: SiteSummary, why: string): Promise => { + logger.info(`Using site ${site.id} (${site.name})${why}`); + const saved = await ctx.persistTargetConfig?.({ siteId: site.id }).catch((err: unknown) => { + logger.warn(`Could not save siteId to mieweb.jsonc: ${err instanceof Error ? err.message : String(err)}`); + return false; + }); + if (!saved) logger.info(`Set targets.${target}.siteId to ${site.id} in mieweb.jsonc (or MIEWEB_OS_SITE_ID) to skip this`); + return site.id; + }; if (sites.length === 0) throw new ConfigError('No Manager sites are visible to your account'); const [only] = sites; - if (sites.length === 1 && only) { - logger.info(`Using site ${only.id} (${only.name}), the only one available; ${hint(only.id)}`); - return only.id; - } + if (sites.length === 1 && only) return remember(only, ', the only one available'); const list = sites.map((x) => ` ${x.id}) ${x.name}`).join('\n'); const prompt = deps.prompt ?? ttyPrompter; for (;;) { @@ -331,10 +337,7 @@ export async function resolveSiteId( throw new ConfigError(`targets.${target}.siteId is required (the Manager site to deploy into). Available:\n${list}`); } const pick = sites.find((x) => String(x.id) === answer.trim() || x.name === answer.trim()); - if (pick) { - logger.info(`Using site ${pick.id} (${pick.name}); ${hint(pick.id)}`); - return pick.id; - } + if (pick) return remember(pick, ''); process.stderr.write(`"${answer.trim()}" is not one of the listed sites\n`); } } @@ -344,7 +347,7 @@ export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise => waitForJob(client, jobId, { signal, logger, intervalMs: deps.pollIntervalMs }); @@ -548,7 +551,7 @@ export async function destroy(ctx: DeployContext, deps: ProviderDeps): Promise { const only = harness({ targetConfig: {} }); await provider().deploy(only.ctx); assert.ok(fake.requests.some((r) => r.path.startsWith('/sites/1/containers'))); + assert.ok(only.logs.some((l) => l.includes('Set targets.mieweb.siteId to 1')), 'hint when the host cannot persist'); + + // Host with persistTargetConfig: the choice is saved, no hint. + const saved: Record[] = []; + const h = harness({ targetConfig: {} }); + await provider().deploy({ ...h.ctx, persistTargetConfig: async (p) => { saved.push({ ...p }); return true; } }); + assert.deepEqual(saved, [{ siteId: 1 }]); + assert.ok(!h.logs.some((l) => l.includes('Set targets.mieweb.siteId'))); fake.sites = [{ id: 1, name: 'site-one' }, { id: 2, name: 'site-two' }]; try { From 9600773ac764cd6adfcac7f6a151dcffb7180330 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Oct 2026 14:27:04 -0400 Subject: [PATCH 09/16] ci(os-cloud-provider): preview packages default to the PR's cloud image (#475) --- .github/workflows/os-cloud-provider-preview.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/os-cloud-provider-preview.yml b/.github/workflows/os-cloud-provider-preview.yml index ccb26b71..4bf61efa 100644 --- a/.github/workflows/os-cloud-provider-preview.yml +++ b/.github/workflows/os-cloud-provider-preview.yml @@ -54,6 +54,14 @@ jobs: v=$(node -p 'require("./package.json").version.replace(/-.*$/, "")')-$SUFFIX npm pkg set version="$v" echo "version=$v" >> "$GITHUB_OUTPUT" + # Preview builds default to this PR's cloud image (build-images.yml pushes + # cloud:pr- on every PR push); `latest` only exists after a release. + - name: Default to this PR's cloud image + env: + TAG: pr-${{ github.event.pull_request.number }} + run: | + sed -i "s#\(DEFAULT_IMAGE = 'ghcr.io/mieweb/opensource-server/cloud\):latest'#\1:$TAG'#" src/config.ts + grep -q "cloud:$TAG'" src/config.ts # prepack builds dist/. - run: pnpm publish --no-git-checks --tag pr${{ github.event.pull_request.number }} env: From 8054a767ea1659b22a36e714a5c5564c70f52f4f Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Oct 2026 15:16:39 -0400 Subject: [PATCH 10/16] os-cloud-provider: implement tail by streaming app.service's journal over SSH (#475) --- .../docs/users/mieweb-cli-deploy.md | 2 +- packages/os-cloud-provider/README.md | 3 +- packages/os-cloud-provider/src/deploy.ts | 111 ++++++++++++++++-- packages/os-cloud-provider/src/index.ts | 9 +- packages/os-cloud-provider/src/ssh.ts | 41 +++++++ packages/os-cloud-provider/test/fake-shell.ts | 14 +++ .../os-cloud-provider/test/provider.test.ts | 52 +++++++- packages/os-cloud-provider/test/ssh.test.ts | 38 ++++++ 8 files changed, 251 insertions(+), 19 deletions(-) diff --git a/mie-opensource-landing/docs/users/mieweb-cli-deploy.md b/mie-opensource-landing/docs/users/mieweb-cli-deploy.md index 987e8492..1d113f86 100644 --- a/mie-opensource-landing/docs/users/mieweb-cli-deploy.md +++ b/mie-opensource-landing/docs/users/mieweb-cli-deploy.md @@ -108,7 +108,7 @@ The MinIO password is generated on the first deploy and reused after that. Varia | --- | --- | | `mieweb destroy --target mieweb` | Deletes the container. The `/mnt/data` directory is retained on the node and reattached if you deploy the same name again. | | `mieweb dev --target mieweb` | Not provided by this provider (there's no remote dev mode). Use the local host harness. | -| `mieweb tail --target mieweb` | Not supported yet: the Manager streams job output, not app logs. | +| `mieweb tail --target mieweb` | Streams the app's logs (`journalctl -u app.service`) over the same SSH connection deploy uses, so the same credentials apply. Shows the last 100 lines and keeps following until Ctrl-C. Options: `-n`/`--lines N`, `--no-follow`, and `--since