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
+692
-41
No files matched your search
@@ -23,6 +23,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
@@ -378,6 +380,13 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
version := h.getDefaultVersion(dbType)
|
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{
|
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -388,6 +397,7 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
|
|||||||
Plan: plan,
|
Plan: plan,
|
||||||
Region: region,
|
Region: region,
|
||||||
PublicPort: req.PublicPort,
|
PublicPort: req.PublicPort,
|
||||||
|
ProjectID: projectID,
|
||||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -297,6 +298,28 @@ func handleDeleteProject(c *gin.Context) {
|
|||||||
return
|
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.
|
// Remove live containers and the project network before dropping rows.
|
||||||
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
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.
|
// publishes them.
|
||||||
authed.POST("/projects", handleCreateProject)
|
authed.POST("/projects", handleCreateProject)
|
||||||
authed.POST("/projects/:id/services", handleCreateService)
|
authed.POST("/projects/:id/services", handleCreateService)
|
||||||
|
authed.POST("/projects/:id/import-compose", handleImportProjectCompose)
|
||||||
authed.PUT("/projects/:id", handleUpdateProject)
|
authed.PUT("/projects/:id", handleUpdateProject)
|
||||||
authed.DELETE("/projects/:id", handleDeleteProject)
|
authed.DELETE("/projects/:id", handleDeleteProject)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 {
|
func dedupStrings(in []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
out := make([]string, 0, len(in))
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(file.Services) == 0 {
|
services, warnings, convErr := composeToSpecs(&file)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file defines no services"})
|
if convErr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings := []string{}
|
|
||||||
services := make([]TemplateServiceSpec, 0, len(file.Services))
|
|
||||||
variables := []TemplateVariable{}
|
variables := []TemplateVariable{}
|
||||||
|
|
||||||
// Compose service names become keys; declaration order follows YAML
|
config := TemplateConfig{Version: 2, Services: services}
|
||||||
// document order — yaml.v3 maps lose order, so sort keys for stability.
|
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))
|
names := make([]string, 0, len(file.Services))
|
||||||
for name := range file.Services {
|
for name := range file.Services {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
for i := 1; i < len(names); i++ {
|
sort.Strings(names)
|
||||||
for j := i; j > 0 && names[j] < names[j-1]; j-- {
|
|
||||||
names[j], names[j-1] = names[j-1], names[j]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
svc := file.Services[name]
|
svc := file.Services[name]
|
||||||
@@ -1062,18 +1157,16 @@ func handleImportCompose(c *gin.Context) {
|
|||||||
if engineHint != "" && svc.Build == nil {
|
if engineHint != "" && svc.Build == nil {
|
||||||
spec.Type = "database"
|
spec.Type = "database"
|
||||||
spec.Runtime = engineHint
|
spec.Runtime = engineHint
|
||||||
spec.Environment = composeEnvironment(svc.Environment, warnings, key)
|
spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
|
||||||
} else {
|
} else {
|
||||||
spec.Type = "web"
|
spec.Type = "web"
|
||||||
spec.Runtime = imageName
|
spec.Runtime = imageName
|
||||||
spec.Environment = composeEnvironment(svc.Environment, warnings, key)
|
spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
|
||||||
spec.Port = composeFirstPort(svc.Ports)
|
spec.Port = composeFirstPort(svc.Ports)
|
||||||
spec.StartCommand = composeCommand(svc.Command)
|
spec.StartCommand = composeCommand(svc.Command)
|
||||||
if svc.Build != nil {
|
if svc.Build != nil && imageName == "" {
|
||||||
if imageName == "" {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key))
|
warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if spec.Runtime == "" {
|
if spec.Runtime == "" {
|
||||||
warnings = append(warnings, fmt.Sprintf("%s: no image defined — set a runtime before deploying", key))
|
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 {
|
if len(services) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file produced no importable services"})
|
return nil, warnings, fmt.Errorf("compose file produced no importable services")
|
||||||
return
|
}
|
||||||
|
return services, warnings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
config := TemplateConfig{Version: 2, Services: services}
|
func composeEnvironment(env interface{}, warnings *[]string, svcKey string) map[string]string {
|
||||||
configJSON, _ := json.Marshal(config)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"config": json.RawMessage(configJSON),
|
|
||||||
"variables": variables,
|
|
||||||
"warnings": warnings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func composeEnvironment(env interface{}, warnings []string, svcKey string) map[string]string {
|
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
switch e := env.(type) {
|
switch e := env.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
for k, v := range e {
|
for k, v := range e {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
// `KEY:` in a list/map forwards the host value — flag it.
|
// `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
|
continue
|
||||||
}
|
}
|
||||||
out[k] = fmt.Sprintf("%v", v)
|
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 {
|
if idx := strings.Index(s, "="); idx > 0 {
|
||||||
out[s[:idx]] = s[idx+1:]
|
out[s[:idx]] = s[idx+1:]
|
||||||
} else {
|
} 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)
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
rootCmd.AddCommand(commands.ImportCmd)
|
||||||
rootCmd.AddCommand(commands.NodesCmd)
|
rootCmd.AddCommand(commands.NodesCmd)
|
||||||
rootCmd.AddCommand(commands.ScalingCmd)
|
rootCmd.AddCommand(commands.ScalingCmd)
|
||||||
rootCmd.AddCommand(commands.HACmd)
|
rootCmd.AddCommand(commands.HACmd)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package sqlcdb
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
|
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
|
||||||
@@ -55,8 +57,8 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
||||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateDatabaseServiceParams struct {
|
type CreateDatabaseServiceParams struct {
|
||||||
@@ -69,6 +71,7 @@ type CreateDatabaseServiceParams struct {
|
|||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
PublicPort bool `json:"public_port"`
|
PublicPort bool `json:"public_port"`
|
||||||
|
ProjectID uuid.NullUUID `json:"project_id"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -84,6 +87,7 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
|||||||
arg.Plan,
|
arg.Plan,
|
||||||
arg.Region,
|
arg.Region,
|
||||||
arg.PublicPort,
|
arg.PublicPort,
|
||||||
|
arg.ProjectID,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -154,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data
|
|||||||
return exists, err
|
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
|
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
|
||||||
DELETE FROM database_services
|
DELETE FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
@@ -199,7 +213,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
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
|
FROM database_services
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -228,6 +242,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
&i.BackupTargetID,
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -235,7 +250,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
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
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`
|
`
|
||||||
@@ -269,6 +284,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
&i.BackupTargetID,
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -289,7 +305,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
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
|
FROM database_services
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -324,6 +340,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
&i.BackupTargetID,
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -387,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
return items, nil
|
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
|
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
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -424,6 +495,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
&i.ExternalSsl,
|
&i.ExternalSsl,
|
||||||
&i.PublicPort,
|
&i.PublicPort,
|
||||||
&i.BackupTargetID,
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ type DatabaseService struct {
|
|||||||
ExternalSsl bool `json:"external_ssl"`
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
PublicPort bool `json:"public_port"`
|
PublicPort bool `json:"public_port"`
|
||||||
BackupTargetID sql.NullString `json:"backup_target_id"`
|
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||||
|
ProjectID uuid.NullUUID `json:"project_id"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Querier interface {
|
|||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||||
|
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (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)
|
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, 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)
|
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||||
|
|||||||
@@ -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_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_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_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
|
// Notifications
|
||||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
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;
|
||||||
@@ -35,8 +35,18 @@ FROM database_services
|
|||||||
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
||||||
|
|
||||||
-- name: CreateDatabaseService :exec
|
-- name: CreateDatabaseService :exec
|
||||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
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);
|
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
|
-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ CREATE TABLE database_services (
|
|||||||
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||||
public_port BOOLEAN NOT NULL DEFAULT false,
|
public_port BOOLEAN NOT NULL DEFAULT false,
|
||||||
backup_target_id VARCHAR(255),
|
backup_target_id VARCHAR(255),
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
createService,
|
createService,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
|
importComposeIntoProject,
|
||||||
listConnectedGitRepositories,
|
listConnectedGitRepositories,
|
||||||
listGitBranches,
|
listGitBranches,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Container,
|
Container,
|
||||||
|
FileUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
||||||
@@ -473,6 +475,8 @@ export function ProjectWorkspacePage() {
|
|||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [composeYaml, setComposeYaml] = useState('');
|
||||||
|
|
||||||
const openAddService = () => {
|
const openAddService = () => {
|
||||||
if (!signedIn) {
|
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(
|
const project = useMemo(
|
||||||
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
|
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
|
||||||
[isDemoMode, projectId, projectQuery.data],
|
[isDemoMode, projectId, projectQuery.data],
|
||||||
@@ -721,6 +744,15 @@ export function ProjectWorkspacePage() {
|
|||||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
{!isDemoMode && signedIn && (
|
{!isDemoMode && signedIn && (
|
||||||
|
<>
|
||||||
|
<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
|
<button
|
||||||
onClick={openAddService}
|
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"
|
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"
|
||||||
@@ -729,6 +761,7 @@ export function ProjectWorkspacePage() {
|
|||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
<span className="hidden sm:inline">Add Service</span>
|
<span className="hidden sm:inline">Add Service</span>
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Command Palette */}
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
open={commandPaletteOpen}
|
open={commandPaletteOpen}
|
||||||
|
|||||||
@@ -3190,6 +3190,90 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/agent-tokens": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"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",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -1748,6 +1748,25 @@ export async function importComposeTemplate(composeYaml: string): Promise<Compos
|
|||||||
return { config: payload.config, warnings: payload.warnings ?? [] };
|
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 {
|
export function serviceStatusClass(status: ServiceStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
|
|||||||
@@ -2149,6 +2149,72 @@ paths:
|
|||||||
deployment_id:
|
deployment_id:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/projects/{id}/import-compose:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
summary: 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).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- compose_yaml
|
||||||
|
properties:
|
||||||
|
compose_yaml:
|
||||||
|
type: string
|
||||||
|
description: Full docker-compose YAML document (max 512 KiB)
|
||||||
|
variables:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
plan:
|
||||||
|
type: string
|
||||||
|
region:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Stack deployed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
resource:
|
||||||
|
type: string
|
||||||
|
created:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
warnings:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: Invalid compose YAML or unresolved references
|
||||||
|
'403':
|
||||||
|
description: Access denied
|
||||||
|
'404':
|
||||||
|
description: Project not found
|
||||||
|
|
||||||
# Agent Onboarding Tokens
|
# Agent Onboarding Tokens
|
||||||
/agent-tokens:
|
/agent-tokens:
|
||||||
post:
|
post:
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ templates list|get|create|delete|deploy|plan|import-compose|deploy-graph
|
|||||||
import-compose <compose.yml|-> converts a compose file into
|
import-compose <compose.yml|-> converts a compose file into
|
||||||
a v2 graph config; deploy-graph <project-id> <config.json|->
|
a v2 graph config; deploy-graph <project-id> <config.json|->
|
||||||
deploys an ad-hoc service graph
|
deploys an ad-hoc service graph
|
||||||
|
import compose <project-id> <compose.yml|->
|
||||||
|
deploys a docker-compose file straight into a project —
|
||||||
|
no template row. DB images become managed databases
|
||||||
|
attached under their service name; --var K=V overrides
|
||||||
|
template variables
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
scaling status|policies|services|scale
|
scaling status|policies|services|scale
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
|
|||||||
Reference in new issue
Block a user