mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
dflow importer pages the Payload REST collections (users API-Key auth) and recreates projects/services: app→git web services, docker →image, database→typed image with DATABASE_URL for external providers; variables (empty encrypted values flagged), bind volumes, and domains carried over; soft-deleted docs excluded. Also fixes the Railway migrator posting type "image"/"git" — the create API only accepts web|worker|database|cron and requires an environment, so every service create 400'd. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
442 lines
13 KiB
Go
442 lines
13 KiB
Go
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)
|
|
}
|