mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7e6b4991 | ||
|
|
ee43064c5c | ||
|
|
e054457156 |
No files matched your search
@@ -47,6 +47,12 @@ SECRETS_KEY=
|
|||||||
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
# 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
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
# Optional build-time API URL overrides. Leave empty for the default
|
# Optional build-time API URL overrides. Leave empty for the default
|
||||||
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
||||||
|
|||||||
+12
-6
@@ -398,16 +398,22 @@ and wired refs, one click or one CLI call.
|
|||||||
services (git-repo/image + command + replicas), variables, custom +
|
services (git-repo/image + command + replicas), variables, custom +
|
||||||
service domains → recreates via the Containr API. Project tokens and
|
service domains → recreates via the Containr API. Project tokens and
|
||||||
account tokens both supported. `cli/commands/migrate.go`.
|
account tokens both supported. `cli/commands/migrate.go`.
|
||||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
- [x] **dflow importer** — `containr migrate dflow --url --api-key
|
||||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
--project --into --dry-run`: pages the Payload REST collections
|
||||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
(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`
|
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||||
deploys a compose file straight into a project; CLI `import compose`,
|
deploys a compose file straight into a project; CLI `import compose`,
|
||||||
MCP tool, workspace import modal; project delete cascades to
|
MCP tool, workspace import modal; project delete cascades to
|
||||||
graph-provisioned managed databases (`database_services.project_id`).
|
graph-provisioned managed databases (`database_services.project_id`).
|
||||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
|
||||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
remote Containr catalog into `GET /templates` as `source:"registry"`
|
||||||
principle — nothing phones home).
|
(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
|
## 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)
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ type ServiceTemplate struct {
|
|||||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
IsOfficial bool `json:"is_official" db:"is_official"`
|
||||||
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
||||||
IsPublic bool `json:"is_public" db:"is_public"`
|
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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templates := make([]ServiceTemplate, 0, len(templateRows))
|
templates := make([]ServiceTemplate, 0, len(templateRows))
|
||||||
|
localIDs := make(map[string]bool, len(templateRows))
|
||||||
for _, row := range templateRows {
|
for _, row := range templateRows {
|
||||||
|
localIDs[row.ID] = true
|
||||||
templates = append(templates, mapSQLCTemplate(row))
|
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})
|
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)
|
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,12 +211,19 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
|
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// 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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
templateRow = row
|
||||||
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
template := mapSQLCTemplate(templateRow)
|
template := mapSQLCTemplate(templateRow)
|
||||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||||
|
|||||||
@@ -121,12 +121,14 @@ Token: account/workspace token (Authorization: Bearer) or a project token.
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range plans {
|
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 != "" {
|
if p.Source.Image != "" {
|
||||||
body["type"] = "image"
|
|
||||||
body["image"] = p.Source.Image
|
body["image"] = p.Source.Image
|
||||||
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
|
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
|
||||||
body["type"] = "git"
|
|
||||||
body["git_repo"] = repo
|
body["git_repo"] = repo
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
|
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -38,3 +38,85 @@ func TestEdgesOf(t *testing.T) {
|
|||||||
t.Fatalf("edgesOf(nil conn) = %+v, want empty", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -644,6 +644,11 @@ export function TemplatesPage() {
|
|||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -11094,6 +11094,8 @@ export interface components {
|
|||||||
owner_id?: string | null;
|
owner_id?: string | null;
|
||||||
/** @description Whether a user-created template is published to the public catalog */
|
/** @description Whether a user-created template is published to the public catalog */
|
||||||
is_public?: boolean;
|
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
|
* Format: date-time
|
||||||
* @description Creation timestamp
|
* @description Creation timestamp
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\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",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export type TemplateEntity = {
|
|||||||
isOfficial: boolean;
|
isOfficial: boolean;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
source?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
@@ -751,6 +752,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
|
|||||||
isOfficial: Boolean(template.is_official),
|
isOfficial: Boolean(template.is_official),
|
||||||
ownerId: template.owner_id ?? null,
|
ownerId: template.owner_id ?? null,
|
||||||
isPublic: Boolean(template.is_public),
|
isPublic: Boolean(template.is_public),
|
||||||
|
source: template.source,
|
||||||
createdAt: template.created_at,
|
createdAt: template.created_at,
|
||||||
updatedAt: template.updated_at,
|
updatedAt: template.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7480,6 +7480,9 @@ components:
|
|||||||
is_public:
|
is_public:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether a user-created template is published to the public catalog
|
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:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ 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 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
|
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 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
|
scaling status|policies|services|scale
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
security scan|vulnerabilities|metrics
|
security scan|vulnerabilities|metrics
|
||||||
|
|||||||
Reference in new issue
Block a user