mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463f67356e | ||
|
|
bcb4d30d1a |
No files matched your search
@@ -47,12 +47,6 @@ 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
|
||||||
|
|||||||
+6
-12
@@ -398,22 +398,16 @@ 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`.
|
||||||
- [x] **dflow importer** — `containr migrate dflow --url --api-key
|
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||||
--project --into --dry-run`: pages the Payload REST collections
|
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||||
(users API-Key auth) → services (app→git/web, docker→image,
|
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||||
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`).
|
||||||
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
|
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||||
remote Containr catalog into `GET /templates` as `source:"registry"`
|
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||||
(60s cache, dead registry never blocks local). `GET /templates/:id`
|
principle — nothing phones home).
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -21,20 +21,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ServiceTemplate struct {
|
type ServiceTemplate struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
Description string `json:"description" db:"description"`
|
Description string `json:"description" db:"description"`
|
||||||
Category string `json:"category" db:"category"`
|
Category string `json:"category" db:"category"`
|
||||||
Logo string `json:"logo" db:"logo"`
|
Logo string `json:"logo" db:"logo"`
|
||||||
Config string `json:"config" db:"config"`
|
Config string `json:"config" db:"config"`
|
||||||
Variables string `json:"variables" db:"variables"`
|
Variables string `json:"variables" db:"variables"`
|
||||||
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".
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
Source string `json:"source,omitempty" db:"-"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateConfig struct {
|
type TemplateConfig struct {
|
||||||
@@ -99,24 +97,10 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +112,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -211,18 +190,11 @@ 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
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
template := mapSQLCTemplate(templateRow)
|
template := mapSQLCTemplate(templateRow)
|
||||||
|
|||||||
@@ -121,14 +121,12 @@ Token: account/workspace token (Authorization: Bearer) or a project token.
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{"name": p.Name}
|
||||||
"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)
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
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,85 +38,3 @@ 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,11 +644,6 @@ 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,8 +11094,6 @@ 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\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
|
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -752,7 +751,6 @@ 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,9 +7480,6 @@ 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,7 +75,6 @@ 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