From ee43064c5c6837aa28426c1cbd6a15f9bb5c9062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:48:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(migrate):=20containr=20migrate=20railway?= =?UTF-8?q?=20=E2=80=94=20GraphQL=20import=20of=20services/vars/domains=20?= =?UTF-8?q?(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --token (or RAILWAY_TOKEN) reads the Railway project via backboard GraphQL: services resolve to git-repo or image services with command + replicas, variables and service/custom domains follow. --dry-run prints the plan; --into targets an existing project. Both account (Bearer) and project tokens (Project-Access-Token header) work. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 8 +- app/backend/internal/cli/commands/migrate.go | 372 ++++++++++++++++++ .../internal/cli/commands/migrate_test.go | 40 ++ app/backend/internal/cli/root.go | 1 + docs/guides/CLI.md | 1 + 5 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 app/backend/internal/cli/commands/migrate.go create mode 100644 app/backend/internal/cli/commands/migrate_test.go diff --git a/ROADMAP2.md b/ROADMAP2.md index 6081d64..17828cc 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -393,9 +393,11 @@ and wired refs, one click or one CLI call. ## 9. Phase H — Migration & ecosystem -- [ ] **Railway migrator** — `containr migrate railway --token --project - --dry-run`: Railway GraphQL → services/vars/domains → recreate via - PAT. Landing-page pitch: "off Railway in one command." +- [x] **Railway migrator** — `containr migrate railway --token --project + --environment --into --dry-run`: Railway GraphQL (backboard) → + services (git-repo/image + command + replicas), variables, custom + + service domains → recreates via the Containr API. Project tokens and + account tokens both supported. `cli/commands/migrate.go`. - [ ] **dflow importer** — `containr migrate dflow` via Payload REST export or Mongo dump: servers→nodes, projects, services(+vars/ domains/volumes), databases, templates. Onboards dflow refugees. diff --git a/app/backend/internal/cli/commands/migrate.go b/app/backend/internal/cli/commands/migrate.go new file mode 100644 index 0000000..9337b2b --- /dev/null +++ b/app/backend/internal/cli/commands/migrate.go @@ -0,0 +1,372 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const railwayAPI = "https://backboard.railway.com/graphql/v2" + +// railwaySource mirrors the Railway serviceInstance.source union. +type railwaySource struct { + Repo string `json:"repo"` + Image string `json:"image"` +} + +type railwayServicePlan struct { + ID string + Name string + Source railwaySource + Command string + Replicas int + Domains []string + Variables map[string]string +} + +// MigrateCmd imports workloads from other platforms. +var MigrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Import workloads from other platforms", +} + +var migrateRailwayCmd = &cobra.Command{ + Use: "railway", + Short: "Migrate a Railway project into Containr", + Long: `Pulls a Railway project's services, variables, and domains via the +public GraphQL API and recreates them in Containr. + + containr migrate railway --token --project --dry-run + containr migrate railway --token --project --into + +Token: account/workspace token (Authorization: Bearer) or a project token. +--into defaults to creating a project named after the Railway project.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := cmd.Flags().GetString("token") + if token == "" { + token = os.Getenv("RAILWAY_TOKEN") + } + if token == "" { + return &APIError{Message: "pass --token or set RAILWAY_TOKEN", ExitCode: ExitError} + } + projectID, _ := cmd.Flags().GetString("project") + envName, _ := cmd.Flags().GetString("environment") + into, _ := cmd.Flags().GetString("into") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + projectName, envID, services, err := railwayProject(projectID, envName, token) + if err != nil { + return err + } + if len(services) == 0 { + return &APIError{Message: "no services found in that Railway project/environment", ExitCode: ExitError} + } + + plans := make([]railwayServicePlan, 0, len(services)) + for _, svc := range services { + plan, err := railwayServiceDetail(svc, envID, token) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s: %v — skipping\n", svc["name"], err) + continue + } + plans = append(plans, plan) + } + if len(plans) == 0 { + return &APIError{Message: "no migratable services", ExitCode: ExitError} + } + + fmt.Printf("Railway project %q → %d service(s)\n", projectName, len(plans)) + for _, p := range plans { + src := p.Source.Image + if src == "" { + src = railwayRepoURL(p.Source.Repo) + } + fmt.Printf(" %s src=%s replicas=%d vars=%d domains=%d\n", + p.Name, src, max(p.Replicas, 1), len(p.Variables), len(p.Domains)) + } + + if dryRun { + fmt.Println("\nDry run — pass without --dry-run to create these resources.") + return nil + } + + c, err := client() + if err != nil { + return err + } + + target := into + if target == "" { + data, err := c.Do("POST", "/projects", map[string]interface{}{ + "name": projectName, + "description": "Imported from Railway", + }) + if err != nil { + return err + } + p, _ := unwrapObject(data, "project") + target = str(p, "id") + if target == "" || target == "-" { + return &APIError{Message: "project created but id missing from response", ExitCode: ExitError} + } + fmt.Printf("Created project %s (%s)\n", projectName, target) + } + + for _, p := range plans { + body := map[string]interface{}{"name": p.Name} + if p.Source.Image != "" { + body["type"] = "image" + body["image"] = p.Source.Image + } else if repo := railwayRepoURL(p.Source.Repo); repo != "" { + body["type"] = "git" + body["git_repo"] = repo + } else { + fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name) + continue + } + if p.Command != "" { + body["command"] = p.Command + } + if p.Replicas > 1 { + body["replicas"] = p.Replicas + } + data, err := c.Do("POST", "/projects/"+target+"/services", body) + if err != nil { + fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err) + continue + } + svc, _ := unwrapObject(data, "service") + svcID := str(svc, "id") + fmt.Printf(" created service %s (%s)\n", p.Name, svcID) + + if len(p.Variables) > 0 && svcID != "" && svcID != "-" { + vars := make([]map[string]interface{}, 0, len(p.Variables)) + for k, v := range p.Variables { + vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false}) + } + if _, err := c.Do("PUT", "/services/"+svcID+"/variables", + map[string]interface{}{"variables": vars}); err != nil { + fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err) + } + } + for _, domain := range p.Domains { + if svcID == "" || svcID == "-" { + break + } + if _, err := c.Do("POST", "/services/"+svcID+"/domains", + map[string]interface{}{"domain": domain}); err != nil { + fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err) + } + } + } + fmt.Println("Done. Railway deploy triggers are not migrated — redeploy each service when ready.") + return nil + }, +} + +// railwayGraphQL posts a query; Railway returns 200 with an errors array on +// partial failures — treat errors as fatal, the migration needs clean data. +func railwayGraphQL(token, query string, variables map[string]interface{}) (map[string]interface{}, error) { + body, _ := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) + req, err := http.NewRequest(http.MethodPost, railwayAPI, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + // Project tokens use a dedicated header; account/workspace tokens use Bearer. + if strings.HasPrefix(token, "rw_pat") || strings.HasPrefix(token, "rw_pt") { + req.Header.Set("Project-Access-Token", token) + } else { + req.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + var out struct { + Data map[string]interface{} `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("railway response not JSON (status %d)", resp.StatusCode) + } + if len(out.Errors) > 0 { + return nil, fmt.Errorf("railway: %s", out.Errors[0].Message) + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("railway status %d", resp.StatusCode) + } + return out.Data, nil +} + +// railwayProject resolves the project (by id, or sole accessible project) and +// returns its name, the target environment id, and its services. +func railwayProject(projectID, envName, token string) (string, string, []map[string]interface{}, error) { + if projectID == "" { + data, err := railwayGraphQL(token, + `query { projects(first: 100) { edges { node { id name } } } }`, nil) + if err != nil { + return "", "", nil, err + } + projects := edgesOf(data, "projects") + if len(projects) == 1 { + projectID = str(projects[0], "id") + } else { + for _, p := range projects { + fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name")) + } + return "", "", nil, &APIError{ + Message: "multiple projects — pass --project (listed above)", + ExitCode: ExitError, + } + } + } + + data, err := railwayGraphQL(token, `query ($id: String!) { + project(id: $id) { + name + environments { edges { node { id name } } } + services { edges { node { id name } } } + } + }`, map[string]interface{}{"id": projectID}) + if err != nil { + return "", "", nil, err + } + project, _ := data["project"].(map[string]interface{}) + if project == nil { + return "", "", nil, &APIError{Message: "railway project not found", ExitCode: ExitNotFound} + } + envs := edgesOf(project, "environments") + envID := "" + for _, e := range envs { + if str(e, "name") == envName { + envID = str(e, "id") + break + } + } + if envID == "" && len(envs) == 1 { + envID = str(envs[0], "id") + } + if envID == "" { + names := make([]string, 0, len(envs)) + for _, e := range envs { + names = append(names, str(e, "name")) + } + return "", "", nil, &APIError{ + Message: fmt.Sprintf("environment %q not found (have: %s)", envName, strings.Join(names, ", ")), + ExitCode: ExitError, + } + } + return str(project, "name"), envID, edgesOf(project, "services"), nil +} + +// railwayServiceDetail pulls source, command, replicas, domains, and +// variables for one service in the target environment. +func railwayServiceDetail(svc map[string]interface{}, envID, token string) (railwayServicePlan, error) { + plan := railwayServicePlan{ + ID: str(svc, "id"), + Name: str(svc, "name"), + } + data, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) { + serviceInstance(serviceId: $sid, environmentId: $eid) { + startCommand + numReplicas + source { repo image } + domains { serviceDomains { domain } customDomains { domain } } + } + }`, map[string]interface{}{"sid": plan.ID, "eid": envID}) + if err != nil { + return plan, err + } + inst, _ := data["serviceInstance"].(map[string]interface{}) + if inst == nil { + return plan, fmt.Errorf("no service instance in this environment") + } + if src, ok := inst["source"].(map[string]interface{}); ok { + plan.Source = railwaySource{Repo: str(src, "repo"), Image: str(src, "image")} + } + if cmd, ok := inst["startCommand"].(string); ok { + plan.Command = cmd + } + if n, ok := inst["numReplicas"].(float64); ok { + plan.Replicas = int(n) + } + if domains, ok := inst["domains"].(map[string]interface{}); ok { + for _, group := range []string{"serviceDomains", "customDomains"} { + if list, ok := domains[group].([]interface{}); ok { + for _, d := range list { + if m, ok := d.(map[string]interface{}); ok { + if dom := str(m, "domain"); dom != "" && dom != "-" { + plan.Domains = append(plan.Domains, dom) + } + } + } + } + } + } + + vdata, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) { + variables(serviceId: $sid, environmentId: $eid) + }`, map[string]interface{}{"sid": plan.ID, "eid": envID}) + if err == nil { + if vars, ok := vdata["variables"].(map[string]interface{}); ok { + plan.Variables = map[string]string{} + for k, v := range vars { + if s, ok := v.(string); ok { + // Railway reference variables (${{…}}) resolve at deploy + // time — carry them literally so they stay visible. + plan.Variables[k] = s + } + } + } + } + return plan, nil +} + +// railwayRepoURL normalizes Railway's "owner/repo" to a cloneable https URL. +func railwayRepoURL(repo string) string { + repo = strings.TrimSpace(repo) + if repo == "" || repo == "-" { + return "" + } + if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") { + return repo + } + return "https://github.com/" + repo +} + +func edgesOf(obj map[string]interface{}, key string) []map[string]interface{} { + conn, _ := obj[key].(map[string]interface{}) + edges, _ := conn["edges"].([]interface{}) + out := make([]map[string]interface{}, 0, len(edges)) + for _, e := range edges { + if m, ok := e.(map[string]interface{}); ok { + if node, ok := m["node"].(map[string]interface{}); ok { + out = append(out, node) + } + } + } + return out +} + +func init() { + migrateRailwayCmd.Flags().String("token", "", "Railway API token (or RAILWAY_TOKEN env)") + migrateRailwayCmd.Flags().String("project", "", "Railway project id (lists yours when omitted and >1 exists)") + migrateRailwayCmd.Flags().String("environment", "production", "Railway environment name") + migrateRailwayCmd.Flags().String("into", "", "existing Containr project id (default: create one)") + migrateRailwayCmd.Flags().Bool("dry-run", false, "print the plan without creating anything") + MigrateCmd.AddCommand(migrateRailwayCmd) +} diff --git a/app/backend/internal/cli/commands/migrate_test.go b/app/backend/internal/cli/commands/migrate_test.go new file mode 100644 index 0000000..6b769f3 --- /dev/null +++ b/app/backend/internal/cli/commands/migrate_test.go @@ -0,0 +1,40 @@ +package commands + +import "testing" + +func TestRailwayRepoURL(t *testing.T) { + cases := map[string]string{ + "owner/repo": "https://github.com/owner/repo", + "https://gitlab.com/a/b": "https://gitlab.com/a/b", + "git@github.com:a/b.git": "git@github.com:a/b.git", + "": "", + "-": "", + " spaced/x ": "https://github.com/spaced/x", + } + for in, want := range cases { + if got := railwayRepoURL(in); got != want { + t.Errorf("railwayRepoURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEdgesOf(t *testing.T) { + obj := map[string]interface{}{ + "services": map[string]interface{}{ + "edges": []interface{}{ + map[string]interface{}{"node": map[string]interface{}{"id": "a", "name": "web"}}, + map[string]interface{}{"node": map[string]interface{}{"id": "b", "name": "api"}}, + }, + }, + } + nodes := edgesOf(obj, "services") + if len(nodes) != 2 || str(nodes[0], "name") != "web" { + t.Fatalf("edgesOf returned %+v", nodes) + } + if got := edgesOf(obj, "missing"); len(got) != 0 { + t.Fatalf("edgesOf(missing) = %+v, want empty", got) + } + if got := edgesOf(map[string]interface{}{"services": nil}, "services"); len(got) != 0 { + t.Fatalf("edgesOf(nil conn) = %+v, want empty", got) + } +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index ac73b47..4c3b693 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -94,6 +94,7 @@ func init() { rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.UpCmd) + rootCmd.AddCommand(commands.MigrateCmd) } func initConfig() { diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index dfd3978..3306552 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -74,6 +74,7 @@ import compose nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs) nodes update|prune|commands rename/auto-prune, bounded docker prune, command history +migrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan scaling status|policies|services|scale ha status|alerts|health|policies|enable|disable|failover security scan|vulnerabilities|metrics