mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): env doctor — surface empty vars, bad refs, unreadable secrets
GET /services/:id/env-check (read scope) reports, without exposing values:
- unresolved: ${{service.KEY}} refs that don't match a sibling service/env
- empty: variables with blank values
- unreadable: secrets whose ciphertext fails decryption (key rotation)
install.sh now generates SECRETS_KEY explicitly (was falling back to
JWT_SECRET — a rotation would orphan ciphertext). .env.example documents it.
Surfaces: CLI `services env-check`, MCP containr_services_env_check
(90 tools), OpenAPI path, and a warning banner in the service Variables
panel listing each issue.
Verified live: injected a bogus ${{nosuchservice.PORT}} var plus an
unreadable ciphertext row; the endpoint flagged both, ok=false.
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
dbc87bf34c
commit
d135e5fd4b
13 files changed
+269
-3
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -638,3 +638,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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
@@ -1315,6 +1322,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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -168,6 +168,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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -25,7 +25,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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user