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:
Tomas Dvorak committed 2026-10-09 09:33:38 +02:00
1 parent dca8aaa7b3
commit 5c2f2861bd
20 files changed
+711 -60

No files matched your search

+10
View File
@@ -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},
})
+23
View File
@@ -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 {
+1
View File
@@ -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)
+116 -32
View File
@@ -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)
}
}
@@ -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 <project-id> <compose.yml|->",
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)
}
+1
View File
@@ -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)
@@ -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 {
@@ -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"`
}
@@ -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)
+1
View File
@@ -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})
@@ -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;
+12 -2
View File
@@ -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
+1
View File
@@ -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()
);
@@ -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() {
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{!isDemoMode && signedIn && (
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
<>
<button
onClick={() => setImportOpen(true)}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span>
</button>
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span>
</button>
</>
)}
</div>
</div>
@@ -973,6 +1006,49 @@ export function ProjectWorkspacePage() {
/>
)}
{/* Compose Import */}
{importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileUp size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
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.
</p>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setImportOpen(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
>
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack
</button>
</div>
</div>
</div>
)}
{/* Command Palette */}
<CommandPalette
open={commandPaletteOpen}
+84
View File
@@ -3190,6 +3190,90 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{id}/import-compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy a docker-compose file into a project
* @description Parses a docker-compose YAML document and deploys it directly into
* the project — no template row. Well-known database images
* (postgres, mysql, redis, mongo, mariadb, clickhouse, dragonfly)
* become managed databases attached to the project network under
* their service name as DNS alias; other services are created and
* deployed in dependency order. Returns the created resources plus
* any import warnings (unsupported keys, missing values).
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Full docker-compose YAML document (max 512 KiB) */
compose_yaml: string;
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Stack deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: Record<string, never>[];
warnings?: string[];
};
};
};
/** @description Invalid compose YAML or unresolved references */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Access denied */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Project not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agent-tokens": {
parameters: {
query?: never;
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `-Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+19
View File
@@ -1748,6 +1748,25 @@ export async function importComposeTemplate(composeYaml: string): Promise<Compos
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export type ProjectComposeImportResult = {
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
warnings: string[];
};
export async function importComposeIntoProject(
projectId: string,
composeYaml: string,
): Promise<ProjectComposeImportResult> {
const payload = await requestJson<{
created?: ProjectComposeImportResult['created'];
warnings?: string[];
}>(`/projects/${projectId}/import-compose`, {
method: 'POST',
body: JSON.stringify({ compose_yaml: composeYaml }),
});
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':