diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 46579eb..93c2472 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -23,6 +23,8 @@ import ( "strings" "time" + "github.com/google/uuid" + dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" @@ -378,6 +380,13 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri now := time.Now() version := h.getDefaultVersion(dbType) + var projectID uuid.NullUUID + if req.ProjectID != "" { + if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil { + projectID = uuid.NullUUID{UUID: parsed, Valid: true} + } + } + err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{ ID: databaseID, UserID: userID, @@ -388,6 +397,7 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri Plan: plan, Region: region, PublicPort: req.PublicPort, + ProjectID: projectID, CreatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true}, }) diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go index 47f06d3..037bb11 100644 --- a/app/backend/internal/api/projects.go +++ b/app/backend/internal/api/projects.go @@ -7,6 +7,7 @@ import ( "context" "database/sql" "errors" + "log" "net/http" "strings" "time" @@ -297,6 +298,28 @@ func handleDeleteProject(c *gin.Context) { return } + // Managed databases provisioned into this project (graph templates, + // compose imports) carry project_id and must be torn down first — their + // containers are not labelled containr.project and hold references to the + // project network. + if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil { + if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true}) + if listErr == nil { + for _, projectDB := range projectDBs { + if projectDB.Provider != "external" { + if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil { + log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err) + } + } + _ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID) + } + } + cancel() + } + } + // Remove live containers and the project network before dropping rows. // Best-effort: Docker may be unavailable; DB cleanup proceeds regardless. if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 950dbd8..1e56ecd 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -333,6 +333,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // publishes them. authed.POST("/projects", handleCreateProject) authed.POST("/projects/:id/services", handleCreateService) + authed.POST("/projects/:id/import-compose", handleImportProjectCompose) authed.PUT("/projects/:id", handleUpdateProject) authed.DELETE("/projects/:id", handleDeleteProject) diff --git a/app/backend/internal/api/templategraph.go b/app/backend/internal/api/templategraph.go index d6f9488..868c8e1 100644 --- a/app/backend/internal/api/templategraph.go +++ b/app/backend/internal/api/templategraph.go @@ -12,6 +12,7 @@ import ( "math/big" "net/http" "regexp" + "sort" "strconv" "strings" "time" @@ -948,6 +949,88 @@ func handleDeployInlineGraph(c *gin.Context) { }) } +// handleImportProjectCompose deploys a docker-compose file straight into a +// project — parse → graph specs → deployTemplateGraph, no template row. +func handleImportProjectCompose(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + ctx := c.Request.Context() + + projectID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + queries := sqlcdb.New(db.DB) + ownerID, err := queries.GetProjectOwnerID(ctx, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"}) + return + } + if ownerID.String() != userID && !contextIsAdmin(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + var req struct { + ComposeYAML string `json:"compose_yaml" binding:"required"` + Variables map[string]string `json:"variables"` + Plan string `json:"plan"` + Region string `json:"region"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.ComposeYAML) > 512*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"}) + return + } + + var file composeFileDef + if err := yaml.Unmarshal([]byte(req.ComposeYAML), &file); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()}) + return + } + specs, warnings, convErr := composeToSpecs(&file) + if convErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()}) + return + } + + var engine *deployment.DeploymentEngine + if v, exists := c.Get("deployment_engine"); exists && v != nil { + engine, _ = v.(*deployment.DeploymentEngine) + } + var dbHandler *DatabaseHandler + if v, exists := c.Get("database_handler"); exists { + dbHandler, _ = v.(*DatabaseHandler) + } + + created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID, + specs, nil, req.Variables, req.Plan, req.Region, "compose-import") + if fail != nil { + status := http.StatusBadRequest + if (*fail)["code"] == "CONFLICT" { + status = http.StatusConflict + } + c.JSON(status, *fail) + return + } + c.JSON(http.StatusCreated, gin.H{ + "resource": "stack", + "created": created, + "warnings": warnings, + }) +} + func dedupStrings(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) @@ -1012,26 +1095,38 @@ func handleImportCompose(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()}) return } - if len(file.Services) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file defines no services"}) + services, warnings, convErr := composeToSpecs(&file) + if convErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()}) return } - - warnings := []string{} - services := make([]TemplateServiceSpec, 0, len(file.Services)) variables := []TemplateVariable{} - // Compose service names become keys; declaration order follows YAML - // document order — yaml.v3 maps lose order, so sort keys for stability. + config := TemplateConfig{Version: 2, Services: services} + configJSON, _ := json.Marshal(config) + + c.JSON(http.StatusOK, gin.H{ + "config": json.RawMessage(configJSON), + "variables": variables, + "warnings": warnings, + }) +} + +// composeToSpecs converts a parsed compose file into graph service specs. +// Compose service names become keys; declaration order follows YAML document +// order — yaml.v3 maps lose order, so sort keys for stability. +func composeToSpecs(file *composeFileDef) ([]TemplateServiceSpec, []string, error) { + if len(file.Services) == 0 { + return nil, nil, fmt.Errorf("compose file defines no services") + } + warnings := []string{} + services := make([]TemplateServiceSpec, 0, len(file.Services)) + names := make([]string, 0, len(file.Services)) for name := range file.Services { names = append(names, name) } - for i := 1; i < len(names); i++ { - for j := i; j > 0 && names[j] < names[j-1]; j-- { - names[j], names[j-1] = names[j-1], names[j] - } - } + sort.Strings(names) for _, name := range names { svc := file.Services[name] @@ -1062,17 +1157,15 @@ func handleImportCompose(c *gin.Context) { if engineHint != "" && svc.Build == nil { spec.Type = "database" spec.Runtime = engineHint - spec.Environment = composeEnvironment(svc.Environment, warnings, key) + spec.Environment = composeEnvironment(svc.Environment, &warnings, key) } else { spec.Type = "web" spec.Runtime = imageName - spec.Environment = composeEnvironment(svc.Environment, warnings, key) + spec.Environment = composeEnvironment(svc.Environment, &warnings, key) spec.Port = composeFirstPort(svc.Ports) spec.StartCommand = composeCommand(svc.Command) - if svc.Build != nil { - if imageName == "" { - warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key)) - } + if svc.Build != nil && imageName == "" { + warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key)) } if spec.Runtime == "" { warnings = append(warnings, fmt.Sprintf("%s: no image defined — set a runtime before deploying", key)) @@ -1089,28 +1182,19 @@ func handleImportCompose(c *gin.Context) { } if len(services) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file produced no importable services"}) - return + return nil, warnings, fmt.Errorf("compose file produced no importable services") } - - config := TemplateConfig{Version: 2, Services: services} - configJSON, _ := json.Marshal(config) - - c.JSON(http.StatusOK, gin.H{ - "config": json.RawMessage(configJSON), - "variables": variables, - "warnings": warnings, - }) + return services, warnings, nil } -func composeEnvironment(env interface{}, warnings []string, svcKey string) map[string]string { +func composeEnvironment(env interface{}, warnings *[]string, svcKey string) map[string]string { out := map[string]string{} switch e := env.(type) { case map[string]interface{}: for k, v := range e { if v == nil { // `KEY:` in a list/map forwards the host value — flag it. - warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, k)) + *warnings = append(*warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, k)) continue } out[k] = fmt.Sprintf("%v", v) @@ -1124,7 +1208,7 @@ func composeEnvironment(env interface{}, warnings []string, svcKey string) map[s if idx := strings.Index(s, "="); idx > 0 { out[s[:idx]] = s[idx+1:] } else { - warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, s)) + *warnings = append(*warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, s)) } } } diff --git a/app/backend/internal/api/templategraph_compose_test.go b/app/backend/internal/api/templategraph_compose_test.go new file mode 100644 index 0000000..b0a468a --- /dev/null +++ b/app/backend/internal/api/templategraph_compose_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) { + t.Helper() + var file composeFileDef + if err := yaml.Unmarshal([]byte(y), &file); err != nil { + t.Fatalf("yaml: %v", err) + } + return composeToSpecs(&file) +} + +func TestComposeToSpecsConvertsStack(t *testing.T) { + specs, warnings, err := composeToSpecsForTest(t, `services: + web: + image: traefik/whoami:latest + ports: + - "80" + environment: + GREETING: hello-compose + depends_on: + - db + db: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: pgsecret123 + POSTGRES_DB: appdb +`) + if err != nil { + t.Fatal(err) + } + if len(specs) != 2 { + t.Fatalf("want 2 specs, got %d", len(specs)) + } + db, web := specs[0], specs[1] + if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" { + t.Errorf("db spec wrong: %+v", db) + } + if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" { + t.Errorf("db env lost: %v", db.Environment) + } + if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" { + t.Errorf("web spec wrong: %+v", web) + } + if web.Port != 80 { + t.Errorf("web port = %d, want 80", web.Port) + } + if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" { + t.Errorf("web depends_on = %v, want [db]", web.DependsOn) + } + if len(warnings) != 0 { + t.Errorf("unexpected warnings: %v", warnings) + } +} + +func TestComposeToSpecsDeterministicOrder(t *testing.T) { + y := `services: + zebra: + image: alpine + alpha: + image: alpine +` + for i := 0; i < 5; i++ { + specs, _, err := composeToSpecsForTest(t, y) + if err != nil { + t.Fatal(err) + } + if specs[0].Key != "alpha" || specs[1].Key != "zebra" { + t.Fatalf("unstable order: %v", specs) + } + } +} + +func TestComposeToSpecsDatabaseImages(t *testing.T) { + cases := map[string]string{ + "postgres:16": "postgresql", + "redis:7": "redis", + "mysql:8": "mysql", + "mariadb:11": "mariadb", + "mongo:7": "mongodb", + "clickhouse:24": "clickhouse", + } + for image, want := range cases { + specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n") + if err != nil { + t.Fatalf("%s: %v", image, err) + } + if specs[0].Type != "database" || specs[0].Runtime != want { + t.Errorf("%s → %+v, want database/%s", image, specs[0], want) + } + } +} + +func TestComposeToSpecsNoServices(t *testing.T) { + _, _, err := composeToSpecsForTest(t, "version: '3'\n") + if err == nil || !strings.Contains(err.Error(), "no services") { + t.Errorf("want no-services error, got %v", err) + } +} + +func TestComposeToSpecsWarnings(t *testing.T) { + _, warnings, err := composeToSpecsForTest(t, `services: + app: + build: . + orphan: + image: alpine + environment: + - UNSET_VAR +`) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(warnings, "\n") + if !strings.Contains(joined, "build contexts are not supported") { + t.Errorf("missing build warning: %v", warnings) + } + if !strings.Contains(joined, "UNSET_VAR") { + t.Errorf("missing env warning: %v", warnings) + } +} diff --git a/app/backend/internal/cli/commands/import.go b/app/backend/internal/cli/commands/import.go new file mode 100644 index 0000000..df8d319 --- /dev/null +++ b/app/backend/internal/cli/commands/import.go @@ -0,0 +1,57 @@ +package commands + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var importVars []string + +var ImportCmd = &cobra.Command{ + Use: "import", + Short: "Import external definitions into Containr", +} + +var importComposeCmd = &cobra.Command{ + Use: "compose ", + Short: "Deploy a docker-compose file straight into a project (no template)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + var raw []byte + var err error + if args[1] == "-" { + raw, err = io.ReadAll(os.Stdin) + } else { + raw, err = os.ReadFile(args[1]) + } + if err != nil { + return fmt.Errorf("cannot read compose file: %w", err) + } + vars, err := parseKVFlags(importVars) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{"compose_yaml": string(raw)} + if len(vars) > 0 { + body["variables"] = vars + } + data, err := c.Do("POST", "/projects/"+args[0]+"/import-compose", body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +func init() { + importComposeCmd.Flags().StringArrayVar(&importVars, "var", nil, "Variable override (KEY=VALUE, repeatable)") + ImportCmd.AddCommand(importComposeCmd) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 6f8dbb1..ad0dedb 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -79,6 +79,7 @@ func init() { rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.TemplatesCmd) + rootCmd.AddCommand(commands.ImportCmd) rootCmd.AddCommand(commands.NodesCmd) rootCmd.AddCommand(commands.ScalingCmd) rootCmd.AddCommand(commands.HACmd) diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index c715fa3..b46c800 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -8,6 +8,8 @@ package sqlcdb import ( "context" "database/sql" + + "github.com/google/uuid" ) const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one @@ -55,22 +57,23 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa } const createDatabaseService = `-- name: CreateDatabaseService :exec -INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ` type CreateDatabaseServiceParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - PublicPort bool `json:"public_port"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + PublicPort bool `json:"public_port"` + ProjectID uuid.NullUUID `json:"project_id"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error { @@ -84,6 +87,7 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS arg.Plan, arg.Region, arg.PublicPort, + arg.ProjectID, arg.CreatedAt, arg.UpdatedAt, ) @@ -154,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data return exists, err } +const deleteDatabaseServiceByID = `-- name: DeleteDatabaseServiceByID :exec +DELETE FROM database_services +WHERE id = $1 +` + +func (q *Queries) DeleteDatabaseServiceByID(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteDatabaseServiceByID, id) + return err +} + const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec DELETE FROM database_services WHERE id = $1 AND user_id = $2 @@ -199,7 +213,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a } const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 ` @@ -228,6 +242,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa &i.ExternalSsl, &i.PublicPort, &i.BackupTargetID, + &i.ProjectID, &i.CreatedAt, &i.UpdatedAt, ) @@ -235,7 +250,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa } const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 AND user_id = $2 ` @@ -269,6 +284,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData &i.ExternalSsl, &i.PublicPort, &i.BackupTargetID, + &i.ProjectID, &i.CreatedAt, &i.UpdatedAt, ) @@ -289,7 +305,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str } const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at FROM database_services ORDER BY created_at DESC ` @@ -324,6 +340,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic &i.ExternalSsl, &i.PublicPort, &i.BackupTargetID, + &i.ProjectID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -387,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg return items, nil } +const listDatabaseServicesByProject = `-- name: ListDatabaseServicesByProject :many +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at +FROM database_services +WHERE project_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) { + rows, err := q.db.QueryContext(ctx, listDatabaseServicesByProject, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DatabaseService{} + for rows.Next() { + var i DatabaseService + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Type, + &i.Status, + &i.Version, + &i.Plan, + &i.Region, + &i.ConnectionUrl, + &i.BackupSchedule, + &i.NextBackupAt, + &i.Provider, + &i.ExternalHost, + &i.ExternalPort, + &i.ExternalName, + &i.ExternalUsername, + &i.ExternalPassword, + &i.ExternalSsl, + &i.PublicPort, + &i.BackupTargetID, + &i.ProjectID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many -SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC @@ -424,6 +495,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) &i.ExternalSsl, &i.PublicPort, &i.BackupTargetID, + &i.ProjectID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 44a3498..b7bd99d 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -116,6 +116,7 @@ type DatabaseService struct { ExternalSsl bool `json:"external_ssl"` PublicPort bool `json:"public_port"` BackupTargetID sql.NullString `json:"backup_target_id"` + ProjectID uuid.NullUUID `json:"project_id"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index e3a7ad8..c4ad8c1 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -34,6 +34,7 @@ type Querier interface { DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error + DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) @@ -65,6 +66,7 @@ type Querier interface { ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) + ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index c54d0df..ec2297f 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -134,6 +134,7 @@ func init() { register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false}) register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false}) register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false}) + register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false}) // Notifications register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) diff --git a/app/backend/migrations_goose/20261102000000_database_services_project.sql b/app/backend/migrations_goose/20261102000000_database_services_project.sql new file mode 100644 index 0000000..6388ae8 --- /dev/null +++ b/app/backend/migrations_goose/20261102000000_database_services_project.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Link managed databases provisioned by graph template deploys / compose +-- imports to their project so deleting the project cleans them up too. +ALTER TABLE database_services + ADD COLUMN IF NOT EXISTS project_id uuid NULL REFERENCES projects(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_database_services_project_id + ON database_services(project_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_database_services_project_id; +ALTER TABLE database_services DROP COLUMN IF EXISTS project_id; diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index 5a1eff9..0418cab 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -35,8 +35,18 @@ FROM database_services WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name)); -- name: CreateDatabaseService :exec -INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); +INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); + +-- name: ListDatabaseServicesByProject :many +SELECT database_services.* +FROM database_services +WHERE project_id = $1 +ORDER BY created_at DESC; + +-- name: DeleteDatabaseServiceByID :exec +DELETE FROM database_services +WHERE id = $1; -- name: SetDatabaseServicePublicPortByIDAndUser :exec UPDATE database_services diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index ec59f4d..fe6dc99 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -148,6 +148,7 @@ CREATE TABLE database_services ( external_ssl BOOLEAN NOT NULL DEFAULT false, public_port BOOLEAN NOT NULL DEFAULT false, backup_target_id VARCHAR(255), + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 8c0ba72..c55b75d 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -8,6 +8,7 @@ import { createManagedDatabase, createService, getProjectById, + importComposeIntoProject, listConnectedGitRepositories, listGitBranches, listGitProviders, @@ -45,6 +46,7 @@ import { Search, GitBranch, Container, + FileUp, } from 'lucide-react'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; @@ -473,6 +475,8 @@ export function ProjectWorkspacePage() { const signedIn = isDemoMode || Boolean(sessionQuery.data); const [createOpen, setCreateOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [composeYaml, setComposeYaml] = useState(''); const openAddService = () => { if (!signedIn) { @@ -556,6 +560,25 @@ export function ProjectWorkspacePage() { }, }); + const composeImportMutation = useMutation({ + mutationFn: (yaml: string) => importComposeIntoProject(projectId, yaml), + onSuccess: (result) => { + setImportOpen(false); + setComposeYaml(''); + queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + const count = result.created.length; + const warn = result.warnings.length > 0 ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : ''; + toast.showToast(`Imported ${count} resource${count === 1 ? '' : 's'} from compose${warn}`, 'success'); + for (const warning of result.warnings) { + toast.showToast(warning, 'warning'); + } + }, + onError: (err) => { + toast.showToast(err instanceof Error ? err.message : 'Compose import failed', 'error'); + }, + }); + const project = useMemo( () => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data), [isDemoMode, projectId, projectQuery.data], @@ -721,14 +744,24 @@ export function ProjectWorkspacePage() { ⌘K {!isDemoMode && signedIn && ( - + <> + + + )} @@ -973,6 +1006,49 @@ export function ProjectWorkspacePage() { /> )} + {/* Compose Import */} + {importOpen && ( +
+
setImportOpen(false)} /> +
+
+
+ +
+

Import docker-compose

+
+

+ Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo, + mariadb, clickhouse and dragonfly images become managed databases reachable by service name. +

+