mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.
- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
(ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
(base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
--maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast
Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2f8f595d6e
commit
e995c98d05
19 files changed
+1565
-42
No files matched your search
@@ -36,6 +36,13 @@ import {
|
||||
getServiceRuntime,
|
||||
type CronJobEntity,
|
||||
type ServiceVolume,
|
||||
type ServiceDomain,
|
||||
type DomainCheckResult,
|
||||
listServiceDomains,
|
||||
addServiceDomain,
|
||||
removeServiceDomain,
|
||||
setDefaultServiceDomain,
|
||||
checkServiceDomains,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
@@ -438,6 +445,23 @@ export function ServiceDetailPage() {
|
||||
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||
} | null>(null);
|
||||
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: ['service-domains', serviceId],
|
||||
queryFn: () => listServiceDomains(serviceId),
|
||||
enabled: Boolean(serviceId) && !isDemoMode,
|
||||
});
|
||||
|
||||
const domainMutation = useMutation({
|
||||
mutationFn: (fn: () => Promise<unknown>) => fn(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
|
||||
invalidateService();
|
||||
},
|
||||
});
|
||||
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
@@ -2194,6 +2218,174 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={domainMutation.isPending}
|
||||
onClick={async () => {
|
||||
const results = await checkServiceDomains(serviceId);
|
||||
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Check DNS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-3">
|
||||
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
|
||||
)}
|
||||
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
|
||||
const check = domainChecks?.[d.domain];
|
||||
return (
|
||||
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
|
||||
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
|
||||
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
|
||||
{check && (
|
||||
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
|
||||
{check.status}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{!d.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
set default
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
|
||||
className="text-[var(--error)]"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={domainInput}
|
||||
onChange={(e) => setDomainInput(e.target.value)}
|
||||
placeholder="app.example.com"
|
||||
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={domainMutation.isPending || !domainInput.trim()}
|
||||
onClick={() =>
|
||||
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
|
||||
onSuccess: () => setDomainInput(''),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{domainMutation.isError && (
|
||||
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
|
||||
)}
|
||||
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
|
||||
{accessForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessForm === null ? (
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accessForm.maintenance}
|
||||
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
|
||||
/>
|
||||
Maintenance mode — redirect all traffic to the maintenance page
|
||||
</label>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
|
||||
<textarea
|
||||
value={accessForm.basicAuth}
|
||||
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="admin:s3cret"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() => {
|
||||
const creds = accessForm.basicAuth
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
const i = l.indexOf(':');
|
||||
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
|
||||
})
|
||||
.filter((x): x is { username: string; password: string } => x !== null);
|
||||
updateServiceMutation.mutate(
|
||||
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
|
||||
{ onSuccess: () => setAccessForm(null) },
|
||||
);
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccessForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -971,6 +971,245 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/domains": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List service domains
|
||||
* @description All hostnames attached to the service; the default is mirrored to services.domain
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Domain list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
domains?: components["schemas"]["ServiceDomain"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Attach a domain
|
||||
* @description Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
domain: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Domain attached */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
domain?: components["schemas"]["ServiceDomain"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Domain already attached */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/domains/check": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* DNS preflight
|
||||
* @description Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Per-domain DNS status */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
domains?: components["schemas"]["DomainCheckResult"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/domains/{domain_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Detach a domain */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
domain_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Domain removed */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/domains/{domain_id}/default": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Set the default domain
|
||||
* @description Marks the domain default and mirrors it to services.domain
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
domain_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Updated domain list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
domains?: components["schemas"]["ServiceDomain"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/maintenance": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Maintenance page
|
||||
* @description Public HTML page Traefik redirects to while a service is in maintenance mode
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: never;
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/deployments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7805,6 +8044,12 @@ export interface components {
|
||||
restart_policy?: string;
|
||||
/** @description Volume/bind mounts applied to every replica */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
/** @description All attached hostnames; `domain` is the derived default */
|
||||
domains?: components["schemas"]["ServiceDomain"][];
|
||||
/** @description Redirect all traffic to the platform maintenance page */
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
||||
basic_auth?: string[];
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
@@ -7902,6 +8147,40 @@ export interface components {
|
||||
restart_policy?: string;
|
||||
/** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
/** @description Redirect all traffic to the platform maintenance page */
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
||||
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
||||
};
|
||||
ServiceDomain: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
service_id?: string;
|
||||
domain?: string;
|
||||
is_default?: boolean;
|
||||
cert_type?: string;
|
||||
/** @enum {string} */
|
||||
cert_status?: "pending" | "issued" | "failed";
|
||||
/** Format: date-time */
|
||||
last_checked_at?: string;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
};
|
||||
DomainCheckResult: {
|
||||
domain?: string;
|
||||
/**
|
||||
* @description ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
|
||||
* @enum {string}
|
||||
*/
|
||||
status?: "ok" | "wrong-target" | "pending";
|
||||
/** @description A/AAAA records resolved */
|
||||
resolved?: string[];
|
||||
};
|
||||
BasicAuthCred: {
|
||||
username: string;
|
||||
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||
password: string;
|
||||
};
|
||||
ServiceVolume: {
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel <id>` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -55,9 +55,29 @@ export type ServiceEntity = {
|
||||
healthcheckPath?: string;
|
||||
restartPolicy?: string;
|
||||
volumes?: ServiceVolume[];
|
||||
domains?: ServiceDomain[];
|
||||
maintenanceMode?: boolean;
|
||||
basicAuth?: string[];
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
export type ServiceDomain = {
|
||||
id: string;
|
||||
service_id: string;
|
||||
domain: string;
|
||||
is_default: boolean;
|
||||
cert_type?: string;
|
||||
cert_status?: string;
|
||||
last_checked_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type DomainCheckResult = {
|
||||
domain: string;
|
||||
status: 'ok' | 'wrong-target' | 'pending';
|
||||
resolved?: string[];
|
||||
};
|
||||
|
||||
export type ServiceVolume = {
|
||||
type?: 'volume' | 'bind';
|
||||
source: string;
|
||||
@@ -561,6 +581,9 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
healthcheckPath: service.healthcheck_path,
|
||||
restartPolicy: service.restart_policy,
|
||||
volumes: service.volumes as ServiceVolume[] | undefined,
|
||||
domains: service.domains as ServiceDomain[] | undefined,
|
||||
maintenanceMode: service.maintenance_mode,
|
||||
basicAuth: service.basic_auth as string[] | undefined,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
@@ -1100,6 +1123,36 @@ export async function createService(projectId: string, input: CreateServiceInput
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
|
||||
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
|
||||
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain, is_default: isDefault }),
|
||||
});
|
||||
return payload.domain;
|
||||
}
|
||||
|
||||
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
|
||||
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
|
||||
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
|
||||
`/services/${serviceId}/domains/${domainId}/default`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
|
||||
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||
method: 'PUT',
|
||||
|
||||
Reference in new issue
Block a user