feat(cli): containr migrate dflow + fix railway service types (#53)

dflow importer pages the Payload REST collections (users API-Key
auth) and recreates projects/services: app→git web services, docker
→image, database→typed image with DATABASE_URL for external
providers; variables (empty encrypted values flagged), bind volumes,
and domains carried over; soft-deleted docs excluded.

Also fixes the Railway migrator posting type "image"/"git" — the
create API only accepts web|worker|database|cron and requires an
environment, so every service create 400'd.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 14:37:26 +02:00
1 parent ee43064c5c
commit ae7e6b4991
6 files changed
+536 -7

No files matched your search

@@ -38,3 +38,85 @@ func TestEdgesOf(t *testing.T) {
t.Fatalf("edgesOf(nil conn) = %+v, want empty", got)
}
}
func TestDflowServicePlanOf(t *testing.T) {
appSvc := map[string]interface{}{
"id": "s1",
"name": "api",
"type": "app",
"providerType": "github",
"githubSettings": map[string]interface{}{
"owner": "acme", "repository": "api", "branch": "main",
"buildPath": "/", "port": float64(8080),
},
"variables": []interface{}{
map[string]interface{}{"key": "FOO", "value": "bar"},
map[string]interface{}{"key": "EMPTY", "value": ""},
},
"domains": []interface{}{
map[string]interface{}{"domain": "api.example.com"},
},
"volumes": []interface{}{
map[string]interface{}{"hostPath": "/data", "containerPath": "/app/data"},
},
}
plan, warn := dflowServicePlanOf(appSvc)
if plan.Type != "web" || plan.GitRepo != "https://github.com/acme/api" || plan.Port != 8080 {
t.Fatalf("app plan = %+v", plan)
}
if plan.Variables["FOO"] != "bar" || len(plan.Domains) != 1 || len(plan.Volumes) != 1 {
t.Fatalf("plan detail = %+v", plan)
}
if len(warn) != 1 { // EMPTY value flagged
t.Fatalf("warnings = %v", warn)
}
dbSvc := map[string]interface{}{
"id": "s2", "name": "pg", "type": "database",
"databaseDetails": map[string]interface{}{
"type": "postgres", "version": "16", "provider": "external",
"connectionUrl": "postgres://u:p@h:5432/db",
},
}
dplan, _ := dflowServicePlanOf(dbSvc)
if dplan.Type != "database" || dplan.Image != "postgres:16" {
t.Fatalf("db plan = %+v", dplan)
}
if dplan.Variables["DATABASE_URL"] != "postgres://u:p@h:5432/db" {
t.Fatalf("db vars = %+v", dplan.Variables)
}
dockerSvc := map[string]interface{}{
"id": "s3", "name": "img", "type": "docker",
"dockerDetails": map[string]interface{}{
"url": "ghrc://acme/web:v1",
"ports": []interface{}{
map[string]interface{}{"hostPort": float64(80), "containerPort": float64(3000), "scheme": "http"},
},
},
}
iplan, _ := dflowServicePlanOf(dockerSvc)
if iplan.Image != "ghcr.io/acme/web:v1" || iplan.Port != 3000 {
t.Fatalf("docker plan = %+v", iplan)
}
}
func TestDflowRepoURL(t *testing.T) {
cases := []struct {
provider, owner, repo, want string
}{
{"github", "acme", "api", "https://github.com/acme/api"},
{"gitlab", "acme", "api", "https://gitlab.com/acme/api"},
{"bitbucket", "acme", "api", "https://bitbucket.org/acme/api"},
{"gitea", "acme", "api", ""},
{"github", "acme", "api.git", "https://github.com/acme/api"},
{"github", "", "api", ""},
{"github", "acme", "", ""},
{"github", "acme", "https://git.example.com/a/b", "https://git.example.com/a/b"},
}
for _, c := range cases {
if got := dflowRepoURL(c.provider, c.owner, c.repo); got != c.want {
t.Errorf("dflowRepoURL(%q,%q,%q) = %q, want %q", c.provider, c.owner, c.repo, got, c.want)
}
}
}