mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: direct docker-compose import into projects
POST /projects/:id/import-compose deploys a compose file straight into a project — no template row required. Well-known DB images become managed databases attached to the project network under their service name as DNS alias; other services deploy in dependency order via deployTemplateGraph. - api: extract composeToSpecs converter (shared by the template import-compose preview and the new project endpoint); fix dropped env warnings (pointer receiver); replace insertion sort with sort.Strings - cascade: database_services.project_id links graph-provisioned DBs to their project so project delete tears down runtime + row (previously leaked container + network) - cli: new `containr import` group, `import compose <project> <file|->` - mcp: containr_projects_import_compose tool - openapi + regenerated frontend types; docs snapshot + CLI.md - frontend: compose import modal on the project workspace toolbar - tests: composeToSpecs conversion, ordering, db-image map, warnings
This commit is contained in:
1 parent
dca8aaa7b3
commit
5c2f2861bd
20 files changed
+711
-60
No files matched your search
@@ -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},
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user