mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(migrate): containr migrate railway — GraphQL import of services/vars/domains (#51)
--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>
This commit is contained in:
1 parent
e054457156
commit
ee43064c5c
5 files changed
+419
-3
No files matched your search
+5
-3
@@ -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.
|
||||
|
||||
@@ -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 <railway-token> --project <id> --dry-run
|
||||
containr migrate railway --token <railway-token> --project <id> --into <project-id>
|
||||
|
||||
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 <id> (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)
|
||||
}
|
||||
@@ -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",
|
||||
"[email protected]:a/b.git": "[email protected]: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)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.BannersCmd)
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
rootCmd.AddCommand(commands.MigrateCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
|
||||
@@ -74,6 +74,7 @@ import compose <project-id> <compose.yml|->
|
||||
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 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
|
||||
|
||||
Reference in new issue
Block a user