diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index fdbdbd38..1b111cd6 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -105,6 +105,20 @@ jobs: type=ref,event=tag type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + - name: Docker Meta (Cloud) + id: meta-cloud + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/cloud + bake-target: cloud + flavor: latest=false + tags: | + type=sha + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + - name: Docker Meta (Docs) id: meta-docs uses: docker/metadata-action@v5 @@ -169,6 +183,7 @@ jobs: ${{ steps.meta-nodejs.outputs.bake-file }} ${{ steps.meta-docker.outputs.bake-file }} ${{ steps.meta-docker-nodejs.outputs.bake-file }} + ${{ steps.meta-cloud.outputs.bake-file }} ${{ steps.meta-docs.outputs.bake-file }} ${{ steps.meta-agent.outputs.bake-file }} ${{ steps.meta-manager.outputs.bake-file }} @@ -182,6 +197,8 @@ jobs: docker.cache-to=type=gha,mode=max,scope=docker-${{ github.ref_name }} docker-nodejs.cache-from=type=gha,scope=docker-nodejs-${{ github.ref_name }} docker-nodejs.cache-to=type=gha,mode=max,scope=docker-nodejs-${{ github.ref_name }} + cloud.cache-from=type=gha,scope=cloud-${{ github.ref_name }} + cloud.cache-to=type=gha,mode=max,scope=cloud-${{ github.ref_name }} docs.cache-from=type=gha,scope=docs-${{ github.ref_name }} docs.cache-to=type=gha,mode=max,scope=docs-${{ github.ref_name }} agent.cache-from=type=gha,scope=agent-${{ github.ref_name }} diff --git a/.github/workflows/os-cloud-provider-preview.yml b/.github/workflows/os-cloud-provider-preview.yml new file mode 100644 index 00000000..4bf61efa --- /dev/null +++ b/.github/workflows/os-cloud-provider-preview.yml @@ -0,0 +1,69 @@ +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" + # 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: + 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 new file mode 100644 index 00000000..eed3a7d8 --- /dev/null +++ b/.github/workflows/os-cloud-provider.yml @@ -0,0 +1,55 @@ +name: os-cloud-provider + +# Typecheck + test @mieweb/os-cloud-provider (packages/os-cloud-provider). +# The regular suite runs against an in-process fake Manager; the live suite +# (pnpm test:live) needs a running Manager and is not run here. + +on: + push: + branches: [main] + paths: + - 'packages/os-cloud-provider/**' + - 'create-a-container/openapi.v1.yaml' + - '.github/workflows/os-cloud-provider.yml' + pull_request: + paths: + - 'packages/os-cloud-provider/**' + - 'create-a-container/openapi.v1.yaml' + - '.github/workflows/os-cloud-provider.yml' + +permissions: + contents: read + # @mieweb/deploy-contract preview versions come from GitHub Packages. + packages: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: ['22', '24'] + 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: ${{ 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 + - run: pnpm test + - run: pnpm build diff --git a/agent/src/volumes.ts b/agent/src/volumes.ts index 7480bfee..29885497 100644 --- a/agent/src/volumes.ts +++ b/agent/src/volumes.ts @@ -33,11 +33,12 @@ import fs from 'fs'; import { log } from './log'; import type { SiteConfig, SiteVolume, VolumeResult } from './types'; -// Mode for created directories. RW volumes get owner/group rwx (the id-mapped -// container root owns the dir, so it can write); RO volumes are r-x. World bits -// are left closed so other tenants can't read another container's data. -const RW_MODE = 0o0770; -const RO_MODE = 0o0550; +// Mode for volume roots, like /var/lib: the id-mapped container root owns the +// dir (rwx for RW, r-x for RO) and non-root service accounts inside the +// container can traverse it to their own subdirectories. Isolation between +// tenants comes from each container mounting only its own volume. +const RW_MODE = 0o0755; +const RO_MODE = 0o0555; /** * Collect the volumes to provision from the config snapshot (site-level). 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/models/dns-service.js b/create-a-container/models/dns-service.js index 254bfa43..0151ca00 100644 --- a/create-a-container/models/dns-service.js +++ b/create-a-container/models/dns-service.js @@ -16,7 +16,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, recordType: { type: DataTypes.ENUM('SRV'), diff --git a/create-a-container/models/http-service.js b/create-a-container/models/http-service.js index 7d12e1c4..1455af74 100644 --- a/create-a-container/models/http-service.js +++ b/create-a-container/models/http-service.js @@ -17,7 +17,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, externalHostname: { type: DataTypes.STRING(255), diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index 10397805..dfd7a652 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -47,7 +47,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, protocol: { type: DataTypes.ENUM('tcp', 'udp'), 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..a603ac39 --- /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; + /** 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. */ + 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}`; + + // 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)) { + 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..aec37d41 --- /dev/null +++ b/packages/os-cloud-provider/src/deploy.ts @@ -0,0 +1,697 @@ +/** + * `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 { sleep, waitForJob } from './jobs.ts'; +import type { SessionInfo } from './api-types.ts'; +import { forgetHostKey, knownHostsPath, SshConnection, SshError, 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. */ +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; + /** Total time to keep trying to reach SSH (default 60 s; tests shorten it). */ + sshTimeoutMs?: number; + /** Delay between SSH connection attempts (default 2 s). */ + sshRetryDelayMs?: number; +} + +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; +} + +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, + ctx: DeployContext, +): Promise { + if (configured !== undefined) return configured; + const { logger, target } = ctx; + const sites = await client.get('/sites'); + // 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) return remember(only, ', the only one available'); + 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) return remember(pick, ''); + 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); + const image = normalizeImageRef(s.image); + const wait = (jobId: number): Promise => + waitForJob(client, jobId, { signal, logger, intervalMs: deps.pollIntervalMs }); + + logger.info(`Deploying "${name}" to site ${siteId} on ${s.instanceUrl}`); + logger.info(`Image: ${image}`); + + 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)) { + 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, 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, 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/${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, siteId, 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/${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/${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) { + if (!final.sshPort || !(s.sshHost ?? final.sshHost)) { + throw new Error(`Container ${id!} has no published SSH port/host; cannot sync code (set targets.${ctx.target}.sync to false to skip)`); + } + const shell = await openShell(ctx, deps, client, s, final, { fresh }); + try { + await syncWorktree(ctx.root, shell, logger, signal); + } 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) }], + }; +} + +/** Open an SSH session to the container (waiting for SSH to come up). */ +export async function openShell( + ctx: DeployContext, + deps: ProviderDeps, + client: ManagerClient, + s: TargetSettings, + container: Container, + opts: { fresh?: boolean } = {}, +): Promise { + const { logger, signal } = ctx; + const port = container.sshPort; + const host = s.sshHost ?? container.sshHost ?? undefined; + if (!port || !host) throw new Error(`Container ${container.id} has no published SSH port/host`); + const user = s.sshUser ?? (await client.get('/session')).user; + const target: SshTarget = { host, port, user }; + const knownHostsFile = knownHostsPath(deps.env); + if (opts.fresh) await forgetHostKey(knownHostsFile, host, port); + + // A freshly created/rebuilt container can take a while before sshd answers, + // accepts connections reliably, and (via SSSD) serves the user's LDAP keys. + // Keep retrying transient failures within one overall budget. + const budgetMs = deps.sshTimeoutMs ?? 60_000; + const delayMs = deps.sshRetryDelayMs ?? 2000; + const deadline = Date.now() + budgetMs; + const remaining = (): number => Math.max(0, deadline - Date.now()); + // Ask for a password/passphrase at most once across attempts. + const answers = new Map(); + const basePrompt = deps.prompt ?? ttyPrompter; + const prompt: Prompter = async (q, hidden) => { + if (!answers.has(q)) answers.set(q, await basePrompt(q, hidden)); + return answers.get(q)!; + }; + + logger.info(`Connecting to ${user}@${host}:${port}`); + for (let attempt = 1; ; attempt += 1) { + try { + await (deps.waitForSsh ?? waitForSsh)(host, port, signal, logger, Math.max(remaining(), 1000)); + const attemptTimeout = Math.min(20_000, Math.max(remaining(), 5000)); + return await (deps.connectSsh + ? deps.connectSsh(target, { knownHostsFile, signal, logger }) + : SshConnection.connect({ target, env: deps.env, knownHostsFile, prompt, signal, logger, timeoutMs: attemptTimeout })); + } catch (err) { + if (signal.aborted) throw err; + const kind = err instanceof SshError ? err.kind : 'network'; + // Auth failures are only plausibly transient right after (re)creation. + const retryable = kind === 'network' || (kind === 'auth' && opts.fresh === true); + if (!retryable || remaining() <= delayMs) { + if (retryable && attempt > 1) { + throw new Error( + `SSH on ${host}:${port} was not usable within ${Math.round(budgetMs / 1000)}s (${attempt} attempts): ${(err as Error).message}`, + { cause: err }, + ); + } + throw err; + } + logger.info(`SSH not ready yet (${(err as Error).message.split('. ')[0]}); retrying…`); + await sleep(delayMs, signal); + } + } +} + +export interface TailOptions { + lines: number; + follow: boolean; + since?: string; +} + +/** Parse `mieweb tail` passthrough args: `-n/--lines N`, `--no-follow`, `--since