diff --git a/README.md b/README.md index 3be9be0..e1a0713 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,48 @@ The next times, it just sends the new code and deploys. The folder remembers its Not sure what to type? Run **`idem`** alone: a menu lists what you can do with the application of this folder. +## Deploy anything + +```bash +idem deploy # this folder +idem deploy --git https://github.com/me/app # a repository (--branch, --base-dir for a monorepo) +idem deploy --image ghcr.io/me/api:1.4 # an image (--registry-user + IDEM_REGISTRY_PASSWORD if private) +idem deploy --compose docker-compose.yml # your own stack, as a service +idem deploy --template n8n # a one-click service (idem templates lists them) +``` + +A new application goes into a **workspace** — everything in it shares one network, and its databases are linked to its applications. It runs on **IDEM's infrastructure** (nothing to manage; `--region` when your plan allows) or on **one of your servers** (`--server `). On a terminal, `idem deploy` asks; in CI, flags answer. + +Build settings when the detection needs help: `--base-dir`, `--install`, `--build`, `--start`, `--port`, `--publish-dir`. + ## Commands -| Command | | +| Applications | | |---|---| -| `idem deploy` | Deploy this folder (creates the application the first time). `-y` asks nothing, `-d` does not wait, `-a ` targets an application, `--env` sends `.env` with a new one | -| `idem login` / `logout` / `whoami` | Sign in through the browser (`--token` for CI) · sign out and revoke this machine's token | +| `idem deploy` | Deploy (see above). `-y` asks nothing, `-d` does not wait, `-a ` targets an application, `--env [file]` sends variables with a new one | | `idem apps` | Your applications, with their status and address | -| `idem link` / `unlink` | Link this folder to an existing application, or forget it | +| `idem status` · `idem deployments` | The application and its containers · its deployment history | | `idem logs [-f]` | What the running application prints; `-f` follows it | -| `idem status` | The application, its containers and its last deployment | | `idem open [--dashboard]` | Open the application — or its page on iDeploy | -| `idem restart` | Restart it (asks first; `-y` does not) | +| `idem restart` · `start` · `stop` | Its lifecycle | | `idem rollback [--to ]` | Back to an earlier version | +| `idem destroy` | Delete it, its data and its address (asks for its name) | | `idem env [ls\|set\|rm\|pull]` | Variables: list (hidden; `--reveal`), `set KEY=value…`, `rm KEY…`, `pull` into a local `.env` | +| `idem domains [ls\|add\|rm]` | Its addresses with their DNS/HTTPS state; `add` your domain and get the DNS record to create | +| `idem link` · `unlink` | Link this folder to an existing application, or forget it | + +| Infrastructure | | +|---|---| +| `idem workspaces [ls\|create\|show\|rm]` | Where things run together; `create --server ` or `--region ` | +| `idem db [ls\|create\|show\|start\|stop\|restart\|backup\|rm]` | PostgreSQL, MySQL, MariaDB, MongoDB, Redis, KeyDB, Dragonfly, ClickHouse, RabbitMQ; `show --reveal` gives the credentials | +| `idem services [ls\|start\|stop\|restart\|rm]` · `idem templates [search]` | Compose stacks and the catalogue | +| `idem servers [ls\|add\|check\|setup\|rm]` | Your own servers: `add` generates the SSH key (or uses yours), checks the server and installs Docker and the proxy | + +| Account | | +|---|---| +| `idem login` · `logout` · `whoami` | Sign in through the browser (`--token` for CI) · sign out and revoke this machine's token | | `idem dashboard` | Open iDeploy | +| `idem` | A menu of all this, for the application of this folder | Every command takes `--json` for scripts. diff --git a/src/api.ts b/src/api.ts index 1f0b177..d41d127 100644 --- a/src/api.ts +++ b/src/api.ts @@ -63,6 +63,7 @@ export const api = { get: (path: string, opts?: RequestOptions) => request('GET', path, undefined, opts), post: (path: string, body?: unknown, opts?: RequestOptions) => request('POST', path, body ?? {}, opts), put: (path: string, body?: unknown) => request('PUT', path, body ?? {}), + patch: (path: string, body?: unknown) => request('PATCH', path, body ?? {}), delete: (path: string) => request('DELETE', path), }; diff --git a/src/commands/apps.ts b/src/commands/apps.ts index d9797cb..1536420 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -1,7 +1,7 @@ /** The applications of the team, and linking this folder to one. */ import { api, Application } from '../api.js'; import { readLink, removeLink } from '../project.js'; -import { linkFolder, listApps, openBrowser, requireSignedIn, resolveApp } from '../shared.js'; +import { confirmOrStop, linkFolder, listApps, openBrowser, requireSignedIn, resolveApp, table } from '../shared.js'; import { webUrl } from '../config.js'; import { appUrl, color, fail, printJson, prompts } from '../ui.js'; @@ -111,3 +111,100 @@ export async function dashboard(): Promise { const url = webUrl(); if (!openBrowser(url)) process.stdout.write(url + '\n'); } + +export async function startStop(action: 'start' | 'stop', opts: { app?: string; yes?: boolean }): Promise { + const app = await resolveApp(opts.app); + if (action === 'stop') await confirmOrStop(`Stop ${app.name}? It stops answering until started again.`, opts.yes); + const spin = prompts.spinner(); + spin.start(`${action === 'start' ? 'Starting' : 'Stopping'} ${app.name}`); + await api.post(`/applications/${app.uuid}/${action}`); + spin.stop(`${app.name} ${action === 'start' ? 'started' : 'stopped'}.`); +} + +/** Deletes the application, its containers, volumes and images on its server. */ +export async function destroy(opts: { app?: string; yes?: boolean }): Promise { + const app = await resolveApp(opts.app); + if (!opts.yes) { + if (!process.stdin.isTTY) fail(`Deleting ${app.name} needs --yes.`); + const typed = await prompts.text({ message: `Delete ${app.name}, its data and its address? Type its name to confirm` }); + if (prompts.isCancel(typed) || typed !== app.name) return void prompts.cancel('Nothing deleted.'); + } + const r = await api.delete<{ serverCleanup?: string }>(`/applications/${app.uuid}`); + if (readLink()?.applicationUuid === app.uuid) removeLink(); + if (printJson({ removed: app.uuid, serverCleanup: r?.serverCleanup })) return; + prompts.log.success(`${app.name} deleted.${r?.serverCleanup === 'failed' ? color.yellow(' Its server could not be cleaned: it was unreachable.') : ''}`); +} + +export async function deployments(opts: { app?: string }): Promise { + const app = await resolveApp(opts.app); + const list = await api.get(`/applications/${app.uuid}/deployments`); + if (printJson(list)) return; + if (list.length === 0) return void prompts.log.info(`${app.name} was never deployed.`); + const mark = (s: string) => (s === 'finished' ? color.green('✓') : s === 'failed' ? color.red('✗') : color.yellow('…')); + process.stdout.write( + table(list.slice(0, 20).map((d) => [mark(d.status), d.deployment_uuid.slice(0, 8), d.status, d.commit && d.commit !== 'HEAD' ? d.commit.slice(0, 7) : '', color.dim(d.created_at ? new Date(d.created_at).toLocaleString() : '')])) + '\n' + ); +} + +// ── Domains ─────────────────────────────────────────────────────────────────── + +interface DnsGuide { + serverIp: string; + domains: { + host: string; + status: string; + record: { type: string; name: string; value: string; zone: string } | null; + https: { status: string }; + }[]; +} + +function domainsOf(app: Application): string[] { + return (app.fqdn ?? '').split(',').map((d) => d.trim()).filter(Boolean); +} + +const bare = (d: string) => d.replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase(); + +/** Its addresses, with where their DNS and HTTPS stand. */ +export async function domainsList(opts: { app?: string }): Promise { + const app = await resolveApp(opts.app); + const guide = await api.get(`/applications/${app.uuid}/domains/dns`).catch(() => null); + const domains = domainsOf(app); + if (printJson(guide ?? domains)) return; + if (domains.length === 0) return void prompts.log.info(`${app.name} has no address yet.`); + const byHost = new Map((guide?.domains ?? []).map((d) => [d.host, d])); + process.stdout.write( + table( + domains.map((d, i) => { + const check = byHost.get(bare(d)); + const ok = check?.status === 'ok' || check?.status === 'automatic'; + const state = check ? `DNS ${check.status} · HTTPS ${check.https.status}` : ''; + return [i === 0 ? color.green('●') : color.dim('○'), d, ok ? color.green(state) : color.yellow(state)]; + }) + ) + '\n' + ); +} + +export async function domainsAdd(domain: string, opts: { app?: string }): Promise { + const app = await resolveApp(opts.app); + const host = bare(domain); + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(host)) fail(`"${domain}" is not a domain name.`); + const current = domainsOf(app); + if (current.some((d) => bare(d) === host)) return void prompts.log.info(`${host} is already one of its addresses.`); + // Yours first: it becomes the main address. + const updated = await api.patch(`/applications/${app.uuid}`, { fqdn: [`https://${host}`, ...current].join(',') }); + if (printJson(updated)) return; + prompts.log.success(`${host} added to ${app.name}.`); + const guide = await api.get(`/applications/${app.uuid}/domains/dns?domains=${encodeURIComponent(host)}`).catch(() => null); + const record = guide?.domains.find((d) => d.host === host)?.record; + if (record) prompts.note(`${record.type} ${record.name} → ${record.value}\n${color.dim(`in the DNS zone of ${record.zone}`)}`, 'At your domain registrar, create'); + prompts.log.info(`HTTPS is issued once the DNS points here. Check: ${color.cyan('idem domains')}`); +} + +export async function domainsRemove(domain: string, opts: { app?: string }): Promise { + const app = await resolveApp(opts.app); + const host = bare(domain); + const kept = domainsOf(app).filter((d) => bare(d) !== host); + if (kept.length === domainsOf(app).length) fail(`${host} is not an address of ${app.name}.`); + await api.patch(`/applications/${app.uuid}`, { fqdn: kept.join(',') }); + if (!printJson({ removed: host })) prompts.log.success(`${host} removed from ${app.name}.`); +} diff --git a/src/commands/databases.ts b/src/commands/databases.ts new file mode 100644 index 0000000..be5a8af --- /dev/null +++ b/src/commands/databases.ts @@ -0,0 +1,121 @@ +/** + * Databases, caches and queues of a workspace: PostgreSQL, MySQL, MariaDB, + * MongoDB, Redis, KeyDB, Dragonfly, ClickHouse, RabbitMQ. Created next to the + * applications of the workspace, on its network; `idem deploy` links them. + */ +import { api } from '../api.js'; +import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; +import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; +import { workspaceOf } from './workspaces.js'; + +export const ENGINES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', 'rabbitmq'] as const; + +interface Database { + uuid: string; + name: string; + type: string; + status: string | null; + is_public?: boolean; + public_port?: number | null; +} + +interface DatabaseDetail extends Database { + credentials: Record; + internal_host: string; + port: number; + connection_url: string | null; + public_connection_url: string | null; +} + +const listDatabases = () => api.get('/databases'); + +async function databaseOf(wanted?: string): Promise { + requireSignedIn(); + return pick(await listDatabases(), wanted, 'database', (d) => `${d.type}${d.status ? ` · ${d.status}` : ''}`); +} + +function dot(status: string | null): string { + return (status ?? '').startsWith('running') ? color.green('●') : (status ?? '').startsWith('exited') ? color.red('●') : color.dim('○'); +} + +export async function dbList(): Promise { + requireSignedIn(); + const list = await listDatabases(); + if (printJson(list.map((d) => ({ uuid: d.uuid, name: d.name, type: d.type, status: d.status })))) return; + if (list.length === 0) return void prompts.log.info(`No database yet: ${color.cyan('idem db create')}.`); + process.stdout.write(table([[' ', color.dim('NAME'), color.dim('ENGINE'), color.dim('STATUS'), color.dim('ID')], ...list.map((d) => [dot(d.status), d.name, d.type, d.status ?? '—', color.dim(d.uuid.slice(0, 8))])]) + '\n'); +} + +export async function dbCreate(engine: string | undefined, opts: { name?: string; workspace?: string; start?: boolean }): Promise { + requireSignedIn(); + let type = engine?.toLowerCase(); + if (type === 'postgres' || type === 'pg') type = 'postgresql'; + if (!type) { + if (!interactive()) fail(`Which engine? ${ENGINES.join(', ')}`); + type = String(unlessCancelled(await prompts.select({ message: 'Engine', options: ENGINES.map((e) => ({ value: e, label: e })) }))); + } + if (!(ENGINES as readonly string[]).includes(type)) fail(`Unknown engine "${type}".`, `One of: ${ENGINES.join(', ')}`); + const ws = await workspaceOf(opts.workspace); + const name = + opts.name ?? (interactive() ? String(unlessCancelled(await prompts.text({ message: 'Name', initialValue: `${ws.name}-${type}` }))) : `${ws.name}-${type}`); + + const created = await api.post(`/databases/${type}`, { name, workspace_uuid: ws.uuid }); + if (opts.start !== false) { + const spin = interactive() ? prompts.spinner() : null; + spin?.start(`Starting ${name}`); + const r = await api.post<{ success?: boolean }>(`/databases/${type}/${created.uuid}/start`).catch((e: Error) => ({ success: false, error: e.message })); + spin?.stop(r.success === false ? `${name} created, but did not start.` : `${name} is running.`, r.success === false ? 1 : 0); + } + if (printJson(created)) return; + prompts.log.success(`${color.bold(name)} (${type}) in workspace ${ws.name}. Its credentials: ${color.cyan(`idem db show ${name}`)}`); + prompts.log.info(`The applications of ${ws.name} get its address at their next ${color.cyan('idem deploy')} (example values only).`); +} + +export async function dbShow(wanted: string | undefined, opts: { reveal?: boolean }): Promise { + const db = await databaseOf(wanted); + const d = await api.get(`/databases/${db.type}/${db.uuid}`); + const hide = (v: string | null) => (v && !opts.reveal ? v.replace(/:\/\/([^:@/]*):([^@]*)@/, '://$1:••••@') : v); + if (printJson(opts.reveal ? d : { ...d, credentials: undefined, connection_url: hide(d.connection_url), public_connection_url: hide(d.public_connection_url) })) return; + const creds = Object.entries(d.credentials ?? {}).map(([k, v]) => `${color.dim(k.padEnd(24))} ${opts.reveal || !/pass|secret|key/i.test(k) ? v : '••••'}`); + prompts.note( + [ + `${color.dim('Engine'.padEnd(24))} ${d.type} ${dot(d.status)} ${d.status ?? ''}`, + `${color.dim('Inside the workspace'.padEnd(24))} ${d.internal_host}:${d.port}`, + `${color.dim('Connection URL'.padEnd(24))} ${hide(d.connection_url) ?? '—'}`, + ...(d.public_connection_url ? [`${color.dim('From outside'.padEnd(24))} ${hide(d.public_connection_url)}`] : []), + '', + ...creds, + ].join('\n'), + d.name + ); + if (!opts.reveal) prompts.log.info(color.dim('Secrets hidden — --reveal shows them.')); +} + +export async function dbAction(action: 'start' | 'stop' | 'restart', wanted: string | undefined): Promise { + const db = await databaseOf(wanted); + const spin = prompts.spinner(); + spin.start(`${action === 'start' ? 'Starting' : action === 'stop' ? 'Stopping' : 'Restarting'} ${db.name}`); + const r = await api.post<{ success?: boolean; output?: string }>(`/databases/${db.type}/${db.uuid}/${action}`); + if (r.success === false) { + spin.stop(`${db.name}: ${action} failed.`, 1); + fail(r.output?.split('\n').slice(-6).join('\n') || `${action} failed.`); + } + spin.stop(`${db.name}: done.`); +} + +export async function dbBackup(wanted: string | undefined): Promise { + const db = await databaseOf(wanted); + const spin = prompts.spinner(); + spin.start(`Backing up ${db.name}`); + const r = await api.post<{ success: boolean; filename: string; output: string }>(`/databases/${db.type}/${db.uuid}/backup-now`); + spin.stop(r.success ? `Backup made: ${r.filename}` : 'The backup failed.', r.success ? 0 : 1); + if (!r.success) fail(r.output.split('\n').slice(-6).join('\n') || 'Backup failed.'); + printJson(r); +} + +export async function dbRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { + const db = await databaseOf(wanted); + await confirmOrStop(`Delete ${db.name} (${db.type}) and all its data? This cannot be undone.`, opts.yes); + await api.delete(`/databases/${db.type}/${db.uuid}`); + if (!printJson({ removed: db.uuid })) prompts.log.success(`${db.name} deleted.`); +} diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 6dcfb59..d6a6ec5 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,16 +1,27 @@ /** - * `idem deploy` — the one command. Signs in if needed, links the folder to an - * application (creating it the first time), sends the code, follows the - * deployment and ends with the address — or with why it failed. + * `idem deploy` — the one command. Signs in if needed, then deploys: + * + * idem deploy this folder (its code is sent) + * idem deploy --git a Git repository (a branch, a sub-folder) + * idem deploy --image an image already in a registry + * idem deploy --compose a docker-compose.yml, as a service + * idem deploy --template a one-click service of the catalogue + * + * The first time, it creates the application in a workspace — on IDEM's + * infrastructure (`--region`) or on one of your servers (`--server`) — and + * remembers it in `.idem/project.json`. Then it follows the deployment and + * ends with the address, or with why it failed. */ -import { basename, join } from 'node:path'; -import { api, ApiError, Application, Workspace } from '../api.js'; +import { readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { api, ApiError, Application } from '../api.js'; import { token } from '../config.js'; -import { formatBytes, MAX_BYTES, MAX_FILES, packFolder } from '../pack.js'; +import { formatBytes, MAX_BYTES, MAX_FILES, packFolder, Pack } from '../pack.js'; import { readLink, removeLink, writeLink } from '../project.js'; import { appNameFrom, elapsed, followDeployment, listApps, readEnvFile, resolveApp } from '../shared.js'; import { appUrl, color, fail, interactive, isJson, printJson, prompts, unlessCancelled } from '../ui.js'; import { browserLogin } from './auth.js'; +import { listWorkspaces, placement } from './workspaces.js'; export interface DeployOptions { app?: string; @@ -18,204 +29,342 @@ export interface DeployOptions { name?: string; yes?: boolean; detach?: boolean; - env?: boolean; + env?: boolean | string; + upload?: boolean; + // Sources + git?: string; + branch?: string; + image?: string; + tag?: string; + registryUser?: string; + compose?: string; + template?: string; + // Where a new workspace runs + server?: string; + region?: string; + // Build settings of a new application + baseDir?: string; + install?: string; + build?: string; + start?: string; + port?: string; + publishDir?: string; } interface QuickDeployResult { + kind: 'application' | 'service'; deploymentUuid?: string; applicationUuid?: string; + serviceUuid?: string; url?: string | null; workspace: { uuid: string; name: string }; linkedVariables?: { key: string; resource: string }[]; } +type Source = 'folder' | 'git' | 'image' | 'compose' | 'template'; + const NEW_WORKSPACE = '__new__'; +function sourceOf(opts: DeployOptions): Source { + const given = (['git', 'image', 'compose', 'template'] as const).filter((k) => opts[k]); + if (given.length > 1) fail(`Choose one source: --${given.join(', --')}.`); + return given[0] ?? 'folder'; +} + +function link(app: { uuid: string; name: string; workspace_uuid?: string | null; workspace_name?: string | null }): void { + writeLink({ applicationUuid: app.uuid, applicationName: app.name, workspaceUuid: app.workspace_uuid, workspaceName: app.workspace_name }); +} + export async function deploy(opts: DeployOptions): Promise { const started = Date.now(); const cwd = process.cwd(); const ask = interactive() && !opts.yes && !isJson(); + const source = sourceOf(opts); if (!isJson()) prompts.intro(color.bgCyan(color.black(' idem deploy '))); - // 1. Signed in — through the browser, right here, when it is the first time. + // 1. Signed in — through the browser, right here, the first time. if (!token()) { if (!interactive()) fail('You are not signed in.', 'In CI, set IDEM_TOKEN (Security › API tokens on iDeploy).'); await browserLogin(); } - // 2. The code, as Git would see it. + // 2. An existing application: --app, or this folder's link (folder deployments only). + let target: Application | null = null; + if (opts.app) { + target = await resolveApp(opts.app); + if (source === 'folder') link(target); + } else if (source === 'folder') { + target = await linkedOrSameName(cwd, ask); + } + + if (target) return redeploy(target, opts, started); + if (source === 'compose' || source === 'template') return createService(source, opts, ask, started); + return createApplication(source, opts, ask, started); +} + +/** The folder's application — or one of the same name, offered when the folder was deployed from elsewhere. */ +async function linkedOrSameName(cwd: string, ask: boolean): Promise { + const saved = readLink(); + if (saved) { + const app = await api.get(`/applications/${saved.applicationUuid}`).catch((err: unknown) => { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + }); + if (app) return app; + removeLink(); + prompts.log.warn(`"${saved.applicationName}" no longer exists on iDeploy: a new application is created.`); + return null; + } + if (!ask) return null; + const name = appNameFrom(basename(cwd)); + const same = (await listApps()).filter((a) => a.name.toLowerCase() === name); + if (same.length === 0) return null; + const choice = unlessCancelled( + await prompts.select({ + message: `An application "${name}" exists already. Deploy this folder to it?`, + options: [ + ...same.map((a) => ({ value: a.uuid, label: `Yes — ${a.name}`, hint: a.workspace_name ?? undefined })), + { value: '', label: 'No — create a new application' }, + ], + }) + ); + const chosen = same.find((a) => a.uuid === choice) ?? null; + if (chosen) link(chosen); + return chosen; +} + +/** The code of this folder, within the API's limits — or a clear stop. */ +function readProject(cwd: string): Pack { const spin = isJson() ? null : prompts.spinner(); spin?.start('Reading the project'); const pack = packFolder(cwd); if (pack.count === 0) { spin?.stop('Nothing to send.', 1); - fail('This folder has no file to deploy.', 'Run `idem deploy` in your project folder.'); + fail('This folder has no file to deploy.', 'Run `idem deploy` in your project folder, or deploy a source: --git, --image, --compose, --template.'); } if (pack.tooLarge) { spin?.stop('Too large to send.', 1); const heavy = pack.heaviest.map((h) => ` ${h.dir} ${formatBytes(h.bytes)}`).join('\n'); fail( - `The project is ${formatBytes(pack.bytes)} in ${pack.count} files: iDeploy takes up to ${formatBytes(MAX_BYTES)} and ${MAX_FILES} files from the CLI.`, - `The heaviest folders:\n${heavy}\nAdd what the build does not need to .idemignore (same syntax as .gitignore), or deploy from Git on iDeploy.` + `The project is ${formatBytes(pack.bytes)} in ${pack.count} files: the CLI sends up to ${formatBytes(MAX_BYTES)} and ${MAX_FILES} files.`, + `The heaviest folders:\n${heavy}\nAdd what the build does not need to .idemignore (as .gitignore), or deploy from Git: idem deploy --git .` ); } spin?.stop(`Project read: ${pack.count} files, ${formatBytes(pack.bytes)}`); + return pack; +} - // 3. Which application: --app, the folder's link, else a new one. - let target: Application | null = null; - if (opts.app) { - target = await resolveApp(opts.app); - writeLink({ applicationUuid: target.uuid, applicationName: target.name, workspaceUuid: target.workspace_uuid, workspaceName: target.workspace_name }); - } else { - const link = readLink(); - if (link) { - target = await api.get(`/applications/${link.applicationUuid}`).catch((err: unknown) => { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - }); - if (!target) { - removeLink(); - prompts.log.warn(`"${link.applicationName}" no longer exists on iDeploy: a new application is created.`); - } - } else if (ask) { - // A folder deployed before from another machine: offer the application of the same name. - const name = appNameFrom(basename(cwd)); - const same = (await listApps()).filter((a) => a.name.toLowerCase() === name); - if (same.length > 0) { - const choice = unlessCancelled( - await prompts.select({ - message: `An application "${name}" exists already. Deploy this folder to it?`, - options: [ - ...same.map((a) => ({ value: a.uuid, label: `Yes — ${a.name}`, hint: a.workspace_name ?? undefined })), - { value: '', label: 'No — create a new application' }, - ], - }) - ); - if (choice) { - target = same.find((a) => a.uuid === choice)!; - writeLink({ applicationUuid: target.uuid, applicationName: target.name, workspaceUuid: target.workspace_uuid, workspaceName: target.workspace_name }); - } - } - } - } - +/** An existing application: its code is replaced (a folder), or it is rebuilt from its own source. */ +async function redeploy(app: Application, opts: DeployOptions, started: number): Promise { + const fromGit = Boolean(app.git_repository) && app.build_pack !== 'dockerimage'; + const fromImage = app.build_pack === 'dockerimage'; let deploymentUuid: string; - let applicationUuid: string; - let applicationName: string; - let url: string | null = null; - - if (target) { - // 4a. An existing application: its code is replaced, a deployment queued. - spin?.start(`Sending the code to ${color.bold(target.name)}`); - const sent = await api.put<{ deploymentUuid: string | null }>(`/applications/${target.uuid}/source`, { files: pack.files, deploy: true }); + + if ((fromGit || fromImage) && !opts.upload) { + // Its source is a repository or an image: sending this folder would replace it. + const what = fromImage ? `its image${opts.tag ? ` (tag ${opts.tag})` : ''}` : `its repository${app.git_repository ? ` ${color.dim(app.git_repository)}` : ''}`; + if (!isJson()) prompts.log.info(`${color.bold(app.name)} deploys from ${what}. ${color.dim('(--upload sends this folder instead)')}`); + const queued = await api.post<{ deploymentUuid: string }>('/deploy', { uuid: app.uuid, ...(opts.tag ? { image_tag: opts.tag } : {}) }); + deploymentUuid = queued.deploymentUuid; + } else { + const pack = readProject(process.cwd()); + const spin = isJson() ? null : prompts.spinner(); + spin?.start(`Sending the code to ${color.bold(app.name)}`); + const sent = await api.put<{ deploymentUuid: string | null }>(`/applications/${app.uuid}/source`, { files: pack.files, deploy: true }); if (!sent.deploymentUuid) { spin?.stop('Code sent, but no deployment was queued.', 1); fail('iDeploy did not queue a deployment.'); } - spin?.stop(`Code sent to ${color.bold(target.name)}`); + spin?.stop(`Code sent to ${color.bold(app.name)}`); deploymentUuid = sent.deploymentUuid; - applicationUuid = target.uuid; - applicationName = target.name; - url = appUrl(target); - } else { - // 4b. A new application: its name, its workspace, its variables. - let name = appNameFrom(opts.name ?? basename(cwd)); - if (ask && !opts.name) { - name = appNameFrom( - unlessCancelled( - await prompts.text({ - message: 'Name of the application', - initialValue: name, - validate: (v) => (appNameFrom(v) ? undefined : 'Letters, digits and dashes.'), - }) - ) - ); - } + } + await follow(app.uuid, app.name, deploymentUuid, appUrl(app), opts, started); +} - let workspace: { workspace_uuid?: string; workspace_name?: string } = { workspace_name: opts.workspace ?? name }; - if (opts.workspace) { - const found = (await api.get('/workspaces')).find( - (w) => w.uuid === opts.workspace || w.name.toLowerCase() === opts.workspace!.toLowerCase() +/** The workspace of a new resource: --workspace, a choice, or a new one — on IDEM or on your server. */ +async function workspaceFor( + name: string, + opts: DeployOptions, + ask: boolean +): Promise<{ workspace_uuid?: string; workspace_name?: string; deployment_type?: 'saas' | 'own'; server_uuid?: string; region?: string }> { + if (opts.workspace) { + const found = (await listWorkspaces()).find((w) => w.uuid === opts.workspace || w.name.toLowerCase() === opts.workspace!.toLowerCase()); + if (found) return { workspace_uuid: found.uuid }; + return { workspace_name: opts.workspace, ...(await placement(opts)) }; + } + if (ask) { + const workspaces = await listWorkspaces(); + if (workspaces.length > 0) { + const choice = unlessCancelled( + await prompts.select({ + message: 'Workspace (its databases are linked automatically)', + options: [ + { value: NEW_WORKSPACE, label: `New workspace "${name}"` }, + ...workspaces.map((w) => ({ + value: w.uuid, + label: w.name, + hint: w.deploymentType === 'own' ? `your server ${w.assignedServerName ?? ''}`.trim() : 'IDEM', + })), + ], + }) ); - workspace = found ? { workspace_uuid: found.uuid } : { workspace_name: opts.workspace }; - } else if (ask) { - const workspaces = await api.get('/workspaces'); - if (workspaces.length > 0) { - const choice = unlessCancelled( - await prompts.select({ - message: 'Workspace (its databases are linked automatically)', - options: [ - { value: NEW_WORKSPACE, label: `New workspace "${name}"` }, - ...workspaces.map((w) => ({ value: w.uuid, label: w.name })), - ], - }) - ); - workspace = choice === NEW_WORKSPACE ? { workspace_name: name } : { workspace_uuid: String(choice) }; - } + if (choice !== NEW_WORKSPACE) return { workspace_uuid: String(choice) }; } + } + return { workspace_name: name, ...(await placement(opts)) }; +} - // The .env holds this machine's values: sent only when the developer agrees. - let environment_variables: { key: string; value: string }[] = []; - const local = readEnvFile(join(cwd, '.env')); - if (local && local.length > 0) { - let send = Boolean(opts.env); - if (ask && !opts.env) { - send = unlessCancelled( - await prompts.confirm({ - message: `Send the ${local.length} variables of .env? (${local - .slice(0, 4) - .map((v) => v.key) - .join(', ')}${local.length > 4 ? '…' : ''}) — iDeploy replaces local addresses by its own databases`, - initialValue: true, - }) - ); - } - if (send) environment_variables = local; - } +/** The variables a new application starts with: `--env `, or the folder's .env when agreed. */ +async function variablesFor(opts: DeployOptions, ask: boolean): Promise<{ key: string; value: string }[]> { + const file = typeof opts.env === 'string' ? resolve(opts.env) : join(process.cwd(), '.env'); + const local = readEnvFile(file); + if (typeof opts.env === 'string' && !local) fail(`Cannot read ${opts.env}.`); + if (!local || local.length === 0) return []; + if (opts.env) return local; + if (!ask) return []; + const send = unlessCancelled( + await prompts.confirm({ + message: `Send the ${local.length} variables of .env? (${local + .slice(0, 4) + .map((v) => v.key) + .join(', ')}${local.length > 4 ? '…' : ''}) — iDeploy replaces local addresses by its own databases`, + initialValue: true, + }) + ); + return send ? local : []; +} - spin?.start(`Creating ${color.bold(name)} and sending the code`); - const created = await api.post('/quick-deploy', { - name, - ...workspace, - files: pack.files, - environment_variables, - }); - if (!created.applicationUuid || !created.deploymentUuid) { - spin?.stop('Not created.', 1); - fail('iDeploy did not create the application.'); - } - spin?.stop(`Created ${color.bold(name)} in workspace ${color.bold(created.workspace.name)}`); +async function nameFor(opts: DeployOptions, ask: boolean, fallback: string): Promise { + let name = appNameFrom(opts.name ?? fallback); + if (ask && !opts.name) { + name = appNameFrom( + unlessCancelled( + await prompts.text({ message: 'Name', initialValue: name, validate: (v) => (appNameFrom(v) ? undefined : 'Letters, digits and dashes.') }) + ) + ); + } + return name; +} + +async function createApplication(source: Source, opts: DeployOptions, ask: boolean, started: number): Promise { + const cwd = process.cwd(); + const fallback = + source === 'git' + ? basename(opts.git!.replace(/\.git$/, '').replace(/\/+$/, '')) + : source === 'image' + ? basename(opts.image!.split(':')[0]) + : basename(cwd); + const pack = source === 'folder' ? readProject(cwd) : null; + const name = await nameFor(opts, ask, fallback); + const workspace = await workspaceFor(name, opts, ask); + const environment_variables = await variablesFor(opts, ask); + + const [image, imageTag] = opts.image ? splitImage(opts.image) : [undefined, undefined]; + const registry_password = process.env.IDEM_REGISTRY_PASSWORD; + if (opts.registryUser && !registry_password) fail('Give the registry password in IDEM_REGISTRY_PASSWORD (never on the command line).'); + + const spin = isJson() ? null : prompts.spinner(); + spin?.start(`Creating ${color.bold(name)}`); + const created = await api.post('/quick-deploy', { + name, + ...workspace, + environment_variables, + ...(pack ? { files: pack.files } : {}), + ...(source === 'git' ? { git_repository: opts.git, git_branch: opts.branch } : {}), + ...(source === 'image' + ? { build_pack: 'dockerimage', docker_image: image, docker_image_tag: opts.tag ?? imageTag, registry_username: opts.registryUser, registry_password } + : {}), + base_directory: opts.baseDir, + install_command: opts.install, + build_command: opts.build, + start_command: opts.start, + ports_exposes: opts.port, + publish_directory: opts.publishDir, + }); + if (!created.applicationUuid || !created.deploymentUuid) { + spin?.stop('Not created.', 1); + fail('iDeploy did not create the application.'); + } + spin?.stop(`Created ${color.bold(name)} in workspace ${color.bold(created.workspace.name)}`); + if (source === 'folder') { writeLink({ applicationUuid: created.applicationUuid, applicationName: name, workspaceUuid: created.workspace.uuid, workspaceName: created.workspace.name }); - if (created.linkedVariables?.length && !isJson()) { - prompts.log.info( - `Linked to the workspace: ${created.linkedVariables.map((v) => `${color.bold(v.key)} → ${v.resource}`).join(', ')}` - ); - } - deploymentUuid = created.deploymentUuid; - applicationUuid = created.applicationUuid; - applicationName = name; - url = created.url ?? null; } + if (created.linkedVariables?.length && !isJson()) { + prompts.log.info(`Linked to the workspace: ${created.linkedVariables.map((v) => `${color.bold(v.key)} → ${v.resource}`).join(', ')}`); + } + await follow(created.applicationUuid, name, created.deploymentUuid, created.url ?? null, opts, started); +} + +/** A compose file or a catalogue template: a service, started at once. */ +async function createService(source: 'compose' | 'template', opts: DeployOptions, ask: boolean, started: number): Promise { + const fallback = source === 'template' ? opts.template! : basename(process.cwd()); + const name = await nameFor(opts, ask, fallback); + let workspace = await workspaceFor(name, opts, ask); + const environment_variables = await variablesFor(opts, ask); + const spin = isJson() ? null : prompts.spinner(); + + if (source === 'template') { + spin?.start(`Deploying the template ${color.bold(opts.template!)}`); + const created = await api.post('/quick-deploy', { name, template: opts.template, ...workspace, environment_variables }); + spin?.stop(`${color.bold(name)} is starting in workspace ${color.bold(created.workspace.name)}`); + if (!printJson(created)) prompts.outro(`Its address and passwords: ${color.cyan('idem services')} · page on iDeploy: ${color.cyan('idem dashboard')} ${color.dim(`(${elapsed(started)})`)}`); + return; + } + + let compose: string; + try { + compose = readFileSync(resolve(opts.compose!), 'utf8'); + } catch { + fail(`Cannot read ${opts.compose}.`); + } + // A service goes into an existing workspace: a new one is created first. + if (!workspace.workspace_uuid) { + const { workspace_name, ...where } = workspace; + const created = await api.post<{ uuid: string; name: string }>('/workspaces', { name: workspace_name, ...where }); + workspace = { workspace_uuid: created.uuid }; + } + spin?.start(`Creating the service ${color.bold(name)}`); + const service = await api.post<{ uuid: string; name: string }>('/services', { + name, + workspace_uuid: workspace.workspace_uuid, + docker_compose_raw: compose, + environment_variables, + }); + spin?.message(`Starting ${color.bold(name)} (pulling its images)`); + const result = await api.post<{ success?: boolean; output?: string }>(`/services/${service.uuid}/start`); + if (result.success === false) { + spin?.stop(`${name} did not start.`, 1); + if (result.output && !isJson()) process.stdout.write(color.dim(result.output.split('\n').slice(-20).join('\n')) + '\n'); + fail('The service failed to start — the end of its output is above.', `Fix the compose file, then: idem services restart ${name}`); + } + spin?.stop(`${color.bold(name)} is running.`); + if (!printJson({ serviceUuid: service.uuid, name })) prompts.outro(`${color.green('Running')} ${color.dim(`(${elapsed(started)})`)} · ${color.cyan('idem services')}`); +} +/** `registry/org/app:v1` → image and tag (a port in the registry host is not a tag). */ +function splitImage(ref: string): [string, string | undefined] { + const colon = ref.lastIndexOf(':'); + return colon > ref.lastIndexOf('/') ? [ref.slice(0, colon), ref.slice(colon + 1)] : [ref, undefined]; +} + +/** Followed to its end, live — or handed back at once with --detach. */ +async function follow(applicationUuid: string, name: string, deploymentUuid: string, url: string | null, opts: DeployOptions, started: number): Promise { if (opts.detach) { if (!printJson({ deploymentUuid, applicationUuid, url })) { prompts.outro(`Deployment queued. Follow it with ${color.cyan('idem status')}${url ? ` · ${color.cyan(url)}` : ''}`); } return; } - - // 5. Followed to its end, live. - if (!isJson()) prompts.log.step(`Deploying ${color.bold(applicationName)} ${color.dim(`(${deploymentUuid.slice(0, 8)})`)}`); + if (!isJson()) prompts.log.step(`Deploying ${color.bold(name)} ${color.dim(`(${deploymentUuid.slice(0, 8)})`)}`); const final = await followDeployment(deploymentUuid, { quiet: isJson() }); - url = final.application_url ?? url; - + const address = final.application_url ?? url; if (final.status === 'finished') { - if (!printJson({ status: 'finished', deploymentUuid, applicationUuid, url })) { - prompts.outro(`${color.green('Live')}${url ? ` at ${color.cyan(color.bold(url))}` : ''} ${color.dim(`(${elapsed(started)})`)}`); + if (!printJson({ status: 'finished', deploymentUuid, applicationUuid, url: address })) { + prompts.outro(`${color.green('Live')}${address ? ` at ${color.cyan(color.bold(address))}` : ''} ${color.dim(`(${elapsed(started)})`)}`); } return; } - if (printJson({ status: final.status, deploymentUuid, applicationUuid, url })) process.exit(1); + if (printJson({ status: final.status, deploymentUuid, applicationUuid, url: address })) process.exit(1); fail( final.status === 'cancelled-by-user' ? 'The deployment was cancelled.' : 'The deployment failed — the cause is in the log above.', `The previous version, if any, is still the one serving. Logs: ${color.cyan('idem logs')} · page: ${color.cyan('idem open --dashboard')}` diff --git a/src/commands/menu.ts b/src/commands/menu.ts index 64d4434..dc66a6b 100644 --- a/src/commands/menu.ts +++ b/src/commands/menu.ts @@ -12,8 +12,12 @@ import { apps, dashboard, link, open, restart, status } from './apps.js'; import { logs } from './logs.js'; import { rollback } from './rollback.js'; import { envList } from './env.js'; +import { domainsList } from './apps.js'; +import { dbList } from './databases.js'; +import { workspacesList } from './workspaces.js'; +import { serversList } from './servers.js'; -type Action = 'deploy' | 'logs' | 'status' | 'open' | 'restart' | 'rollback' | 'env' | 'apps' | 'link' | 'dashboard' | 'logout' | 'quit'; +type Action = 'deploy' | 'logs' | 'status' | 'open' | 'restart' | 'rollback' | 'env' | 'domains' | 'apps' | 'link' | 'db' | 'workspaces' | 'servers' | 'dashboard' | 'logout' | 'quit'; export async function menu(): Promise { prompts.intro(color.bgCyan(color.black(' idem '))); @@ -29,11 +33,15 @@ export async function menu(): Promise { { value: 'status', label: 'Status', hint: 'idem status' }, { value: 'open', label: 'Open in the browser', hint: 'idem open' }, { value: 'env', label: 'Variables', hint: 'idem env' }, + { value: 'domains', label: 'Domains', hint: 'idem domains' }, { value: 'restart', label: 'Restart', hint: 'idem restart' }, { value: 'rollback', label: 'Back to an earlier version', hint: 'idem rollback' }, ] as { value: Action; label: string; hint?: string }[]) : [{ value: 'link' as Action, label: 'Link this folder to an existing application', hint: 'idem link' }]), { value: 'apps', label: 'All my applications', hint: 'idem apps' }, + { value: 'db', label: 'Databases, caches, queues', hint: 'idem db' }, + { value: 'workspaces', label: 'Workspaces', hint: 'idem workspaces' }, + { value: 'servers', label: 'My servers', hint: 'idem servers' }, { value: 'dashboard', label: 'Open iDeploy', hint: 'idem dashboard' }, { value: 'logout', label: 'Sign out', hint: 'idem logout' }, { value: 'quit', label: 'Quit' }, @@ -59,6 +67,14 @@ export async function menu(): Promise { return rollback({}); case 'apps': return apps(); + case 'domains': + return domainsList({}); + case 'db': + return dbList(); + case 'workspaces': + return workspacesList(); + case 'servers': + return serversList(); case 'link': return link({}); case 'dashboard': diff --git a/src/commands/servers.ts b/src/commands/servers.ts new file mode 100644 index 0000000..817e2b6 --- /dev/null +++ b/src/commands/servers.ts @@ -0,0 +1,151 @@ +/** + * Your own servers: add one (SSH key generated or yours), check it, install + * Docker and the proxy on it, see what runs there. IDEM's managed servers are + * not listed: a workspace on IDEM's infrastructure is placed on one for you. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { api } from '../api.js'; +import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; +import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; + +export interface Server { + id: number; + uuid: string; + name: string; + ip: string; + port?: number; + user?: string; + description?: string | null; +} + +interface Key { + id: number; + uuid: string; + name: string; + public_key?: string; +} + +interface Readiness { + ready: boolean; + checks: { id: string; label: string; status: string; detail?: string; remedy?: string }[]; +} + +export const listServers = () => api.get('/servers'); + +export async function serverOf(wanted?: string): Promise { + requireSignedIn(); + return pick(await listServers(), wanted, 'server', (s) => s.ip); +} + +export async function serversList(): Promise { + requireSignedIn(); + const servers = await listServers(); + if (printJson(servers.map((s) => ({ uuid: s.uuid, name: s.name, ip: s.ip })))) return; + if (servers.length === 0) return void prompts.log.info(`No server of your own. Add one: ${color.cyan('idem servers add')} — or deploy on IDEM's infrastructure, nothing to add.`); + process.stdout.write(table([[color.dim('NAME'), color.dim('ADDRESS'), color.dim('ID')], ...servers.map((s) => [s.name, `${s.user ?? 'root'}@${s.ip}:${s.port ?? 22}`, color.dim(s.uuid.slice(0, 8))])]) + '\n'); +} + +function printReadiness(r: Readiness): void { + for (const c of r.checks) { + const mark = c.status === 'ok' ? color.green('✓') : c.status === 'warning' ? color.yellow('⚠') : c.status === 'skipped' ? color.dim('–') : color.red('✗'); + process.stdout.write(`${mark} ${c.label}${c.detail ? color.dim(` — ${c.detail}`) : ''}\n`); + if (c.remedy && c.status !== 'ok') process.stdout.write(` ${color.dim(c.remedy)}\n`); + } +} + +/** Add a server: name, address, SSH user, and a key — a new one to install, or one of yours. */ +export async function serversAdd(opts: { name?: string; ip?: string; port?: string; user?: string; key?: string; setup?: boolean }): Promise { + requireSignedIn(); + const ask = interactive(); + const ip = opts.ip ?? (ask ? unlessCancelled(await prompts.text({ message: 'Server address (IP or host name)', validate: (v) => (v.trim() ? undefined : 'Required.') })) : undefined); + if (!ip) fail('Give the address: --ip
.'); + const name = opts.name ?? (ask ? unlessCancelled(await prompts.text({ message: 'Name', initialValue: ip.replace(/[^a-z0-9-]+/gi, '-') })) : ip.replace(/[^a-z0-9-]+/gi, '-')); + const user = opts.user ?? 'root'; + const port = Number(opts.port ?? 22); + + // The key: a file of yours, else a new pair whose public half goes on the server. + let keyId: number; + let installHint: string | null = null; + let keyPath = opts.key; + if (!keyPath && ask) { + const choice = unlessCancelled( + await prompts.select({ + message: 'SSH key iDeploy uses to reach it', + options: [ + { value: 'generate', label: 'Generate a new key', hint: 'you add its public half to the server' }, + ...['id_ed25519', 'id_rsa'].filter((f) => existsSync(join(homedir(), '.ssh', f))).map((f) => ({ value: join(homedir(), '.ssh', f), label: `Use ~/.ssh/${f}` })), + { value: 'path', label: 'Another private key file' }, + ], + }) + ); + keyPath = choice === 'generate' ? undefined : choice === 'path' ? unlessCancelled(await prompts.text({ message: 'Path to the private key' })) : String(choice); + } + if (keyPath) { + let pem: string; + try { + pem = readFileSync(keyPath.replace(/^~/, homedir()), 'utf8'); + } catch { + fail(`Cannot read ${keyPath}.`); + } + if (/ENCRYPTED/.test(pem)) fail('This key has a passphrase: iDeploy needs a key without one.', 'Generate one instead (leave --key out).'); + const key = await api.post('/security/keys', { name: `${name} (${keyPath.split('/').pop()})`, private_key: pem }); + keyId = key.id; + } else { + const key = await api.post('/security/keys/generate', { name: `${name} key` }); + keyId = key.id; + installHint = key.public_key ?? null; + } + + const server = await api.post('/servers', { name, ip, port, user, private_key_id: keyId }); + if (printJson({ uuid: server.uuid, name: server.name, publicKey: installHint })) return; + prompts.log.success(`Server ${color.bold(server.name)} added.`); + if (installHint) { + prompts.note( + `${color.dim(`On ${ip}, as ${user}, run:`)}\n\nmkdir -p ~/.ssh && echo '${installHint.trim()}' >> ~/.ssh/authorized_keys`, + 'Install the public key' + ); + if (ask) unlessCancelled(await prompts.confirm({ message: 'Done? (iDeploy checks the server next)', initialValue: true })); + } + await serversCheck(server.uuid, { setup: opts.setup ?? true }); +} + +/** Can iDeploy deploy there? If not, offers to install what is missing (Docker, the proxy). */ +export async function serversCheck(wanted: string | undefined, opts: { setup?: boolean } = {}): Promise { + const server = await serverOf(wanted); + const spin = prompts.spinner(); + spin.start(`Checking ${server.name}`); + const r = await api.post(`/servers/${server.uuid}/validate`); + spin.stop(r.ready ? `${server.name} is ready to deploy.` : `${server.name} is not ready yet.`, r.ready ? 0 : 1); + if (printJson(r)) return; + printReadiness(r); + if (r.ready) return; + const setup = opts.setup && (!interactive() || unlessCancelled(await prompts.confirm({ message: 'Install Docker and the proxy on it now? (a few minutes)', initialValue: true }))); + if (setup) await serversSetup(server.uuid); + else prompts.log.info(`When it is reachable: ${color.cyan(`idem servers setup ${server.name}`)}`); +} + +export async function serversSetup(wanted?: string): Promise { + const server = await serverOf(wanted); + const spin = prompts.spinner(); + spin.start(`Installing Docker and the proxy on ${server.name} (a few minutes)`); + try { + const r = await api.post<{ success: boolean; output?: string; readiness?: Readiness }>(`/servers/${server.uuid}/setup`); + spin.stop(r.success ? `${server.name} is set up.` : `The setup of ${server.name} failed.`, r.success ? 0 : 1); + if (!r.success) { + if (r.output) process.stdout.write(color.dim(r.output.split('\n').slice(-25).join('\n')) + '\n'); + fail('Setup failed — the end of its output is above.'); + } + } catch (err) { + spin.stop('No answer yet.', 1); + fail(`${(err as Error).message}`, `A long install can outlast the request: check it with ${color.cyan(`idem servers check ${server.name}`)}.`); + } +} + +export async function serversRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { + const server = await serverOf(wanted); + await confirmOrStop(`Remove ${server.name} from iDeploy? What runs on it keeps running.`, opts.yes); + await api.delete(`/servers/${server.uuid}`); + if (!printJson({ removed: server.uuid })) prompts.log.success(`${server.name} removed.`); +} diff --git a/src/commands/services.ts b/src/commands/services.ts new file mode 100644 index 0000000..39216cc --- /dev/null +++ b/src/commands/services.ts @@ -0,0 +1,66 @@ +/** + * Services: Docker Compose stacks — a one-click template from the catalogue + * (n8n, Plausible, Metabase…) or your own docker-compose.yml. + */ +import { api } from '../api.js'; +import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; +import { color, fail, printJson, prompts } from '../ui.js'; + +interface Service { + uuid: string; + name: string; + service_type?: string | null; + status?: string | null; +} + +interface Template { + name: string; + slogan: string; + category: string; + tags: string[]; +} + +const listServices = () => api.get('/services'); + +async function serviceOf(wanted?: string): Promise { + requireSignedIn(); + return pick(await listServices(), wanted, 'service', (s) => s.service_type ?? undefined); +} + +export async function servicesList(): Promise { + requireSignedIn(); + const list = await listServices(); + if (printJson(list)) return; + if (list.length === 0) return void prompts.log.info(`No service yet. From the catalogue: ${color.cyan('idem templates')}, then ${color.cyan('idem deploy --template ')}.`); + process.stdout.write(table([[color.dim('NAME'), color.dim('FROM'), color.dim('ID')], ...list.map((s) => [s.name, s.service_type ?? 'docker-compose.yml', color.dim(s.uuid.slice(0, 8))])]) + '\n'); +} + +export async function servicesAction(action: 'start' | 'stop' | 'restart', wanted?: string): Promise { + const svc = await serviceOf(wanted); + const spin = prompts.spinner(); + spin.start(`${action === 'start' ? 'Starting' : action === 'stop' ? 'Stopping' : 'Restarting'} ${svc.name}`); + const r = await api.post<{ success?: boolean; output?: string }>(`/services/${svc.uuid}/${action}`); + if (r.success === false) { + spin.stop(`${svc.name}: ${action} failed.`, 1); + fail(r.output?.split('\n').slice(-8).join('\n') || `${action} failed.`); + } + spin.stop(`${svc.name}: done.`); +} + +export async function servicesRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { + const svc = await serviceOf(wanted); + await confirmOrStop(`Delete ${svc.name}, its containers and its volumes?`, opts.yes); + await api.delete(`/services/${svc.uuid}`); + if (!printJson({ removed: svc.uuid })) prompts.log.success(`${svc.name} deleted.`); +} + +export async function templatesList(search: string | undefined): Promise { + requireSignedIn(); + const all = await api.get('/services/templates'); + const q = search?.toLowerCase(); + const list = q ? all.filter((t) => `${t.name} ${t.slogan} ${t.category} ${t.tags.join(' ')}`.toLowerCase().includes(q)) : all; + if (printJson(list.map((t) => ({ name: t.name, category: t.category, slogan: t.slogan })))) return; + if (list.length === 0) return void prompts.log.info(`No template matches "${search}".`); + process.stdout.write(table(list.map((t) => [color.bold(t.name), color.dim(t.category), t.slogan.slice(0, 70)])) + '\n'); + prompts.log.info(`${list.length} templates. Deploy one: ${color.cyan('idem deploy --template ')}`); +} diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts new file mode 100644 index 0000000..650ed45 --- /dev/null +++ b/src/commands/workspaces.ts @@ -0,0 +1,122 @@ +/** + * Workspaces: where things run together. On IDEM's infrastructure (a managed + * server is chosen for you, in a region when your plan allows), or on one of + * your servers. Everything in a workspace shares one private network: the + * databases are reached by name, and linked to the applications. + */ +import { api } from '../api.js'; +import { confirmOrStop, pick, requireSignedIn, table } from '../shared.js'; +import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; +import { listServers, Server } from './servers.js'; + +export interface Workspace { + uuid: string; + name: string; + description?: string | null; + deploymentType?: 'saas' | 'own'; + region?: string | null; + assignedServerName?: string | null; +} + +interface Options { + regionSelectionAllowed: boolean; + availableRegions: { code: string; name?: string }[] | string[]; +} + +interface Resource { + kind: 'application' | 'database' | 'service'; + uuid: string; + name: string; + status: string | null; + databaseType: string | null; + /** How its neighbours reach it on the workspace's network. */ + internalHost: string; +} + +export const listWorkspaces = () => api.get('/workspaces'); + +export async function workspaceOf(wanted?: string): Promise { + requireSignedIn(); + return pick(await listWorkspaces(), wanted, 'workspace', where); +} + +function where(w: Workspace): string { + return w.deploymentType === 'own' ? `your server ${w.assignedServerName ?? ''}`.trim() : `IDEM${w.region ? ` · ${w.region}` : ''}`; +} + +export async function workspacesList(): Promise { + requireSignedIn(); + const list = await listWorkspaces(); + if (printJson(list)) return; + if (list.length === 0) return void prompts.log.info(`No workspace yet. ${color.cyan('idem deploy')} creates one, or ${color.cyan('idem workspaces create')}.`); + process.stdout.write(table([[color.dim('NAME'), color.dim('RUNS ON'), color.dim('ID')], ...list.map((w) => [w.name, where(w), color.dim(w.uuid.slice(0, 8))])]) + '\n'); +} + +/** + * Where a new workspace runs — asked on a terminal, from flags otherwise: + * `--server ` (yours) or `--region ` (IDEM's). + */ +export async function placement(opts: { server?: string; region?: string }): Promise<{ deployment_type: 'saas' | 'own'; server_uuid?: string; region?: string }> { + if (opts.server) { + const server = await pick(await listServers(), opts.server, 'server', (s) => s.ip); + return { deployment_type: 'own', server_uuid: server.uuid }; + } + if (opts.region || !interactive()) return { deployment_type: 'saas', ...(opts.region ? { region: opts.region } : {}) }; + + const servers: Server[] = await listServers().catch(() => []); + const choice = unlessCancelled( + await prompts.select({ + message: 'Where should it run?', + options: [ + { value: 'saas', label: "IDEM's infrastructure", hint: 'nothing to manage' }, + ...servers.map((s) => ({ value: s.uuid, label: `Your server ${s.name}`, hint: s.ip })), + ...(servers.length === 0 ? [{ value: 'add', label: 'One of your servers…', hint: 'add it first: idem servers add' }] : []), + ], + }) + ); + if (choice === 'add') fail('Add your server first.', `${color.cyan('idem servers add')}, then run this again.`); + if (choice !== 'saas') return { deployment_type: 'own', server_uuid: String(choice) }; + + const options = await api.get('/workspaces/options').catch(() => null); + const regions = (options?.availableRegions ?? []).map((r) => (typeof r === 'string' ? { code: r } : r)); + if (!options?.regionSelectionAllowed || regions.length < 2) return { deployment_type: 'saas' }; + const region = unlessCancelled( + await prompts.select({ + message: 'Region', + options: [{ value: '', label: 'Automatic', hint: 'the nearest with room' }, ...regions.map((r) => ({ value: r.code, label: r.name ?? r.code }))], + }) + ); + return { deployment_type: 'saas', ...(region ? { region: String(region) } : {}) }; +} + +export async function workspacesCreate(name: string | undefined, opts: { server?: string; region?: string; description?: string }): Promise { + requireSignedIn(); + const wsName = name ?? (interactive() ? unlessCancelled(await prompts.text({ message: 'Workspace name', validate: (v) => (v.trim() ? undefined : 'Required.') })) : undefined); + if (!wsName) fail('Give a name: idem workspaces create .'); + const where_ = await placement(opts); + const created = await api.post('/workspaces', { name: wsName, description: opts.description, ...where_ }); + if (!printJson(created)) prompts.log.success(`Workspace ${color.bold(created.name)} created (${where(created)}).`); +} + +export async function workspacesShow(wanted?: string): Promise { + const ws = await workspaceOf(wanted); + const resources = await api.get(`/workspaces/${ws.uuid}/resources`).catch(() => [] as Resource[]); + if (printJson({ ...ws, resources })) return; + prompts.note( + [ + `${color.dim('Runs on')} ${where(ws)}`, + '', + ...(resources.length + ? resources.map((r) => `${r.name} ${color.dim(`${r.databaseType ?? r.kind}${r.status ? ` · ${r.status}` : ''} · ${r.internalHost}`)}`) + : [color.dim('Empty.')]), + ].join('\n'), + ws.name + ); +} + +export async function workspacesRemove(wanted: string | undefined, opts: { yes?: boolean }): Promise { + const ws = await workspaceOf(wanted); + await confirmOrStop(`Delete workspace ${ws.name}? It must be empty.`, opts.yes); + await api.delete(`/workspaces/${ws.uuid}`); + if (!printJson({ removed: ws.uuid })) prompts.log.success(`${ws.name} deleted.`); +} diff --git a/src/index.ts b/src/index.ts index ae5c498..cc9981e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,11 @@ import { createRequire } from 'node:module'; import { run, setJson, interactive } from './ui.js'; import { login, logout, whoami } from './commands/auth.js'; import { deploy } from './commands/deploy.js'; -import { apps, dashboard, link, open, restart, status, unlink } from './commands/apps.js'; +import { apps, dashboard, deployments, destroy, domainsAdd, domainsList, domainsRemove, link, open, restart, startStop, status, unlink } from './commands/apps.js'; +import { serversAdd, serversCheck, serversList, serversRemove, serversSetup } from './commands/servers.js'; +import { workspacesCreate, workspacesList, workspacesRemove, workspacesShow } from './commands/workspaces.js'; +import { dbAction, dbBackup, dbCreate, dbList, dbRemove, dbShow, ENGINES } from './commands/databases.js'; +import { servicesAction, servicesList, servicesRemove, templatesList } from './commands/services.js'; import { logs } from './commands/logs.js'; import { rollback } from './commands/rollback.js'; import { envList, envPull, envRemove, envSet } from './commands/env.js'; @@ -33,12 +37,28 @@ program program .command('deploy') - .description('Deploy this folder — creates the application the first time') + .description('Deploy — this folder by default; creates the application the first time') .option('-a, --app ', 'deploy to this application (name or id)') - .option('-w, --workspace ', 'workspace of a new application') + .option('--git ', 'deploy a Git repository instead of this folder') + .option('--branch ', 'its branch (default: the default branch)') + .option('--image ', 'run an image from a registry (registry/org/app:tag)') + .option('--tag ', 'the image tag to deploy') + .option('--registry-user ', 'private registry login (password in IDEM_REGISTRY_PASSWORD)') + .option('--compose ', 'deploy a docker-compose.yml as a service') + .option('--template ', 'deploy a service of the catalogue (see idem templates)') + .option('-w, --workspace ', 'workspace of a new application (created if needed)') + .option('--server ', 'a new workspace runs on this server of yours') + .option('--region ', "a new workspace runs on IDEM's infrastructure in this region") .option('-n, --name ', 'name of a new application (default: the folder name)') + .option('--base-dir ', 'the application folder inside a monorepo') + .option('--install ', 'install command') + .option('--build ', 'build command') + .option('--start ', 'start command') + .option('--port ', 'the port the application listens on') + .option('--publish-dir ', 'the built folder of a static site') + .option('--env [file]', 'send the variables of .env (or of this file) with a new application') + .option('--upload', 'send this folder even to an application that deploys from Git or an image') .option('-y, --yes', 'ask nothing: defaults everywhere (CI)') - .option('--env', 'send the variables of .env with a new application') .option('-d, --detach', 'queue the deployment and return without following it') .action(run(deploy)); @@ -59,6 +79,10 @@ program .action(run(logs)); program.command('open').description('Open the application in the browser').option('-a, --app ').option('--dashboard', 'open its page on iDeploy instead').action(run(open)); program.command('dashboard').description('Open iDeploy in the browser').action(run(dashboard)); +program.command('start').description('Start a stopped application').option('-a, --app ').action(run((o: { app?: string }) => startStop('start', o))); +program.command('stop').description('Stop the application').option('-a, --app ').option('-y, --yes').action(run((o: { app?: string; yes?: boolean }) => startStop('stop', o))); +program.command('destroy').description('Delete the application, its data and its address').option('-a, --app ').option('-y, --yes').action(run(destroy)); +program.command('deployments').description('Deployment history').option('-a, --app ').action(run(deployments)); program.command('restart').description('Restart the application').option('-a, --app ').option('-y, --yes', 'do not ask').action(run(restart)); program .command('rollback') @@ -75,4 +99,56 @@ env.command('set').description('Set KEY=value…').argument('').option env.command('rm').description('Remove variables').argument('').option('-a, --app ').action(run(envRemove)); env.command('pull').description('Write them to a local .env').argument('[file]').option('-a, --app ').option('-y, --yes', 'replace an existing file').action(run(envPull)); +const domains = program.command('domains').description('Addresses of the application'); +domains.command('ls', { isDefault: true }).description('Its addresses, with DNS and HTTPS state').option('-a, --app ').action(run(domainsList)); +domains.command('add').description('Add your domain (it becomes the main address)').argument('').option('-a, --app ').action(run(domainsAdd)); +domains.command('rm').description('Remove an address').argument('').option('-a, --app ').action(run(domainsRemove)); + +const servers = program.command('servers').description('Your own servers (IDEM-managed ones need nothing)'); +servers.command('ls', { isDefault: true }).description('List them').action(run(serversList)); +servers + .command('add') + .description('Add a server: SSH key, checks, Docker and proxy installed') + .option('--name ') + .option('--ip
') + .option('--port ', 'SSH port', '22') + .option('--user ', 'SSH user', 'root') + .option('--key ', 'a private key of yours (default: a new key is generated)') + .option('--no-setup', 'do not install Docker and the proxy') + .action(run(serversAdd)); +servers.command('check').description('Can iDeploy deploy there?').argument('[server]').action(run((s?: string) => serversCheck(s, { setup: true }))); +servers.command('setup').description('Install Docker and the proxy').argument('[server]').action(run(serversSetup)); +servers.command('rm').description('Remove a server from iDeploy').argument('[server]').option('-y, --yes').action(run(serversRemove)); + +const workspaces = program.command('workspaces').alias('ws').description('Workspaces: where applications and databases run together'); +workspaces.command('ls', { isDefault: true }).description('List them').action(run(workspacesList)); +workspaces + .command('create') + .description("Create one — on IDEM's infrastructure or on your server") + .argument('[name]') + .option('--server ', 'on this server of yours') + .option('--region ', "on IDEM's infrastructure, in this region") + .option('--description ') + .action(run(workspacesCreate)); +workspaces.command('show').description('What runs in it, and how its parts reach each other').argument('[workspace]').action(run(workspacesShow)); +workspaces.command('rm').description('Delete an empty workspace').argument('[workspace]').option('-y, --yes').action(run(workspacesRemove)); + +const db = program.command('db').description(`Databases, caches and queues (${ENGINES.join(', ')})`); +db.command('ls', { isDefault: true }).description('List them').action(run(dbList)); +db.command('create').description('Create one in a workspace, and start it').argument('[engine]').option('--name ').option('-w, --workspace ').option('--no-start', 'only create it').action(run(dbCreate)); +db.command('show').description('Address and credentials').argument('[database]').option('--reveal', 'show the secrets').action(run(dbShow)); +for (const action of ['start', 'stop', 'restart'] as const) { + db.command(action).description(`${action[0].toUpperCase()}${action.slice(1)} it`).argument('[database]').action(run((d?: string) => dbAction(action, d))); +} +db.command('backup').description('Back it up now').argument('[database]').action(run(dbBackup)); +db.command('rm').description('Delete it and its data').argument('[database]').option('-y, --yes').action(run(dbRemove)); + +const services = program.command('services').description('Services: docker-compose stacks and catalogue templates'); +services.command('ls', { isDefault: true }).description('List them').action(run(servicesList)); +for (const action of ['start', 'stop', 'restart'] as const) { + services.command(action).description(`${action[0].toUpperCase()}${action.slice(1)} it`).argument('[service]').action(run((s?: string) => servicesAction(action, s))); +} +services.command('rm').description('Delete it, its containers and volumes').argument('[service]').option('-y, --yes').action(run(servicesRemove)); +program.command('templates').description('The catalogue of one-click services').argument('[search]').action(run(templatesList)); + program.parseAsync(process.argv); diff --git a/src/shared.ts b/src/shared.ts index 0878705..50da314 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -186,3 +186,50 @@ export function elapsed(since: number): string { const s = Math.round((Date.now() - since) / 1000); return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')}`; } + +/** + * One item of a list, named on the command line (name, id, or the start of + * the id) — else chosen from the list on a terminal. + */ +export async function pick( + items: T[], + wanted: string | undefined, + what: string, + hint?: (item: T) => string | undefined +): Promise { + if (wanted) { + const w = wanted.toLowerCase(); + const found = items.filter((i) => i.uuid === wanted || i.name.toLowerCase() === w || (w.length >= 6 && i.uuid.startsWith(w))); + if (found.length === 1) return found[0]; + if (found.length > 1) fail(`Several ${what}s match "${wanted}".`, 'Use its id.'); + fail(`No ${what} "${wanted}".`); + } + if (items.length === 0) fail(`No ${what} yet.`); + if (items.length === 1) return items[0]; + if (!interactive()) fail(`Which ${what}?`, `Give its name or id.`); + const uuid = unlessCancelled( + await prompts.select({ + message: `Which ${what}?`, + options: items.map((i) => ({ value: i.uuid, label: i.name, hint: hint?.(i) })), + }) + ); + return items.find((i) => i.uuid === uuid)!; +} + +/** Yes/no before something that cannot be undone; `--yes` answers for scripts. */ +export async function confirmOrStop(message: string, yes?: boolean): Promise { + if (yes) return; + if (!interactive()) fail(`${message} — pass --yes to confirm.`); + const ok = unlessCancelled(await prompts.confirm({ message, initialValue: false })); + if (!ok) { + prompts.cancel('Nothing changed.'); + process.exit(0); + } +} + +/** Rows of aligned columns. */ +export function table(rows: string[][]): string { + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + const widths = rows[0]?.map((_, i) => Math.min(Math.max(...rows.map((r) => strip(r[i] ?? '').length)), 48)) ?? []; + return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + ' '.repeat(Math.max(widths[i] - strip(cell).length, 0)))).join(' ')).join('\n'); +}