Merge pull request #30 from Dvorinka/feat/env-doctor

feat(services): env doctor — empty vars, bad refs, unreadable secrets
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-10-09 05:30:22 +02:00
commit 8d682583ff
13 files changed
+269 -3

No files matched your search

+4
View File
@@ -39,6 +39,10 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# Application Configuration # Application Configuration
# In production this must be a strong value with at least 32 characters. # In production this must be a strong value with at least 32 characters.
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*() JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset;
# setting it explicitly survives a JWT_SECRET rotation.
SECRETS_KEY=
# Shared secret for node-agent registration/heartbeat auth. # Shared secret for node-agent registration/heartbeat auth.
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
+1
View File
@@ -279,6 +279,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains) read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains) read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/services/:id/env-check", handleServiceEnvCheck)
read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment) read.GET("/deployments/:id", handleGetDeployment)
+109
View File
@@ -641,3 +641,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil return nil
} }
// handleServiceEnvCheck reports environment problems without exposing
// values: empty vars, ${{service.KEY}} refs that fail to resolve, and
// secrets whose ciphertext can't be decrypted (key rotation).
func handleServiceEnvCheck(c *gin.Context) {
dbVal, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbVal.(*database.DB)
service, ok := loadReadableService(c, db)
if !ok {
return
}
rows, err := db.Query(
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
service.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var unresolved, empty, unreadable []string
var raw []struct{ key, val string }
for rows.Next() {
var k, v string
var secret bool
if err := rows.Scan(&k, &v, &secret); err != nil {
continue
}
decrypted := secrets.Decrypt(v)
if secret && secrets.IsEncrypted(v) && decrypted == v {
// Ciphertext failed to decrypt — Decrypt returns the input.
unreadable = append(unreadable, k)
continue
}
if strings.TrimSpace(decrypted) == "" {
empty = append(empty, k)
}
raw = append(raw, struct{ key, val string }{k, decrypted})
}
// Project siblings for ${{name.KEY}} resolution.
siblingIDs := map[string]string{}
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
if err == nil {
for svcRows.Next() {
var id, name string
if err := svcRows.Scan(&id, &name); err == nil {
siblingIDs[strings.ToLower(name)] = id
}
}
svcRows.Close()
}
resolvable := func(serviceName, key string) bool {
id, ok := siblingIDs[strings.ToLower(serviceName)]
if !ok {
return false
}
if strings.EqualFold(key, "HOST") {
return true
}
if strings.EqualFold(key, "PORT") {
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
return port > 0
}
return false
}
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&n); err == nil {
return n > 0
}
return false
}
for _, kv := range raw {
for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) {
if len(match) == 3 && !resolvable(match[1], match[2]) {
unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0]))
}
}
}
if unresolved == nil {
unresolved = []string{}
}
if empty == nil {
empty = []string{}
}
if unreadable == nil {
unreadable = []string{}
}
okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0
c.JSON(http.StatusOK, gin.H{
"ok": okResult,
"unresolved": unresolved,
"empty": empty,
"unreadable": unreadable,
})
}
+34 -1
View File
@@ -530,5 +530,38 @@ func init() {
domainsAddCmd.Flags().Bool("default", false, "set as the default domain") domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd) ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd)
}
var servicesEnvCheckCmd = &cobra.Command{
Use: "env-check <id>",
Short: "Diagnose service environment: empty vars, unresolved ${{refs}}",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
if b, ok := m["ok"].(bool); ok && b {
fmt.Println("env check: ok")
return nil
}
for _, group := range []string{"unresolved", "empty", "unreadable"} {
if list, ok := m[group].([]interface{}); ok {
for _, item := range list {
fmt.Printf("%s: %v\n", group, item)
}
}
}
return &APIError{Message: "env check failed", ExitCode: ExitError}
},
} }
+1
View File
@@ -60,6 +60,7 @@ func init() {
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false}) register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false}) register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false})
register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false}) register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false})
register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false})
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false}) register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
@@ -36,6 +36,7 @@ import {
sleepService, sleepService,
wakeService, wakeService,
getServiceRuntime, getServiceRuntime,
getServiceEnvCheck,
type CronJobEntity, type CronJobEntity,
type ServiceVolume, type ServiceVolume,
type ServiceDomain, type ServiceDomain,
@@ -189,6 +190,12 @@ export function ServiceDetailPage() {
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables', enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
}); });
const envCheckQuery = useQuery({
queryKey: ['service-env-check', serviceId],
queryFn: () => getServiceEnvCheck(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
});
const saveVariablesMutation = useMutation({ const saveVariablesMutation = useMutation({
mutationFn: (rows: VariableDraft[]) => mutationFn: (rows: VariableDraft[]) =>
updateServiceVariables( updateServiceVariables(
@@ -1303,6 +1310,23 @@ export function ServiceDetailPage() {
</div> </div>
</div> </div>
{envCheckQuery.data && !envCheckQuery.data.ok && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--warning)]/40 bg-[var(--warning-soft)] p-4">
<p className="text-xs font-semibold text-[var(--warning)] mb-2">Environment issues detected</p>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{(envCheckQuery.data.unresolved ?? []).map((u) => (
<li key={u} className="mono">Unresolved reference: {u}</li>
))}
{(envCheckQuery.data.empty ?? []).map((k) => (
<li key={k} className="mono">Empty value: {k}</li>
))}
{(envCheckQuery.data.unreadable ?? []).map((k) => (
<li key={k} className="mono">Cannot decrypt secret (key rotated?): {k}</li>
))}
</ul>
</div>
)}
{bulkOpen && ( {bulkOpen && (
<div className="mb-4 panel-soft p-4"> <div className="mb-4 panel-soft p-4">
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
+46
View File
@@ -1010,6 +1010,52 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/services/{id}/env-check": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Environment diagnostics
* @description Reports empty variables, unresolved ${{service.KEY}} references, and secrets whose stored ciphertext fails to decrypt. Values are never returned.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Diagnostic result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ok?: boolean;
unresolved?: string[];
empty?: string[];
unreadable?: string[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/internal/wake/{id}": { "/internal/wake/{id}": {
parameters: { parameters: {
query?: never; query?: never;
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md", "path": "guides/CLI.md",
"section": "guides", "section": "guides",
"title": "Containr CLI", "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\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\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|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\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`voLine truncated "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 clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\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|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\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`voLine truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+11
View File
@@ -1304,6 +1304,17 @@ export const restartService = (serviceId: string) => serviceAction(serviceId, 'r
export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep'); export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep');
export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake'); export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake');
export type EnvCheckResult = {
ok: boolean;
unresolved?: string[];
empty?: string[];
unreadable?: string[];
};
export async function getServiceEnvCheck(serviceId: string): Promise<EnvCheckResult> {
return requestJson<EnvCheckResult>(`/services/${serviceId}/env-check`);
}
export async function redeployService(serviceId: string): Promise<ServiceRuntime> { export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, { const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST', method: 'POST',
+35
View File
@@ -685,6 +685,41 @@ paths:
'202': '202':
description: Wake initiated description: Wake initiated
/services/{id}/env-check:
get:
tags:
- Services
summary: Environment diagnostics
description: Reports empty variables, unresolved ${{service.KEY}} references, and secrets whose stored ciphertext fails to decrypt. Values are never returned.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Diagnostic result
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
unresolved:
type: array
items:
type: string
empty:
type: array
items:
type: string
unreadable:
type: array
items:
type: string
/internal/wake/{id}: /internal/wake/{id}:
get: get:
tags: tags:
+1
View File
@@ -170,6 +170,7 @@ containr services update <id> --sleep --sleep-idle 15 # enable, 15 min idle ti
containr services update <id> --sleep=false # disable containr services update <id> --sleep=false # disable
containr services sleep <id> # sleep now containr services sleep <id> # sleep now
containr services wake <id> # resume now containr services wake <id> # resume now
containr services env-check <id> # flag empty vars + unresolved ${{refs}}
``` ```
Idleness is measured as flat container network I/O over the configured Idleness is measured as flat container network I/O over the configured
+1
View File
@@ -73,6 +73,7 @@ else
PG_PASS="$(gen)"; REDIS_PASS="$(gen)" PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
setvar ENVIRONMENT "production" setvar ENVIRONMENT "production"
setvar JWT_SECRET "$(gen)" setvar JWT_SECRET "$(gen)"
setvar SECRETS_KEY "$(gen)"
setvar BETTER_AUTH_SECRET "$(gen)" setvar BETTER_AUTH_SECRET "$(gen)"
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)" setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
setvar CONTAINR_AGENT_AUTH_TOKEN "$(gen)" setvar CONTAINR_AGENT_AUTH_TOKEN "$(gen)"
+1 -1
View File
@@ -26,7 +26,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists. - **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it. - **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it.
- **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`. - **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`.
- **Sleep mode**: `PUT /services/{id} {sleep_enabled, sleep_idle_minutes}` scales a service to zero after N idle minutes (network-byte deltas, 30s sweep). Status becomes `sleeping`. A busybox wake container then holds the service's Traefik host rule at priority 100, serves a reloading "waking" page, and hits `GET /api/v1/internal/wake/{id}` (unauthenticated, idempotent) which reconciles the service back. `POST /services/{id}/sleep|wake` for manual control. Requires the API reachable from containers (containerized deploy or `HOST=0.0.0.0`). - **Sleep mode**: `PUT /services/{id} {sleep_enabled, sleep_idle_minutes}` scales a service to zero after N idle minutes (network-byte deltas, 30s sweep). Status becomes `sleeping`. A busybox wake container then holds the service's Traefik host rule at priority 100, serves a reloading "waking" page, and hits `GET /api/v1/internal/wake/{id}` (unauthenticated, idempotent) which reconciles the service back. `POST /services/{id}/sleep|wake` for manual control; `GET /services/{id}/env-check` reports empty vars, unresolved `${{svc.KEY}}` refs, and undecryptable secrets (values never exposed). Requires the API reachable from containers (containerized deploy or `HOST=0.0.0.0`).
- **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service.<key>.<prop>}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars). - **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service.<key>.<prop>}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars).
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.