mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee43064c5c | ||
|
|
e054457156 |
No files matched your search
@@ -47,6 +47,12 @@ 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
|
||||||
|
|||||||
+11
-6
@@ -393,9 +393,11 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 9. Phase H — Migration & ecosystem
|
## 9. Phase H — Migration & ecosystem
|
||||||
|
|
||||||
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
- [x] **Railway migrator** — `containr migrate railway --token --project
|
||||||
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
--environment --into --dry-run`: Railway GraphQL (backboard) →
|
||||||
PAT. Landing-page pitch: "off Railway in one command."
|
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
|
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||||
@@ -403,9 +405,12 @@ and wired refs, one click or one CLI call.
|
|||||||
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`).
|
||||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
|
||||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
remote Containr catalog into `GET /templates` as `source:"registry"`
|
||||||
principle — nothing phones home).
|
(60s cache, dead registry never blocks local). `GET /templates/:id`
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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,18 +21,20 @@ 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"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
// Source marks catalog origin: "" (local/official) or "registry".
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
Source string `json:"source,omitempty" db:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateConfig struct {
|
type TemplateConfig struct {
|
||||||
@@ -97,10 +99,24 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +128,11 @@ 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
|
||||||
}
|
}
|
||||||
@@ -190,11 +211,18 @@ 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) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
// Registry template — materialize a local copy for the
|
||||||
|
// 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)
|
||||||
|
|||||||
@@ -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.BannersCmd)
|
||||||
rootCmd.AddCommand(commands.InvitesCmd)
|
rootCmd.AddCommand(commands.InvitesCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
|
rootCmd.AddCommand(commands.MigrateCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
|
|||||||
@@ -644,6 +644,11 @@ 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,6 +11094,8 @@ 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
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -751,6 +752,7 @@ 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,6 +7480,9 @@ 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
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import compose <project-id> <compose.yml|->
|
|||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
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
|
||||||
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