Compare commits

...
Author SHA1 Message Date
Tomáš DvořákandDevin 401daf69aa fix(cli): gate SIGWINCH resize watch behind unix build tag (#55)
syscall.SIGWINCH does not exist on Windows, breaking the release
cross-compile for windows/amd64.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 16:09:36 +02:00
Tomáš DvořákandDevin ae7e6b4991 feat(cli): containr migrate dflow + fix railway service types (#53)
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>
2026-10-09 14:37:26 +02:00
Tomáš DvořákandDevin ee43064c5c feat(migrate): containr migrate railway — GraphQL import of services/vars/domains (#51)
--token (or RAILWAY_TOKEN) reads the Railway project via backboard
GraphQL: services resolve to git-repo or image services with command +
replicas, variables and service/custom domains follow. --dry-run prints
the plan; --into targets an existing project. Both account (Bearer) and
project tokens (Project-Access-Token header) work.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:48:36 +02:00
Tomáš DvořákandDevin e054457156 feat(templates): remote template registry via CONTAINR_TEMPLATE_REGISTRY_URL (#52)
- GET /templates merges a remote Containr catalog as source:"registry"
  (5s timeout, 60s cache, offline-safe)
- GET /templates/:id falls through to the registry
- Deploy of a registry template materializes a deterministic private
  copy for the deployer — repeat deploys reuse it
- Templates page badges registry entries; openapi + env documented

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:46:35 +02:00
17 changed files with 1249 additions and 40 deletions

No files matched your search

+6
View File
@@ -47,6 +47,12 @@ SECRETS_KEY=
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence.
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
# Bundled agent binaries dir served at /api/agents/download/:platform.
# CONTAINR_AGENT_BIN_DIR=/agents
# 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.
# CONTAINR_TEMPLATE_REGISTRY_URL=https://templates.example.com
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
# Optional build-time API URL overrides. Leave empty for the default
# same-origin setup - the frontend nginx proxies /api to the backend, which
+17 -9
View File
@@ -393,19 +393,27 @@ and wired refs, one click or one CLI call.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **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] **Railway migrator** — `containr migrate railway --token --project
--environment --into --dry-run`: Railway GraphQL (backboard) →
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`.
- [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
graph-provisioned managed databases (`database_services.project_id`).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
remote Containr catalog into `GET /templates` as `source:"registry"`
(60s cache, dead registry never blocks local). `GET /templates/:id`
falls through to the registry; deploy materializes a deterministic
`reg-<hash>` private copy for the deployer so re-deploys don't dup.
`api/templateregistry.go`, `.env.example`.
## 9b. Phase I — Companion surfaces
@@ -0,0 +1,186 @@
package api
// Template registry — CONTAINR_TEMPLATE_REGISTRY_URL points at another
// Containr instance (or any server answering the same template shape):
// GET {base}/api/v1/templates → {"templates": [ServiceTemplate…]}
// GET {base}/api/v1/templates/:id → {"template": …, "config": …, "variables": …}
// Registry entries merge into the catalog as source:"registry"; deploying
// one materializes it as a user template for the deployer first. The
// bundled catalog always works offline — a dead registry never blocks it.
import (
"containr/internal/database/sqlcdb"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const registryCacheTTL = 60 * time.Second
var registryCache struct {
mu sync.Mutex
fetchedAt time.Time
templates []ServiceTemplate
}
func registryBaseURL() string {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("CONTAINR_TEMPLATE_REGISTRY_URL")), "/")
base = strings.TrimSuffix(base, "/api/v1")
return base
}
// fetchRegistryTemplates lists the remote catalog, cached for
// registryCacheTTL. Failures degrade to an empty list — never fatal.
func fetchRegistryTemplates(ctx context.Context) []ServiceTemplate {
if registryBaseURL() == "" {
return nil
}
registryCache.mu.Lock()
fresh := time.Since(registryCache.fetchedAt) < registryCacheTTL && registryCache.templates != nil
cached := registryCache.templates
registryCache.mu.Unlock()
if fresh {
return cached
}
var templates []ServiceTemplate
var payload struct {
Templates []ServiceTemplate `json:"templates"`
}
if err := registryGet(ctx, "/api/v1/templates", &payload); err == nil {
templates = payload.Templates
}
for i := range templates {
templates[i].Source = "registry"
}
registryCache.mu.Lock()
registryCache.fetchedAt = time.Now()
registryCache.templates = templates
registryCache.mu.Unlock()
return templates
}
// fetchRegistryTemplate fetches one remote template's full document
// (template + config + variables). nil when the registry lacks it.
func fetchRegistryTemplate(ctx context.Context, id string) *ServiceTemplate {
base := registryBaseURL()
if base == "" {
return nil
}
var payload struct {
Template ServiceTemplate `json:"template"`
Config json.RawMessage `json:"config"`
Variables []TemplateVariable `json:"variables"`
}
if err := registryGet(ctx, "/api/v1/templates/"+id, &payload); err != nil {
return nil
}
t := payload.Template
if t.ID == "" {
t.ID = id
}
t.Source = "registry"
// The detail endpoint returns config/variables decoded; store them back
// as raw JSON so they can persist unmodified into a local copy.
if len(payload.Config) > 0 {
t.Config = string(payload.Config)
}
if payload.Variables != nil {
if raw, err := json.Marshal(payload.Variables); err == nil {
t.Variables = string(raw)
}
}
return &t
}
func registryGet(ctx context.Context, path string, out interface{}) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryBaseURL()+path, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errRegistry{resp.StatusCode}
}
return json.NewDecoder(resp.Body).Decode(out)
}
type errRegistry struct{ status int }
func (e errRegistry) Error() string { return "template registry status " + http.StatusText(e.status) }
// respondRegistryTemplate answers GET /templates/:id for a registry-only
// template with the same envelope shape as local templates.
func respondRegistryTemplate(c *gin.Context, t *ServiceTemplate) {
config := json.RawMessage(t.Config)
if len(config) == 0 {
config = json.RawMessage(`{}`)
}
variables := json.RawMessage(t.Variables)
if len(variables) == 0 {
variables = json.RawMessage(`[]`)
}
c.JSON(http.StatusOK, gin.H{
"template": t,
"config": config,
"variables": variables,
})
}
// materializeRegistryTemplate copies a registry template into the local
// catalog as a private template owned by the deployer, so the normal
// deploy path (and future re-deploys) work without the registry. The
// local id is deterministic per (registry, template, user) so repeat
// deploys reuse the same copy instead of accumulating duplicates.
func materializeRegistryTemplate(c *gin.Context, q *sqlcdb.Queries, userID, templateID string) (sqlcdb.ServiceTemplate, error) {
ownerUUID, err := uuid.Parse(userID)
if err != nil {
return sqlcdb.ServiceTemplate{}, err
}
sum := sha256.Sum256([]byte(registryBaseURL() + "|" + templateID + "|" + userID))
localID := "reg-" + hex.EncodeToString(sum[:8])
if existing, err := q.GetServiceTemplateByID(c.Request.Context(), localID); err == nil {
return existing, nil
}
rt := fetchRegistryTemplate(c.Request.Context(), templateID)
if rt == nil {
return sqlcdb.ServiceTemplate{}, sql.ErrNoRows
}
variables := pqtype.NullRawMessage{}
if rt.Variables != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(rt.Variables), Valid: true}
}
if err := q.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: localID,
Name: rt.Name,
Description: nullableText(rt.Description),
Category: rt.Category,
Logo: nullableText(rt.Logo),
Config: json.RawMessage(rt.Config),
Variables: variables,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: false,
}); err != nil {
return sqlcdb.ServiceTemplate{}, err
}
return q.GetServiceTemplateByID(c.Request.Context(), localID)
}
+43 -15
View File
@@ -21,18 +21,20 @@ import (
)
type ServiceTemplate struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
// Source marks catalog origin: "" (local/official) or "registry".
Source string `json:"source,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TemplateConfig struct {
@@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
}
templates := make([]ServiceTemplate, 0, len(templateRows))
localIDs := make(map[string]bool, len(templateRows))
for _, row := range templateRows {
localIDs[row.ID] = true
templates = append(templates, mapSQLCTemplate(row))
}
// Merge the configured remote registry (CONTAINR_TEMPLATE_REGISTRY_URL).
// Registry ids never shadow local templates.
for _, t := range fetchRegistryTemplates(ctx) {
if localIDs[t.ID] {
continue
}
if category != "" && t.Category != category {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
@@ -112,6 +128,11 @@ func handleGetTemplate(c *gin.Context) {
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Registry fallback — remote templates materialize read-only.
if rt := fetchRegistryTemplate(c.Request.Context(), templateID); rt != nil {
respondRegistryTemplate(c, rt)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -190,11 +211,18 @@ func handleCreateFromTemplate(c *gin.Context) {
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
// Registry template — materialize a local copy for the
// deployer, then continue down the normal deploy path.
row, mErr := materializeRegistryTemplate(c, queries, userID, templateID)
if mErr != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
templateRow = row
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
}
template := mapSQLCTemplate(templateRow)
+6 -15
View File
@@ -4,9 +4,7 @@ import (
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
@@ -44,20 +42,13 @@ var ShellCmd = &cobra.Command{
if err == nil {
defer term.Restore(fd, oldState)
}
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
// Forward SIGWINCH resizes.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
defer signal.Stop(sigCh)
go func() {
for range sigCh {
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
sendSize := func() {
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
}()
}
sendSize()
defer watchResize(sendSize)()
}
done := make(chan struct{})
@@ -0,0 +1,30 @@
//go:build !windows
package commands
import (
"os"
"os/signal"
"syscall"
)
// watchResize forwards SIGWINCH events to send. Returns a stop func.
func watchResize(send func()) func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
done := make(chan struct{})
go func() {
for {
select {
case <-sigCh:
send()
case <-done:
return
}
}
}()
return func() {
signal.Stop(sigCh)
close(done)
}
}
@@ -0,0 +1,8 @@
//go:build windows
package commands
// watchResize is a no-op on Windows — console resize events don't map to a
// signal. The initial size is still sent, and Windows Terminal resize is rare
// enough to accept stale dimensions for now.
func watchResize(send func()) func() { return func() {} }
@@ -0,0 +1,374 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
)
const railwayAPI = "https://backboard.railway.com/graphql/v2"
// railwaySource mirrors the Railway serviceInstance.source union.
type railwaySource struct {
Repo string `json:"repo"`
Image string `json:"image"`
}
type railwayServicePlan struct {
ID string
Name string
Source railwaySource
Command string
Replicas int
Domains []string
Variables map[string]string
}
// MigrateCmd imports workloads from other platforms.
var MigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Import workloads from other platforms",
}
var migrateRailwayCmd = &cobra.Command{
Use: "railway",
Short: "Migrate a Railway project into Containr",
Long: `Pulls a Railway project's services, variables, and domains via the
public GraphQL API and recreates them in Containr.
containr migrate railway --token <railway-token> --project <id> --dry-run
containr migrate railway --token <railway-token> --project <id> --into <project-id>
Token: account/workspace token (Authorization: Bearer) or a project token.
--into defaults to creating a project named after the Railway project.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
token, _ := cmd.Flags().GetString("token")
if token == "" {
token = os.Getenv("RAILWAY_TOKEN")
}
if token == "" {
return &APIError{Message: "pass --token or set RAILWAY_TOKEN", ExitCode: ExitError}
}
projectID, _ := cmd.Flags().GetString("project")
envName, _ := cmd.Flags().GetString("environment")
into, _ := cmd.Flags().GetString("into")
dryRun, _ := cmd.Flags().GetBool("dry-run")
projectName, envID, services, err := railwayProject(projectID, envName, token)
if err != nil {
return err
}
if len(services) == 0 {
return &APIError{Message: "no services found in that Railway project/environment", ExitCode: ExitError}
}
plans := make([]railwayServicePlan, 0, len(services))
for _, svc := range services {
plan, err := railwayServiceDetail(svc, envID, token)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v — skipping\n", svc["name"], err)
continue
}
plans = append(plans, plan)
}
if len(plans) == 0 {
return &APIError{Message: "no migratable services", ExitCode: ExitError}
}
fmt.Printf("Railway project %q → %d service(s)\n", projectName, len(plans))
for _, p := range plans {
src := p.Source.Image
if src == "" {
src = railwayRepoURL(p.Source.Repo)
}
fmt.Printf(" %s src=%s replicas=%d vars=%d domains=%d\n",
p.Name, src, max(p.Replicas, 1), len(p.Variables), len(p.Domains))
}
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 Railway",
})
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": "web",
"environment": "production",
}
if p.Source.Image != "" {
body["image"] = p.Source.Image
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
body["git_repo"] = repo
} else {
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
continue
}
if p.Command != "" {
body["command"] = p.Command
}
if p.Replicas > 1 {
body["replicas"] = p.Replicas
}
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. Railway deploy triggers are not migrated — redeploy each service when ready.")
return nil
},
}
// railwayGraphQL posts a query; Railway returns 200 with an errors array on
// partial failures — treat errors as fatal, the migration needs clean data.
func railwayGraphQL(token, query string, variables map[string]interface{}) (map[string]interface{}, error) {
body, _ := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
req, err := http.NewRequest(http.MethodPost, railwayAPI, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Project tokens use a dedicated header; account/workspace tokens use Bearer.
if strings.HasPrefix(token, "rw_pat") || strings.HasPrefix(token, "rw_pt") {
req.Header.Set("Project-Access-Token", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
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, 4<<20))
var out struct {
Data map[string]interface{} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("railway response not JSON (status %d)", resp.StatusCode)
}
if len(out.Errors) > 0 {
return nil, fmt.Errorf("railway: %s", out.Errors[0].Message)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("railway status %d", resp.StatusCode)
}
return out.Data, nil
}
// railwayProject resolves the project (by id, or sole accessible project) and
// returns its name, the target environment id, and its services.
func railwayProject(projectID, envName, token string) (string, string, []map[string]interface{}, error) {
if projectID == "" {
data, err := railwayGraphQL(token,
`query { projects(first: 100) { edges { node { id name } } } }`, nil)
if err != nil {
return "", "", nil, err
}
projects := edgesOf(data, "projects")
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 <id> (listed above)",
ExitCode: ExitError,
}
}
}
data, err := railwayGraphQL(token, `query ($id: String!) {
project(id: $id) {
name
environments { edges { node { id name } } }
services { edges { node { id name } } }
}
}`, map[string]interface{}{"id": projectID})
if err != nil {
return "", "", nil, err
}
project, _ := data["project"].(map[string]interface{})
if project == nil {
return "", "", nil, &APIError{Message: "railway project not found", ExitCode: ExitNotFound}
}
envs := edgesOf(project, "environments")
envID := ""
for _, e := range envs {
if str(e, "name") == envName {
envID = str(e, "id")
break
}
}
if envID == "" && len(envs) == 1 {
envID = str(envs[0], "id")
}
if envID == "" {
names := make([]string, 0, len(envs))
for _, e := range envs {
names = append(names, str(e, "name"))
}
return "", "", nil, &APIError{
Message: fmt.Sprintf("environment %q not found (have: %s)", envName, strings.Join(names, ", ")),
ExitCode: ExitError,
}
}
return str(project, "name"), envID, edgesOf(project, "services"), nil
}
// railwayServiceDetail pulls source, command, replicas, domains, and
// variables for one service in the target environment.
func railwayServiceDetail(svc map[string]interface{}, envID, token string) (railwayServicePlan, error) {
plan := railwayServicePlan{
ID: str(svc, "id"),
Name: str(svc, "name"),
}
data, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
serviceInstance(serviceId: $sid, environmentId: $eid) {
startCommand
numReplicas
source { repo image }
domains { serviceDomains { domain } customDomains { domain } }
}
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
if err != nil {
return plan, err
}
inst, _ := data["serviceInstance"].(map[string]interface{})
if inst == nil {
return plan, fmt.Errorf("no service instance in this environment")
}
if src, ok := inst["source"].(map[string]interface{}); ok {
plan.Source = railwaySource{Repo: str(src, "repo"), Image: str(src, "image")}
}
if cmd, ok := inst["startCommand"].(string); ok {
plan.Command = cmd
}
if n, ok := inst["numReplicas"].(float64); ok {
plan.Replicas = int(n)
}
if domains, ok := inst["domains"].(map[string]interface{}); ok {
for _, group := range []string{"serviceDomains", "customDomains"} {
if list, ok := domains[group].([]interface{}); ok {
for _, d := range list {
if m, ok := d.(map[string]interface{}); ok {
if dom := str(m, "domain"); dom != "" && dom != "-" {
plan.Domains = append(plan.Domains, dom)
}
}
}
}
}
}
vdata, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
variables(serviceId: $sid, environmentId: $eid)
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
if err == nil {
if vars, ok := vdata["variables"].(map[string]interface{}); ok {
plan.Variables = map[string]string{}
for k, v := range vars {
if s, ok := v.(string); ok {
// Railway reference variables (${{…}}) resolve at deploy
// time — carry them literally so they stay visible.
plan.Variables[k] = s
}
}
}
}
return plan, nil
}
// railwayRepoURL normalizes Railway's "owner/repo" to a cloneable https URL.
func railwayRepoURL(repo string) string {
repo = strings.TrimSpace(repo)
if repo == "" || repo == "-" {
return ""
}
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
return repo
}
return "https://github.com/" + repo
}
func edgesOf(obj map[string]interface{}, key string) []map[string]interface{} {
conn, _ := obj[key].(map[string]interface{})
edges, _ := conn["edges"].([]interface{})
out := make([]map[string]interface{}, 0, len(edges))
for _, e := range edges {
if m, ok := e.(map[string]interface{}); ok {
if node, ok := m["node"].(map[string]interface{}); ok {
out = append(out, node)
}
}
}
return out
}
func init() {
migrateRailwayCmd.Flags().String("token", "", "Railway API token (or RAILWAY_TOKEN env)")
migrateRailwayCmd.Flags().String("project", "", "Railway project id (lists yours when omitted and >1 exists)")
migrateRailwayCmd.Flags().String("environment", "production", "Railway environment name")
migrateRailwayCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
migrateRailwayCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
MigrateCmd.AddCommand(migrateRailwayCmd)
}
@@ -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/<slug> returning {docs, page, totalPages, hasNextPage}. Auth is a
// Payload API key on the users collection: "Authorization: users API-Key <k>".
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 <key> --dry-run
containr migrate dflow --url https://dflow.example.com --api-key <key> --project <id>
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 <id> (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 <provider>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)
}
@@ -0,0 +1,122 @@
package commands
import "testing"
func TestRailwayRepoURL(t *testing.T) {
cases := map[string]string{
"owner/repo": "https://github.com/owner/repo",
"https://gitlab.com/a/b": "https://gitlab.com/a/b",
"[email protected]:a/b.git": "[email protected]:a/b.git",
"": "",
"-": "",
" spaced/x ": "https://github.com/spaced/x",
}
for in, want := range cases {
if got := railwayRepoURL(in); got != want {
t.Errorf("railwayRepoURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestEdgesOf(t *testing.T) {
obj := map[string]interface{}{
"services": map[string]interface{}{
"edges": []interface{}{
map[string]interface{}{"node": map[string]interface{}{"id": "a", "name": "web"}},
map[string]interface{}{"node": map[string]interface{}{"id": "b", "name": "api"}},
},
},
}
nodes := edgesOf(obj, "services")
if len(nodes) != 2 || str(nodes[0], "name") != "web" {
t.Fatalf("edgesOf returned %+v", nodes)
}
if got := edgesOf(obj, "missing"); len(got) != 0 {
t.Fatalf("edgesOf(missing) = %+v, want empty", got)
}
if got := edgesOf(map[string]interface{}{"services": nil}, "services"); len(got) != 0 {
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)
}
}
}
+1
View File
@@ -94,6 +94,7 @@ func init() {
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd)
rootCmd.AddCommand(commands.MigrateCmd)
}
func initConfig() {
@@ -644,6 +644,11 @@ export function TemplatesPage() {
Public
</span>
) : null}
{template.source === 'registry' ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
Registry
</span>
) : null}
</div>
<div className="flex items-center gap-2 mt-1">
<span
+2
View File
@@ -11094,6 +11094,8 @@ export interface components {
owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */
is_public?: boolean;
/** @description Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first. */
source?: string;
/**
* Format: date-time
* @description Creation timestamp
+1 -1
View File
@@ -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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <user@host> 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 <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <user@host> 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|anLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+2
View File
@@ -181,6 +181,7 @@ export type TemplateEntity = {
isOfficial: boolean;
ownerId: string | null;
isPublic: boolean;
source?: string;
createdAt?: string;
updatedAt?: string;
};
@@ -751,6 +752,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public),
source: template.source,
createdAt: template.created_at,
updatedAt: template.updated_at,
};
+3
View File
@@ -7480,6 +7480,9 @@ components:
is_public:
type: boolean
description: Whether a user-created template is published to the public catalog
source:
type: string
description: 'Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first.'
created_at:
type: string
format: date-time
+2
View File
@@ -74,6 +74,8 @@ import compose <project-id> <compose.yml|->
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
nodes add <user@host> 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