From ae7e6b4991b5a9aef2b954f01ff259970d839fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:37:26 +0200 Subject: [PATCH] feat(cli): containr migrate dflow + fix railway service types (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dflow importer pages the Payload REST collections (users API-Key auth) and recreates projects/services: app→git web services, docker →image, database→typed image with DATABASE_URL for external providers; variables (empty encrypted values flagged), bind volumes, and domains carried over; soft-deleted docs excluded. Also fixes the Railway migrator posting type "image"/"git" — the create API only accepts web|worker|database|cron and requires an environment, so every service create 400'd. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 9 +- app/backend/internal/cli/commands/migrate.go | 8 +- .../internal/cli/commands/migrate_dflow.go | 441 ++++++++++++++++++ .../internal/cli/commands/migrate_test.go | 82 ++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- docs/guides/CLI.md | 1 + 6 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 app/backend/internal/cli/commands/migrate_dflow.go diff --git a/ROADMAP2.md b/ROADMAP2.md index 17828cc..4b28200 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -398,9 +398,12 @@ and wired refs, one click or one CLI call. services (git-repo/image + command + replicas), variables, custom + service domains → recreates via the Containr API. Project tokens and account tokens both supported. `cli/commands/migrate.go`. -- [ ] **dflow importer** — `containr migrate dflow` via Payload REST - export or Mongo dump: servers→nodes, projects, services(+vars/ - domains/volumes), databases, templates. Onboards dflow refugees. +- [x] **dflow importer** — `containr migrate dflow --url --api-key + --project --into --dry-run`: pages the Payload REST collections + (users API-Key auth) → services (app→git/web, docker→image, + database→typed image + DATABASE_URL for external providers), + variables (empty encrypted values flagged), bind volumes, domains. + Soft-deleted services excluded. `cli/commands/migrate_dflow.go`. - [x] **`containr import compose`** — `POST /projects/:id/import-compose` deploys a compose file straight into a project; CLI `import compose`, MCP tool, workspace import modal; project delete cascades to diff --git a/app/backend/internal/cli/commands/migrate.go b/app/backend/internal/cli/commands/migrate.go index 9337b2b..7f7dc5f 100644 --- a/app/backend/internal/cli/commands/migrate.go +++ b/app/backend/internal/cli/commands/migrate.go @@ -121,12 +121,14 @@ Token: account/workspace token (Authorization: Bearer) or a project token. } for _, p := range plans { - body := map[string]interface{}{"name": p.Name} + body := map[string]interface{}{ + "name": p.Name, + "type": "web", + "environment": "production", + } if p.Source.Image != "" { - body["type"] = "image" body["image"] = p.Source.Image } else if repo := railwayRepoURL(p.Source.Repo); repo != "" { - body["type"] = "git" body["git_repo"] = repo } else { fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name) diff --git a/app/backend/internal/cli/commands/migrate_dflow.go b/app/backend/internal/cli/commands/migrate_dflow.go new file mode 100644 index 0000000..1989bef --- /dev/null +++ b/app/backend/internal/cli/commands/migrate_dflow.go @@ -0,0 +1,441 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// dflow runs on Payload CMS — every collection is a plain REST resource under +// /api/ returning {docs, page, totalPages, hasNextPage}. Auth is a +// Payload API key on the users collection: "Authorization: users API-Key ". + +type dflowServicePlan struct { + ID string + Name string + Description string + Type string // web | database + Image string + GitRepo string + GitBranch string + BuildPath string + Port int + Variables map[string]string + Domains []string + Volumes []map[string]string +} + +var migrateDflowCmd = &cobra.Command{ + Use: "dflow", + Short: "Migrate a dflow (Payload CMS) project into Containr", + Long: `Pulls a dflow project's services, variables, volumes, and domains via the +Payload REST API and recreates them in Containr. + + containr migrate dflow --url https://dflow.example.com --api-key --dry-run + containr migrate dflow --url https://dflow.example.com --api-key --project + +Auth: a Payload API key from a dflow admin user (users API-Key). Encrypted +fields (variables, git tokens) only come back decrypted if the API key belongs +to a user with access — empty values are flagged so nothing is silently lost.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + base, _ := cmd.Flags().GetString("url") + if base == "" { + base = os.Getenv("DFLOW_URL") + } + if base == "" { + return &APIError{Message: "pass --url or set DFLOW_URL", ExitCode: ExitError} + } + base = strings.TrimSuffix(base, "/") + key, _ := cmd.Flags().GetString("api-key") + if key == "" { + key = os.Getenv("DFLOW_API_KEY") + } + if key == "" { + return &APIError{Message: "pass --api-key or set DFLOW_API_KEY", ExitCode: ExitError} + } + projectID, _ := cmd.Flags().GetString("project") + into, _ := cmd.Flags().GetString("into") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + projectName, services, err := dflowProject(base, key, projectID) + if err != nil { + return err + } + if len(services) == 0 { + return &APIError{Message: "no services found in that dflow project", ExitCode: ExitError} + } + + plans := make([]dflowServicePlan, 0, len(services)) + for _, svc := range services { + plan, warn := dflowServicePlanOf(svc) + for _, w := range warn { + fmt.Fprintf(os.Stderr, " warning: %s: %s\n", plan.Name, w) + } + plans = append(plans, plan) + } + + fmt.Printf("dflow project %q → %d service(s)\n", projectName, len(plans)) + for _, p := range plans { + src := p.Image + if src == "" { + src = p.GitRepo + } + fmt.Printf(" %s type=%s src=%s vars=%d domains=%d volumes=%d\n", + p.Name, p.Type, src, len(p.Variables), len(p.Domains), len(p.Volumes)) + } + + if dryRun { + fmt.Println("\nDry run — pass without --dry-run to create these resources.") + return nil + } + + c, err := client() + if err != nil { + return err + } + + target := into + if target == "" { + data, err := c.Do("POST", "/projects", map[string]interface{}{ + "name": projectName, + "description": "Imported from dflow", + }) + if err != nil { + return err + } + p, _ := unwrapObject(data, "project") + target = str(p, "id") + if target == "" || target == "-" { + return &APIError{Message: "project created but id missing from response", ExitCode: ExitError} + } + fmt.Printf("Created project %s (%s)\n", projectName, target) + } + + for _, p := range plans { + body := map[string]interface{}{ + "name": p.Name, + "type": p.Type, + "environment": "production", + } + if p.Image != "" { + body["image"] = p.Image + } + if p.GitRepo != "" { + body["git_repo"] = p.GitRepo + } + if p.GitBranch != "" { + body["git_branch"] = p.GitBranch + } + if p.BuildPath != "" { + body["build_path"] = p.BuildPath + } + if p.Port > 0 { + body["port"] = p.Port + } + if len(p.Volumes) > 0 { + vols := make([]map[string]interface{}, 0, len(p.Volumes)) + for _, v := range p.Volumes { + vols = append(vols, map[string]interface{}{ + "type": "bind", "source": v["source"], "target": v["target"], + }) + } + body["volumes"] = vols + } + data, err := c.Do("POST", "/projects/"+target+"/services", body) + if err != nil { + fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err) + continue + } + svc, _ := unwrapObject(data, "service") + svcID := str(svc, "id") + fmt.Printf(" created service %s (%s)\n", p.Name, svcID) + + if len(p.Variables) > 0 && svcID != "" && svcID != "-" { + vars := make([]map[string]interface{}, 0, len(p.Variables)) + for k, v := range p.Variables { + vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false}) + } + if _, err := c.Do("PUT", "/services/"+svcID+"/variables", + map[string]interface{}{"variables": vars}); err != nil { + fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err) + } + } + for _, domain := range p.Domains { + if svcID == "" || svcID == "-" { + break + } + if _, err := c.Do("POST", "/services/"+svcID+"/domains", + map[string]interface{}{"domain": domain}); err != nil { + fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err) + } + } + } + fmt.Println("Done. Git credentials, deployment history, and server topology are not migrated — reconnect repos and redeploy each service when ready.") + return nil + }, +} + +// dflowGet performs an authenticated Payload REST call. +func dflowGet(base, apiKey, path string, q url.Values) (map[string]interface{}, error) { + u := base + "/api/" + path + if len(q) > 0 { + u += "?" + q.Encode() + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "users API-Key "+apiKey) + req.Header.Set("Accept", "application/json") + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, &APIError{Message: "dflow rejected the API key — needs an admin user's users API-Key", ExitCode: ExitAuth} + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("dflow response not JSON (status %d)", resp.StatusCode) + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("dflow status %d", resp.StatusCode) + } + return out, nil +} + +// dflowCollection pages through a Payload collection. +func dflowCollection(base, apiKey, collection string, q url.Values) ([]map[string]interface{}, error) { + var docs []map[string]interface{} + for page := 1; ; page++ { + params := url.Values{} + for k, v := range q { + params[k] = v + } + params.Set("limit", "100") + params.Set("depth", "0") + params.Set("page", fmt.Sprint(page)) + data, err := dflowGet(base, apiKey, collection, params) + if err != nil { + return nil, err + } + list, _ := data["docs"].([]interface{}) + for _, d := range list { + if m, ok := d.(map[string]interface{}); ok { + docs = append(docs, m) + } + } + next, _ := data["hasNextPage"].(bool) + if !next { + return docs, nil + } + } +} + +// dflowProject resolves the project (by id, or sole project) and returns its +// name plus its services. +func dflowProject(base, apiKey, projectID string) (string, []map[string]interface{}, error) { + if projectID == "" { + projects, err := dflowCollection(base, apiKey, "projects", nil) + if err != nil { + return "", nil, err + } + if len(projects) == 1 { + projectID = str(projects[0], "id") + } else { + for _, p := range projects { + fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name")) + } + return "", nil, &APIError{ + Message: "multiple projects — pass --project (listed above)", + ExitCode: ExitError, + } + } + } + + projects, err := dflowCollection(base, apiKey, "projects", + url.Values{"where[id][equals]": {projectID}}) + if err != nil { + return "", nil, err + } + if len(projects) == 0 { + return "", nil, &APIError{Message: "dflow project not found", ExitCode: ExitNotFound} + } + name := str(projects[0], "name") + + q := url.Values{ + "where[project][equals]": {projectID}, + "where[deletedAt][exists]": {"false"}, + "where[_status][not_equals]": {"draft"}, + } + services, err := dflowCollection(base, apiKey, "services", q) + if err != nil { + return "", nil, err + } + return name, services, nil +} + +// dflowServicePlanOf maps a Payload service doc onto a Containr create plan. +func dflowServicePlanOf(svc map[string]interface{}) (dflowServicePlan, []string) { + var warn []string + plan := dflowServicePlan{ + ID: str(svc, "id"), + Name: str(svc, "name"), + Description: str(svc, "description"), + Variables: map[string]string{}, + } + switch str(svc, "type") { + case "app": + plan.Type = "web" + settings := dflowProviderSettings(svc) + plan.GitRepo = dflowRepoURL(str(svc, "providerType"), str(settings, "owner"), str(settings, "repository")) + plan.GitBranch = str(settings, "branch") + plan.BuildPath = str(settings, "buildPath") + plan.Port = int(num(settings, "port")) + if plan.GitRepo == "" { + warn = append(warn, "no repository settings — importing with no source") + } + case "docker": + plan.Type = "web" + d, _ := svc["dockerDetails"].(map[string]interface{}) + plan.Image = normalizeDockerImage(str(d, "url")) + for _, p := range listOf(d, "ports") { + if plan.Port == 0 { + plan.Port = int(num(p, "containerPort")) + } + } + if plan.Image == "" { + warn = append(warn, "no docker image url — importing with no source") + } + case "database": + plan.Type = "database" + d, _ := svc["databaseDetails"].(map[string]interface{}) + dbType := str(d, "type") + version := str(d, "version") + if dbType != "" { + plan.Image = dbType + ":" + firstOf(version, "latest") + } + if str(d, "provider") == "external" { + if conn := str(d, "connectionUrl"); conn != "" { + plan.Variables["DATABASE_URL"] = conn + } else if str(d, "host") != "" { + warn = append(warn, "external database has host but no connectionUrl — set DATABASE_URL manually") + } + } + default: + plan.Type = "web" + warn = append(warn, fmt.Sprintf("unknown type %q — imported as web", str(svc, "type"))) + } + + for _, v := range listOf(svc, "variables") { + k, val := str(v, "key"), str(v, "value") + if k == "" { + continue + } + if val == "" { + warn = append(warn, fmt.Sprintf("variable %s came back empty (encrypted field?)", k)) + } + plan.Variables[k] = val + } + for _, d := range listOf(svc, "domains") { + if dom := str(d, "domain"); dom != "" && dom != "-" { + plan.Domains = append(plan.Domains, dom) + } + } + for _, v := range listOf(svc, "volumes") { + host, container := str(v, "hostPath"), str(v, "containerPath") + if host != "" && container != "" { + plan.Volumes = append(plan.Volumes, map[string]string{"source": host, "target": container}) + } + } + return plan, warn +} + +// dflowProviderSettings picks the populated Settings group. +func dflowProviderSettings(svc map[string]interface{}) map[string]interface{} { + for _, key := range []string{"githubSettings", "gitlabSettings", "bitbucketSettings", "giteaSettings", "azureSettings"} { + if g, ok := svc[key].(map[string]interface{}); ok && str(g, "repository") != "" { + return g + } + } + return nil +} + +// dflowRepoURL builds a cloneable https URL for the known git providers. +func dflowRepoURL(providerType, owner, repo string) string { + repo = strings.TrimSuffix(strings.TrimSpace(repo), ".git") + owner = strings.TrimSpace(owner) + if repo == "" { + return "" + } + if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") { + return repo + } + switch providerType { + case "gitlab": + return "https://gitlab.com/" + owner + "/" + repo + case "bitbucket": + return "https://bitbucket.org/" + owner + "/" + repo + case "azureDevOps": + return "https://dev.azure.com/" + owner + "/_git/" + repo + case "gitea": + return "" // host unknown — user must reconnect the repo + default: + if owner == "" { + return "" + } + return "https://github.com/" + owner + "/" + repo + } +} + +// normalizeDockerImage fixes common dflow registry URL typos (ghrc://). +func normalizeDockerImage(image string) string { + image = strings.TrimSpace(image) + if strings.HasPrefix(image, "ghrc://") { + return "ghcr.io/" + strings.TrimPrefix(image, "ghrc://") + } + return image +} + +func listOf(m map[string]interface{}, key string) []map[string]interface{} { + raw, _ := m[key].([]interface{}) + out := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if v, ok := item.(map[string]interface{}); ok { + out = append(out, v) + } + } + return out +} + +func num(m map[string]interface{}, key string) float64 { + v, _ := m[key].(float64) + return v +} + +func firstOf(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func init() { + migrateDflowCmd.Flags().String("url", "", "dflow base URL (or DFLOW_URL env)") + migrateDflowCmd.Flags().String("api-key", "", "dflow admin API key (or DFLOW_API_KEY env)") + migrateDflowCmd.Flags().String("project", "", "dflow project id (lists yours when omitted and >1 exists)") + migrateDflowCmd.Flags().String("into", "", "existing Containr project id (default: create one)") + migrateDflowCmd.Flags().Bool("dry-run", false, "print the plan without creating anything") + MigrateCmd.AddCommand(migrateDflowCmd) +} diff --git a/app/backend/internal/cli/commands/migrate_test.go b/app/backend/internal/cli/commands/migrate_test.go index 6b769f3..67598a6 100644 --- a/app/backend/internal/cli/commands/migrate_test.go +++ b/app/backend/internal/cli/commands/migrate_test.go @@ -38,3 +38,85 @@ func TestEdgesOf(t *testing.T) { t.Fatalf("edgesOf(nil conn) = %+v, want empty", got) } } + +func TestDflowServicePlanOf(t *testing.T) { + appSvc := map[string]interface{}{ + "id": "s1", + "name": "api", + "type": "app", + "providerType": "github", + "githubSettings": map[string]interface{}{ + "owner": "acme", "repository": "api", "branch": "main", + "buildPath": "/", "port": float64(8080), + }, + "variables": []interface{}{ + map[string]interface{}{"key": "FOO", "value": "bar"}, + map[string]interface{}{"key": "EMPTY", "value": ""}, + }, + "domains": []interface{}{ + map[string]interface{}{"domain": "api.example.com"}, + }, + "volumes": []interface{}{ + map[string]interface{}{"hostPath": "/data", "containerPath": "/app/data"}, + }, + } + plan, warn := dflowServicePlanOf(appSvc) + if plan.Type != "web" || plan.GitRepo != "https://github.com/acme/api" || plan.Port != 8080 { + t.Fatalf("app plan = %+v", plan) + } + if plan.Variables["FOO"] != "bar" || len(plan.Domains) != 1 || len(plan.Volumes) != 1 { + t.Fatalf("plan detail = %+v", plan) + } + if len(warn) != 1 { // EMPTY value flagged + t.Fatalf("warnings = %v", warn) + } + + dbSvc := map[string]interface{}{ + "id": "s2", "name": "pg", "type": "database", + "databaseDetails": map[string]interface{}{ + "type": "postgres", "version": "16", "provider": "external", + "connectionUrl": "postgres://u:p@h:5432/db", + }, + } + dplan, _ := dflowServicePlanOf(dbSvc) + if dplan.Type != "database" || dplan.Image != "postgres:16" { + t.Fatalf("db plan = %+v", dplan) + } + if dplan.Variables["DATABASE_URL"] != "postgres://u:p@h:5432/db" { + t.Fatalf("db vars = %+v", dplan.Variables) + } + + dockerSvc := map[string]interface{}{ + "id": "s3", "name": "img", "type": "docker", + "dockerDetails": map[string]interface{}{ + "url": "ghrc://acme/web:v1", + "ports": []interface{}{ + map[string]interface{}{"hostPort": float64(80), "containerPort": float64(3000), "scheme": "http"}, + }, + }, + } + iplan, _ := dflowServicePlanOf(dockerSvc) + if iplan.Image != "ghcr.io/acme/web:v1" || iplan.Port != 3000 { + t.Fatalf("docker plan = %+v", iplan) + } +} + +func TestDflowRepoURL(t *testing.T) { + cases := []struct { + provider, owner, repo, want string + }{ + {"github", "acme", "api", "https://github.com/acme/api"}, + {"gitlab", "acme", "api", "https://gitlab.com/acme/api"}, + {"bitbucket", "acme", "api", "https://bitbucket.org/acme/api"}, + {"gitea", "acme", "api", ""}, + {"github", "acme", "api.git", "https://github.com/acme/api"}, + {"github", "", "api", ""}, + {"github", "acme", "", ""}, + {"github", "acme", "https://git.example.com/a/b", "https://git.example.com/a/b"}, + } + for _, c := range cases { + if got := dflowRepoURL(c.provider, c.owner, c.repo); got != c.want { + t.Errorf("dflowRepoURL(%q,%q,%q) = %q, want %q", c.provider, c.owner, c.repo, got, c.want) + } + } +} diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index ee8749a..b9e5a6c 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\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## 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## 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 3306552..2691ac9 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -75,6 +75,7 @@ nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs) nodes update|prune|commands rename/auto-prune, bounded docker prune, command history migrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan +migrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan scaling status|policies|services|scale ha status|alerts|health|policies|enable|disable|failover security scan|vulnerabilities|metrics