diff --git a/.env.example b/.env.example index 441f421..223a5b7 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret # Bundled agent binaries dir served at /api/agents/download/:platform. # CONTAINR_AGENT_BIN_DIR=/agents +# Directory Traefik's file provider watches. The backend writes one +# config per remote service here so domains route to node agents. The +# infra compose wires a shared volume — set only for custom layouts. +# TRAEFIK_DYNAMIC_DIR=/traefik-dynamic # Optional remote template catalog (another Containr instance). Its # templates merge into GET /templates as source:"registry" and # materialize locally on deploy. The bundled catalog works without it. diff --git a/ROADMAP2.md b/ROADMAP2.md index b5006de..2325ea0 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -296,9 +296,15 @@ and wired refs, one click or one CLI call. utilisation, then cpu, then container count, with uninstrumented agents sorting last. Lifecycle and `GET /runtime` are inventory-driven, so remote replicas show up and tear down wherever - they run. Remote nodes run registry-pulled - images only — git builds, Traefik domains, and remote sleep are - documented ceilings. Tag affinity shipped: `node_agents.tags` + they run. Remote ingress shipped: the agent reports assigned host + ports in the `create_container` result, the backend renders a + Traefik file-provider config per remote service into + `TRAEFIK_DYNAMIC_DIR` (shared volume, `watch=true`) pointing the + loadbalancer at each replica's mesh IP + published port; route files + are rewritten on every remote reconcile and removed on teardown or + local placement. Remote nodes run registry-pulled + images only — git builds and remote sleep are documented ceilings. + Tag affinity shipped: `node_agents.tags` (operator-set via `PUT /agents/:id` / `nodes update --tags`) + `services.placement_tags` — `auto` and `spread` only consider online schedulable agents carrying every required tag, tags are diff --git a/app/backend/cmd/agent/main.go b/app/backend/cmd/agent/main.go index 48d2124..b2400d9 100644 --- a/app/backend/cmd/agent/main.go +++ b/app/backend/cmd/agent/main.go @@ -272,7 +272,12 @@ func executeCommand(item command) (string, error) { case "restart_container": return runDocker("restart", commandTarget(item.Payload)) case "remove_container": - return runDocker("rm", "-f", commandTarget(item.Payload)) + out, err := runDocker("rm", "-f", commandTarget(item.Payload)) + // A missing container is the desired end state — report success. + if err != nil && strings.Contains(out, "No such container") { + return out, nil + } + return out, err case "prune": return dockerPrune(item.Payload) case "system_df": @@ -460,7 +465,25 @@ func createContainer(payload map[string]interface{}) (string, error) { logs, _ := runDocker("logs", "--tail", "5", name) return "", fmt.Errorf("container %s is %q after start: %s", name, state, logs) } - return name, nil + // Report the real host port bindings — ephemeral publishes pick a port + // the server can't guess, and remote ingress needs the actual mapping. + hostPorts := map[string]string{} + portsJSON, _ := runDocker("inspect", "-f", "{{json .NetworkSettings.Ports}}", name) + var bindings map[string][]struct { + HostIP string `json:"HostIp"` + HostPort string `json:"HostPort"` + } + if json.Unmarshal([]byte(portsJSON), &bindings) == nil { + for cport, addrs := range bindings { + for _, a := range addrs { + if a.HostPort != "" && a.HostPort != "0" { + hostPorts[cport] = a.HostPort + } + } + } + } + out, _ := json.Marshal(map[string]interface{}{"name": name, "host_ports": hostPorts}) + return string(out), nil } // registryHostOf mirrors the server's registryHost: first path segment with diff --git a/app/backend/internal/api/ingress.go b/app/backend/internal/api/ingress.go new file mode 100644 index 0000000..c062d96 --- /dev/null +++ b/app/backend/internal/api/ingress.go @@ -0,0 +1,171 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + "containr/internal/deployment" +) + +// Remote ingress: replicas on node agents aren't on the hub's +// containr-network, so Traefik's Docker provider can't see them. Instead +// the backend renders one file-provider config per service into +// TRAEFIK_DYNAMIC_DIR (a directory Traefik watches), pointing the +// loadbalancer at the node's reachable IP + the ephemeral host port the +// agent reported back after `create_container`. +// +// Empty dir → feature off (bare-metal dev). Files are best-effort: a +// failed write never fails a reconcile. + +func ingressDir() string { return os.Getenv("TRAEFIK_DYNAMIC_DIR") } + +func ingressFile(serviceID string) string { + return filepath.Join(ingressDir(), "containr-"+serviceID+".yml") +} + +// jsonInt coerces JSON-ish numbers (int/int32/int64/float64) to int — +// spec fields arrive as int32, agent payloads unmarshal as float64. +func jsonInt(v interface{}) int { + switch n := v.(type) { + case int: + return n + case int32: + return int(n) + case int64: + return int(n) + case float64: + return int(n) + } + return 0 +} + +// reportedHostPorts parses the agent's create_container result — +// {"name": ..., "host_ports": {"80/tcp": "32768"}}. Older agents return a +// bare name string; JSON parse failure means no port info. +func reportedHostPorts(result string) map[string]string { + var out struct { + HostPorts map[string]string `json:"host_ports"` + } + if json.Unmarshal([]byte(result), &out) != nil { + return nil + } + return out.HostPorts +} + +// nodeIngressIP picks the address the hub can reach the node on. Mesh +// overlays first — the agent operator joined a mesh precisely so the hub +// can reach it — then the reported primary IP as a LAN fallback. +func nodeIngressIP(agent sqlcdb.NodeAgent) string { + var meta struct { + Mesh map[string]string `json:"mesh"` + } + _ = json.Unmarshal(agent.Metadata.RawMessage, &meta) + for _, prefix := range []string{"tailscale", "netbird", "wg", "zt"} { + var keys []string + for name := range meta.Mesh { + keys = append(keys, name) + } + sort.Strings(keys) + for _, name := range keys { + if strings.HasPrefix(name, prefix) && meta.Mesh[name] != "" { + return meta.Mesh[name] + } + } + } + return agent.IpAddress +} + +// syncRemoteIngress rewrites the service's file-provider config from the +// live remote replica set. Called after every remote reconcile so replica +// adds/removals and node retargets converge the upstream list. +func syncRemoteIngress(ctx context.Context, db *database.DB, spec deployment.RuntimeSpec) { + if ingressDir() == "" { + return + } + domains := spec.Domains + if len(domains) == 0 && spec.Domain != "" { + domains = []string{spec.Domain} + } + if len(domains) == 0 || spec.Port <= 0 { + removeServiceIngress(spec.ServiceID) + return + } + + q := sqlcdb.New(db.DB) + rows, err := q.ListServiceContainers(ctx, spec.ServiceID) + if err != nil { + return + } + type upstream struct{ ip, port string } + var servers []upstream + for _, row := range rows { + var st struct { + State string `json:"state"` + } + _ = json.Unmarshal(row.Status.RawMessage, &st) + if st.State != "running" { + continue + } + var port string + var plist []map[string]interface{} + if err := json.Unmarshal(row.Ports.RawMessage, &plist); err == nil { + for _, p := range plist { + if hp, ok := p["host_port"].(float64); ok && hp > 0 { + port = fmt.Sprintf("%d", int(hp)) + } + } + } + if port == "" { + continue + } + agent, err := q.GetAgent(ctx, row.NodeAgentID) + if err != nil { + continue + } + if ip := nodeIngressIP(agent); ip != "" { + servers = append(servers, upstream{ip, port}) + } + } + if len(servers) == 0 { + removeServiceIngress(spec.ServiceID) + return + } + + router := "svc-" + spec.ServiceID[:8] + rules := make([]string, 0, len(domains)) + for _, d := range domains { + rules = append(rules, "Host(`"+d+"`)") + } + var b strings.Builder + b.WriteString("http:\n routers:\n " + router + ":\n") + b.WriteString(" rule: \"" + strings.Join(rules, " || ") + "\"\n") + b.WriteString(" entrypoints: [web]\n") + b.WriteString(" service: " + router + "\n") + b.WriteString(" services:\n " + router + ":\n loadBalancer:\n servers:\n") + for _, s := range servers { + b.WriteString(" - url: \"http://" + s.ip + ":" + s.port + "\"\n") + } + // Atomic-ish: write temp then rename so Traefik's watcher never reads a + // truncated file. + tmp := ingressFile(spec.ServiceID) + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { + return + } + _ = os.Rename(tmp, ingressFile(spec.ServiceID)) +} + +// removeServiceIngress deletes the service's remote route. Called on +// remote teardown and when a service returns to local placement. +func removeServiceIngress(serviceID string) { + if ingressDir() == "" { + return + } + _ = os.Remove(ingressFile(serviceID)) +} diff --git a/app/backend/internal/api/noderunner.go b/app/backend/internal/api/noderunner.go index 7717596..1f181bb 100644 --- a/app/backend/internal/api/noderunner.go +++ b/app/backend/internal/api/noderunner.go @@ -19,8 +19,8 @@ import ( // agentNodeRunner implements deployment.NodeRunner by dispatching container // lifecycle commands to a node agent's command queue and waiting for the -// reported result. Remote nodes have no Traefik — domains stay local-only -// until per-node ingress lands. +// reported result. Domains route through the file provider — +// syncRemoteIngress renders per-service configs from live inventory. type agentNodeRunner struct { q *sqlcdb.Queries db *database.DB @@ -131,10 +131,6 @@ func (r *agentNodeRunner) RemoteRuntimeState(ctx context.Context, serviceID stri // targetFor and retires stale replicas on any agent — including leftovers // from a previous pin or a different spread assignment. func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) { - if len(spec.Domains) > 0 || spec.Domain != "" { - return nil, fmt.Errorf("domains are routed by the local Traefik — remove the service's domains or run it on the local node") - } - replicas := spec.Replicas if replicas < 1 { replicas = 1 @@ -151,7 +147,7 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment if keep { continue } - if cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container", + if _, cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container", map[string]interface{}{"container_name": row.Name}); cmdErr != nil { return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr) } @@ -185,18 +181,41 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}), }) - if err := r.enqueueAndWait(ctx, agentID, containerID, "create_container", - map[string]interface{}{"container": container}); err != nil { + result, err := r.enqueueAndWait(ctx, agentID, containerID, "create_container", + map[string]interface{}{"container": container}) + if err != nil { return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err) } - _ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ - ID: containerID, - Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}), - }) + if assigned := reportedHostPorts(result); len(assigned) > 0 { + for _, p := range ports { + pm, ok := p.(map[string]interface{}) + if !ok { + continue + } + cport := jsonInt(pm["container_port"]) + proto, _ := pm["protocol"].(string) + if hp, ok := assigned[fmt.Sprintf("%d/%s", cport, proto)]; ok { + if n, err := strconv.Atoi(hp); err == nil { + pm["host_port"] = n + } + } + } + _ = r.q.UpdateContainerPorts(ctx, sqlcdb.UpdateContainerPortsParams{ + ID: containerID, + Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}), + Ports: rawJSON(ports), + }) + } else { + _ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: containerID, + Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}), + }) + } state.Containers = append(state.Containers, deployment.RuntimeContainer{ ID: containerID, Name: name, State: "running", Replica: i, }) } + syncRemoteIngress(ctx, r.db, spec) return state, nil } @@ -296,7 +315,17 @@ func (r *agentNodeRunner) ControlService(ctx context.Context, serviceID, agentID if row.ServiceID != serviceID { continue } - if err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType, + // Tombstoned replicas are already gone remotely — skip the dispatch + // or a fresh docker rm on a missing name reports a false failure. + if action == "remove" { + var st struct { + State string `json:"state"` + } + if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil && st.State == "removed" { + continue + } + } + if _, err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType, map[string]interface{}{"container_name": row.Name}); err != nil { return fmt.Errorf("%s %s on node: %w", action, row.Name, err) } @@ -314,16 +343,38 @@ func (r *agentNodeRunner) RemoveService(ctx context.Context, serviceID, agentID if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil { return err } - return r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{ + if err := r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{ NodeAgentID: agentID, ServiceID: serviceID, - }) + }); err != nil { + return err + } + removeServiceIngress(serviceID) + return nil +} + +// RetireService removes the service's remote replicas wherever they run — +// called when the service converges to local placement so a cleared pin or +// removed tag requirement doesn't leave containers behind. +func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) error { + agentIDs, err := r.q.ListServiceAgents(ctx, serviceID) + if err != nil { + return err + } + for _, agentID := range agentIDs { + if err := r.RemoveService(ctx, serviceID, agentID); err != nil { + return fmt.Errorf("retire on node %s: %w", agentID, err) + } + } + removeServiceIngress(serviceID) + return nil } // enqueueAndWait pushes a command to the agent's queue and polls for the -// reported result. Agents poll on their own interval (≥5s), so this blocks — -// it runs inside the deploy queue worker, never on a request path. -func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) error { +// reported result, returning the agent's result string on success. Agents +// poll on their own interval (≥5s), so this blocks — it runs inside the +// deploy queue worker, never on a request path. +func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) (string, error) { var cid sql.NullString if containerID != "" { cid = sql.NullString{String: containerID, Valid: true} @@ -336,7 +387,7 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container Payload: rawJSON(payload), }) if err != nil { - return err + return "", err } deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node @@ -348,20 +399,20 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container if err == nil { switch row.Status.String { case "completed": - return nil + return row.Result.String, nil case "failed": if row.Error.Valid && row.Error.String != "" { - return fmt.Errorf("agent: %s", row.Error.String) + return "", fmt.Errorf("agent: %s", row.Error.String) } - return fmt.Errorf("agent reported failure") + return "", fmt.Errorf("agent reported failure") } } if time.Now().After(deadline) { - return fmt.Errorf("command %s timed out waiting for the agent", cmdType) + return "", fmt.Errorf("command %s timed out waiting for the agent", cmdType) } select { case <-ctx.Done(): - return ctx.Err() + return "", ctx.Err() case <-time.After(2 * time.Second): } } diff --git a/app/backend/internal/database/sqlcdb/agents.sql.go b/app/backend/internal/database/sqlcdb/agents.sql.go index d215260..d863e3f 100644 --- a/app/backend/internal/database/sqlcdb/agents.sql.go +++ b/app/backend/internal/database/sqlcdb/agents.sql.go @@ -765,8 +765,36 @@ func (q *Queries) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 js return items, nil } +const listServiceAgents = `-- name: ListServiceAgents :many +SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1 +` + +// Distinct agents holding inventory rows for a service. +func (q *Queries) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listServiceAgents, serviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var node_agent_id string + if err := rows.Scan(&node_agent_id); err != nil { + return nil, err + } + items = append(items, node_agent_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listServiceContainers = `-- name: ListServiceContainers :many -SELECT id, name, node_agent_id, status FROM container_instances +SELECT id, name, node_agent_id, status, ports FROM container_instances WHERE service_id = $1 ` @@ -775,6 +803,7 @@ type ListServiceContainersRow struct { Name string `json:"name"` NodeAgentID string `json:"node_agent_id"` Status pqtype.NullRawMessage `json:"status"` + Ports pqtype.NullRawMessage `json:"ports"` } func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) { @@ -791,6 +820,7 @@ func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ( &i.Name, &i.NodeAgentID, &i.Status, + &i.Ports, ); err != nil { return nil, err } @@ -1005,6 +1035,24 @@ func (q *Queries) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeart return err } +const updateContainerPorts = `-- name: UpdateContainerPorts :exec +UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW() +WHERE id = $1 +` + +type UpdateContainerPortsParams struct { + ID string `json:"id"` + Status pqtype.NullRawMessage `json:"status"` + Ports pqtype.NullRawMessage `json:"ports"` +} + +// Agent reports the real host bindings after create — persist them with +// the status flip so ingress can route to the assigned port. +func (q *Queries) UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error { + _, err := q.db.ExecContext(ctx, updateContainerPorts, arg.ID, arg.Status, arg.Ports) + return err +} + const updateContainerStatus = `-- name: UpdateContainerStatus :exec UPDATE container_instances SET status = $2, updated_at = NOW() WHERE id = $1 diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index f5b57e7..6b297a6 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -98,6 +98,8 @@ type Querier interface { ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) // Only agents carrying every required placement tag (jsonb array containment). ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) + // Distinct agents holding inventory rows for a service. + ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) @@ -134,6 +136,9 @@ type Querier interface { UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) + // Agent reports the real host bindings after create — persist them with + // the status flip so ingress can route to the assigned port. + UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index df501c9..e712bb7 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -32,6 +32,9 @@ type NodeRunner interface { // RemoteRuntimeState reads the service's remote replicas from inventory // across all agents. nil/empty means nothing runs remotely. RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error) + // RetireService removes every remote replica of the service across all + // agents — used when a service returns to local placement. + RetireService(ctx context.Context, serviceID string) error } type DeploymentEngine struct { diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index ba3168d..dd78d65 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -182,6 +182,12 @@ func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSp } return de.nodeRunner.ReconcileAuto(ctx, spec) } + // Local placement: retire any remote leftovers from a previous pin, + // spread, or tag affinity before reconciling local replicas. + if de.nodeRunner != nil { + _ = de.nodeRunner.RetireService(ctx, spec.ServiceID) + } + if spec.Replicas < 1 { spec.Replicas = 1 } diff --git a/app/backend/sqlc/queries/agents.sql b/app/backend/sqlc/queries/agents.sql index e649893..f7a32ec 100644 --- a/app/backend/sqlc/queries/agents.sql +++ b/app/backend/sqlc/queries/agents.sql @@ -110,6 +110,12 @@ RETURNING *; UPDATE container_instances SET status = $2, updated_at = NOW() WHERE id = $1; +-- name: UpdateContainerPorts :exec +-- Agent reports the real host bindings after create — persist them with +-- the status flip so ingress can route to the assigned port. +UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW() +WHERE id = $1; + -- name: GetLastAgentCommandByType :one SELECT * FROM agent_commands WHERE node_agent_id = $1 AND type = $2 @@ -174,7 +180,7 @@ LIMIT 1; UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1; -- name: ListServiceContainers :many -SELECT id, name, node_agent_id, status FROM container_instances +SELECT id, name, node_agent_id, status, ports FROM container_instances WHERE service_id = $1; -- name: SetAgentSchedulable :exec @@ -197,5 +203,9 @@ ON CONFLICT (id) DO UPDATE SET -- name: DeleteServiceContainersOnAgent :exec DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2; +-- name: ListServiceAgents :many +-- Distinct agents holding inventory rows for a service. +SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1; + -- name: ScrubCommandPayload :exec UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index c1c9ce6..1572eda 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -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\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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register 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 --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 converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\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\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nshell interactive PTY shell inside the service\n container (bash→sh fallback; resizes follow)\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\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 \ncontainr up --project --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..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --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 ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Node placement\n\nPin a service to a registered node agent, or let the scheduler pick the\nleast-loaded online node:\n\n```bash\ncontainr services create --project --name api --image ghcr.io/x/api:v1 --node \ncontainr services update --node auto # least-loaded online node\ncontainr services update --node local # move back to this server\n```\n\n`auto` scores nodes by real headroom — lowest memory utilisation, then\ncpu, then container count. Or spread replicas across every online,\nschedulable node (mutually exclusive with a pin):\n\n```bash\ncontainr services update --replicas 4 --spread # replica i → node[i % n]\ncontainr services update --spread=false # back to single placement\n```\n\nConstrain `auto`/`spread` placement to nodes carrying operator tags —\n`nodes update` labels a node, `--placement-tags` requires every listed tag\n(mutually exclusive with a pin; an empty value clears the requirement):\n\n```bash\ncontainr nodes update --tags gpu,eu-west\ncontainr services update --placement-tags gpu # only gpu nodes are eligible\n```\n\nRemote nodes run registry-pulled images only — git builds, domains\n(Traefik lives on the host), and sleep detection stay local. Start,\nstop, restart, redeploy, and delete all fan out to the pinned node's\nagent. `services get` shows the resolved `node_name`.\n\nTake a node out of rotation or empty it entirely (admin):\n\n```bash\ncontainr nodes cordon # no new pins; running replicas stay\ncontainr nodes uncordon \ncontainr nodes drain # cordon + remove its containers + unpin services\ncontainr nodes upgrade # agent pulls its replacement from the server, re-execs\n```\n\n`nodes upgrade` swaps the agent binary for the one bundled in the running\nserver image — the agent downloads it over the API, verifies the sha256 the\nserver computed, sanity-checks it, atomically replaces its own executable,\nand re-execs. The node must be online and the server must ship a binary for\nits architecture (`linux-amd64`/`linux-arm64`).\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 app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \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 --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/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## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\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 ` 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 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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register 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 --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 converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\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\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nshell interactive PTY shell inside the service\n container (bash→sh fallback; resizes follow)\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\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 \ncontainr up --project --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..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --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 ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Node placement\n\nPin a service to a registered node agent, or let the scheduler pick the\nleast-loaded online node:\n\n```bash\ncontainr services create --project --name api --image ghcr.io/x/api:v1 --node \ncontainr services update --node auto # least-loaded online node\ncontainr services update --node local # move back to this server\n```\n\n`auto` scores nodes by real headroom — lowest memory utilisation, then\ncpu, then container count. Or spread replicas across every online,\nschedulable node (mutually exclusive with a pin):\n\n```bash\ncontainr services update --replicas 4 --spread # replica i → node[i % n]\ncontainr services update --spread=false # back to single placement\n```\n\nConstrain `auto`/`spread` placement to nodes carrying operator tags —\n`nodes update` labels a node, `--placement-tags` requires every listed tag\n(mutually exclusive with a pin; an empty value clears the requirement):\n\n```bash\ncontainr nodes update --tags gpu,eu-west\ncontainr services update --placement-tags gpu # only gpu nodes are eligible\n```\n\nRemote nodes run registry-pulled images only — git builds and sleep\ndetection stay local. Domains work remotely: the agent publishes the\nservice port and the backend writes a Traefik file-provider route to the\nnode's mesh IP (TRAEFIK_DYNAMIC_DIR — wired by the infra compose).\nStart, stop, restart, redeploy, and delete all fan out to the pinned\nnode's agent. `services get` shows the resolved `node_name`.\n\nTake a node out of rotation or empty it entirely (admin):\n\n```bash\ncontainr nodes cordon # no new pins; running replicas stay\ncontainr nodes uncordon \ncontainr nodes drain # cordon + remove its containers + unpin services\ncontainr nodes upgrade # agent pulls its replacement from the server, re-execs\n```\n\n`nodes upgrade` swaps the agent binary for the one bundled in the running\nserver image — the agent downloads it over the API, verifies the sha256 the\nserver computed, sanity-checks it, atomically replaces its own executable,\nand re-execs. The node must be online and the server must ship a binary for\nits architecture (`linux-amd64`/`linux-arm64`).\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 app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \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 --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/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## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\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 ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index e9223ea..8c06877 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -161,10 +161,12 @@ containr nodes update --tags gpu,eu-west containr services update --placement-tags gpu # only gpu nodes are eligible ``` -Remote nodes run registry-pulled images only — git builds, domains -(Traefik lives on the host), and sleep detection stay local. Start, -stop, restart, redeploy, and delete all fan out to the pinned node's -agent. `services get` shows the resolved `node_name`. +Remote nodes run registry-pulled images only — git builds and sleep +detection stay local. Domains work remotely: the agent publishes the +service port and the backend writes a Traefik file-provider route to the +node's mesh IP (TRAEFIK_DYNAMIC_DIR — wired by the infra compose). +Start, stop, restart, redeploy, and delete all fan out to the pinned +node's agent. `services get` shows the resolved `node_name`. Take a node out of rotation or empty it entirely (admin): diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 6f2ac62..93ba343 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -7,7 +7,8 @@ services: - "--api.insecure=${TRAEFIK_API_INSECURE:-true}" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.file.filename=/etc/traefik/traefik-dynamic.yml" + - "--providers.file.directory=/traefik-dynamic" + - "--providers.file.watch=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.traefik.address=:8080" @@ -26,7 +27,8 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ../data/letsencrypt:/letsencrypt - - ./traefik-dynamic.yml:/etc/traefik/traefik-dynamic.yml:ro + - traefik_dynamic:/traefik-dynamic + - ./traefik-dynamic.yml:/traefik-dynamic/00-base.yml:ro networks: - containr-network restart: unless-stopped @@ -100,6 +102,7 @@ services: - JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production} - CONTAINR_AGENT_AUTH_TOKEN=${CONTAINR_AGENT_AUTH_TOKEN:-} - CONTAINR_AGENT_AUTH_TOKENS=${CONTAINR_AGENT_AUTH_TOKENS:-} + - TRAEFIK_DYNAMIC_DIR=/traefik-dynamic - COOKIE_SECURE=${COOKIE_SECURE:-true} - TRUSTED_PROXY_CIDR=${TRUSTED_PROXY_CIDR:-172.20.0.0/16} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost,http://localhost:3000} @@ -136,6 +139,7 @@ services: - GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com} volumes: - /var/run/docker.sock:/var/run/docker.sock + - traefik_dynamic:/traefik-dynamic group_add: - "${DOCKER_GID:-0}" networks: @@ -213,6 +217,8 @@ volumes: driver: local dragonfly_data: driver: local + traefik_dynamic: + driver: local networks: containr-network: