Files
Tomáš Dvořák 0dc5857b5f refactor(db): port operations, settings, audit, and misc metadata to sqlc (#84)
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc

Covers the cross-cutting surface: operations board reads, app settings,
notification producers, activity/audit inserts, builds upsert/cancel,
git clone credential resolution, invites, environments, registry auth,
project/service variables, HA resolver, boot sweeps, and admin user ops.
Dynamic filtered lists and Better Auth table probes stay raw by design.

Authored By: TDvorak <[email protected]>

* refactor(db): port apwhy gateway handlers and proxy data plane to sqlc

Final tranche — every static call site now uses generated queries. Raw SQL
remains only where the statement is assembled at runtime (filtered lists,
sparse updates, admin count closure) or probes Better Auth's auth_users
table, which lives outside the local schema.

Authored By: TDvorak <[email protected]>
2026-10-10 09:27:30 +02:00

1454 lines
44 KiB
Go

package api
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
"containr/internal/source"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// TemplateServiceSpec describes one member of a multi-service (v2) template
// graph. A graph template carries services[] on TemplateConfig; legacy
// templates keep the flat single-service fields and are untouched by this
// path.
type TemplateServiceSpec struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Type string `json:"type"` // web | worker | cron | database
Runtime string `json:"runtime,omitempty"`
Repo string `json:"repo,omitempty"`
Branch string `json:"branch,omitempty"`
BuildCommand string `json:"build_command,omitempty"`
StartCommand string `json:"start_command,omitempty"`
Port int `json:"port,omitempty"`
HealthCheck string `json:"health_check,omitempty"`
Builder string `json:"builder,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Volumes []ServiceVolume `json:"volumes,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
var (
tmplExprService = regexp.MustCompile(`\{\{\s*service\.([A-Za-z0-9_-]+)\.([a-z_]+)\s*\}\}`)
tmplExprSecret = regexp.MustCompile(`\{\{\s*secret(?:\((\d+)\))?\s*\}\}`)
tmplExprRandom = regexp.MustCompile(`\{\{\s*random:(\d+)\s*\}\}`)
tmplExprVar = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\s*\}\}`)
tmplKeyValid = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
)
// databaseRefSpec maps a managed-database engine to the env keys its
// provisioning plan reads, the internal port and the URL scheme used when
// another member references it via {{service.<key>.url}}.
type databaseRefSpec struct {
userKey string
passKey string
dbKey string
scheme string
port int
// defUser mirrors the fallback used by buildDatabaseRuntimePlan so refs
// resolve to the same value the container ends up with.
defUser string
}
var databaseRefSpecs = map[string]databaseRefSpec{
"postgresql": {userKey: "POSTGRES_USER", passKey: "POSTGRES_PASSWORD", dbKey: "POSTGRES_DB", scheme: "postgresql", port: 5432, defUser: "app"},
"redis": {passKey: "REDIS_PASSWORD", scheme: "redis", port: 6379, defUser: "default"},
"dragonfly": {passKey: "DRAGONFLY_PASSWORD", scheme: "redis", port: 6379, defUser: "default"},
"mysql": {userKey: "MYSQL_USER", passKey: "MYSQL_PASSWORD", dbKey: "MYSQL_DATABASE", scheme: "mysql", port: 3306, defUser: "app"},
"mariadb": {userKey: "MARIADB_USER", passKey: "MARIADB_PASSWORD", dbKey: "MARIADB_DATABASE", scheme: "mysql", port: 3306, defUser: "app"},
"mongodb": {userKey: "MONGO_INITDB_ROOT_USERNAME", passKey: "MONGO_INITDB_ROOT_PASSWORD", dbKey: "MONGO_INITDB_DATABASE", scheme: "mongodb", port: 27017, defUser: "admin"},
"clickhouse": {userKey: "CLICKHOUSE_USER", passKey: "CLICKHOUSE_PASSWORD", dbKey: "CLICKHOUSE_DB", scheme: "http", port: 8123, defUser: "default"},
}
// normalizeGraphAlias turns a member key into a DNS-safe network alias.
func normalizeGraphAlias(key string) string {
alias := strings.ToLower(strings.TrimSpace(key))
var b strings.Builder
for _, r := range alias {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}
// topoSortServices orders graph members so every depends_on edge resolves
// before its dependent. Returns an error on cycles or unknown keys.
func topoSortServices(specs []TemplateServiceSpec) ([]TemplateServiceSpec, error) {
byKey := make(map[string]TemplateServiceSpec, len(specs))
for _, s := range specs {
key := normalizeGraphAlias(s.Key)
if key == "" || !tmplKeyValid.MatchString(key) {
return nil, fmt.Errorf("invalid service key %q — use lowercase letters, digits, '-' or '_'", s.Key)
}
if _, dup := byKey[key]; dup {
return nil, fmt.Errorf("duplicate service key %q", key)
}
s.Key = key
byKey[key] = s
}
indegree := make(map[string]int, len(byKey))
for key := range byKey {
indegree[key] = 0
}
for key, s := range byKey {
for _, dep := range s.DependsOn {
d := normalizeGraphAlias(dep)
if _, ok := byKey[d]; !ok {
return nil, fmt.Errorf("service %q depends on unknown key %q", key, dep)
}
if d == key {
return nil, fmt.Errorf("service %q depends on itself", key)
}
indegree[key]++
}
}
var ready []string
for key, deg := range indegree {
if deg == 0 {
ready = append(ready, key)
}
}
// Deterministic order: keep declaration order for equal in-degrees.
declOrder := make(map[string]int, len(specs))
for i, s := range specs {
declOrder[normalizeGraphAlias(s.Key)] = i
}
sortKeys := func(keys []string) {
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && declOrder[keys[j]] < declOrder[keys[j-1]]; j-- {
keys[j], keys[j-1] = keys[j-1], keys[j]
}
}
}
sortKeys(ready)
var sorted []TemplateServiceSpec
for len(ready) > 0 {
key := ready[0]
ready = ready[1:]
sorted = append(sorted, byKey[key])
for other, s := range byKey {
for _, dep := range s.DependsOn {
if normalizeGraphAlias(dep) == key {
indegree[other]--
if indegree[other] == 0 {
ready = append(ready, other)
}
}
}
}
sortKeys(ready)
}
if len(sorted) != len(byKey) {
return nil, fmt.Errorf("service dependency graph contains a cycle")
}
return sorted, nil
}
// generatedSecrets records which member/key pairs received a generated
// secret so the value can be persisted with is_secret=true.
type resolvedGraph struct {
members []resolvedMember
unresolved []string
}
type resolvedMember struct {
spec TemplateServiceSpec
env map[string]string
secretKeys map[string]bool
}
func generateSecretHex(n int) string {
if n <= 0 || n > 128 {
n = 16
}
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return ""
}
return hex.EncodeToString(buf)
}
// resolveStaticExpr expands {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}}
// and {{VAR:-default}} within a single value. Unknown vars without defaults
// are recorded in unresolved.
func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]string, generated *bool) string {
v := tmplExprSecret.ReplaceAllStringFunc(value, func(m string) string {
if generated != nil {
*generated = true
}
sub := tmplExprSecret.FindStringSubmatch(m)
n, _ := strconv.Atoi(sub[1])
return generateSecretHex(n)
})
v = tmplExprRandom.ReplaceAllStringFunc(v, func(m string) string {
sub := tmplExprRandom.FindStringSubmatch(m)
max, err := strconv.ParseInt(sub[1], 10, 64)
if err != nil || max <= 0 {
return m
}
n, err := rand.Int(rand.Reader, big.NewInt(max))
if err != nil {
return m
}
return n.String()
})
v = tmplExprVar.ReplaceAllStringFunc(v, func(m string) string {
sub := tmplExprVar.FindStringSubmatch(m)
key := sub[1]
if val, ok := userVars[key]; ok {
return val
}
// {{VAR:-x}} supplies a default; {{VAR:-}} declares an explicitly
// empty one. Both resolve — only a bare {{VAR}} is unresolved.
if strings.Contains(m, ":-") {
return sub[2]
}
if unresolved != nil {
*unresolved = append(*unresolved, key)
}
return ""
})
return v
}
// resolveTemplateGraph performs the two-pass resolution: pass 1 expands
// static expressions per member; pass 2 builds the per-service property
// table (host/port/url/credentials) and expands {{service.KEY.prop}}.
func resolveTemplateGraph(sorted []TemplateServiceSpec, userVars map[string]string) resolvedGraph {
graph := resolvedGraph{members: make([]resolvedMember, 0, len(sorted)), unresolved: []string{}}
// Pass 1: static expressions. Database members get their credential
// keys pre-filled so {{service.db.password}} always resolves — the same
// value is then passed to the engine's own env vars at provisioning.
for _, spec := range sorted {
env := map[string]string{}
for k, v := range spec.Environment {
env[k] = v
}
if normalizeTemplateMemberType(spec) == "database" {
engine := normalizeDatabaseType(spec.Runtime)
if ref, ok := databaseRefSpecs[engine]; ok {
if ref.passKey != "" && strings.TrimSpace(env[ref.passKey]) == "" {
env[ref.passKey] = "{{secret}}"
}
}
}
member := resolvedMember{spec: spec, env: map[string]string{}, secretKeys: map[string]bool{}}
for k, v := range env {
generated := false
resolved := resolveStaticExpr(v, userVars, &graph.unresolved, &generated)
member.env[k] = resolved
if generated {
member.secretKeys[k] = true
}
}
graph.members = append(graph.members, member)
}
// Pass 2: property tables.
propTables := make(map[string]map[string]string, len(sorted))
for _, m := range graph.members {
props := map[string]string{}
if normalizeTemplateMemberType(m.spec) == "database" {
engine := normalizeDatabaseType(m.spec.Runtime)
ref := databaseRefSpecs[engine]
alias := m.spec.Key
dbName := m.spec.Name
if dbName == "" {
dbName = m.spec.Key
}
user := ""
if ref.userKey != "" {
user = m.env[ref.userKey]
if user == "" {
user = ref.defUser
}
}
password := ""
if ref.passKey != "" {
password = m.env[ref.passKey]
}
database := ""
if ref.dbKey != "" {
database = m.env[ref.dbKey]
if database == "" {
database = dbName
}
}
user = sanitizeDBIdentifier(user, ref.defUser)
database = sanitizeDBIdentifier(database, "app")
props["host"] = alias
props["port"] = strconv.Itoa(ref.port)
if user != "" {
props["user"] = user
props["username"] = user
}
if password != "" {
props["password"] = password
}
if database != "" {
props["database"] = database
props["db"] = database
}
url := graphDatabaseURL(ref, user, password, alias, database)
if url != "" {
props["url"] = url
props["connection_url"] = url
}
} else {
name := m.spec.Name
if name == "" {
name = m.spec.Key
}
host := normalizeGraphAlias(name)
props["host"] = host
if m.spec.Port > 0 {
props["port"] = strconv.Itoa(m.spec.Port)
props["url"] = fmt.Sprintf("http://%s:%d", host, m.spec.Port)
}
}
propTables[m.spec.Key] = props
}
for i := range graph.members {
m := &graph.members[i]
for k, v := range m.env {
m.env[k] = tmplExprService.ReplaceAllStringFunc(v, func(match string) string {
sub := tmplExprService.FindStringSubmatch(match)
refKey := normalizeGraphAlias(sub[1])
prop := sub[2]
if props, ok := propTables[refKey]; ok {
if val, ok := props[prop]; ok {
// Credential-bearing refs mark the consuming key
// secret so it stores encrypted and masks in plan.
if prop == "password" || prop == "url" || prop == "connection_url" {
m.secretKeys[k] = true
}
return val
}
}
graph.unresolved = append(graph.unresolved, fmt.Sprintf("service.%s.%s", refKey, prop))
return match
})
}
}
return graph
}
func graphDatabaseURL(ref databaseRefSpec, user, password, host, database string) string {
port := strconv.Itoa(ref.port)
switch ref.scheme {
case "redis":
// redis://[:password@]host:port/0 — user slot stays "default" for
// clients that require it.
u := user
if u == "" {
u = "default"
}
return fmt.Sprintf("redis://%s:%s@%s:%s/0", u, password, host, port)
case "mysql":
return fmt.Sprintf("mysql://%s:%s@%s:%s/%s", user, password, host, port, database)
case "mongodb":
return fmt.Sprintf("mongodb://%s:%s@%s:%s/%s", user, password, host, port, database)
case "http":
return fmt.Sprintf("http://%s:%s@%s:%s/%s", user, password, host, port, database)
default:
return fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, database)
}
}
func normalizeTemplateMemberType(spec TemplateServiceSpec) string {
t, err := normalizeTemplateServiceType(spec.Type)
if err != nil {
return "web"
}
return t
}
// validateTemplateGraph checks member shapes that topo sort and resolution
// don't cover: supported types, image/runtime presence, database engines.
func validateTemplateGraph(sorted []TemplateServiceSpec) error {
for _, spec := range sorted {
t := normalizeTemplateMemberType(spec)
if t == "database" {
engine := normalizeDatabaseType(spec.Runtime)
if _, ok := supportedDatabaseTypes[engine]; !ok {
return fmt.Errorf("service %q: unsupported database type %q", spec.Key, spec.Runtime)
}
continue
}
if strings.TrimSpace(spec.Runtime) == "" && strings.TrimSpace(spec.Repo) == "" {
return fmt.Errorf("service %q: needs a runtime (image) or repo", spec.Key)
}
}
return nil
}
// ---- Plan endpoint --------------------------------------------------------
type templatePlanMember struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Runtime string `json:"runtime,omitempty"`
Port int `json:"port,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Environment map[string]string `json:"environment"`
}
// handlePlanTemplate dry-resolves a template (v1 or v2): variable merging,
// expression expansion and service refs, without creating anything.
// Generated secrets are shown masked — deploy regenerates them.
func handlePlanTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
var req struct {
ProjectID string `json:"project_id"`
Variables map[string]string `json:"variables"`
}
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_ = userID
templateRow, err := queries.GetServiceTemplateByID(ctx, c.Param("id"))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
}
template := mapSQLCTemplate(templateRow)
if template.ID == "" || !templateVisibleTo(c, template) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config TemplateConfig
if err := json.Unmarshal([]byte(template.Config), &config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Template configuration is invalid"})
return
}
var templateVars []TemplateVariable
_ = json.Unmarshal([]byte(template.Variables), &templateVars)
varsByKey := map[string]TemplateVariable{}
for _, v := range templateVars {
varsByKey[v.Key] = v
}
userVars := map[string]string{}
for _, v := range templateVars {
if req.Variables != nil {
if override, ok := req.Variables[v.Key]; ok {
userVars[v.Key] = override
continue
}
}
if v.Default != "" {
userVars[v.Key] = v.Default
}
}
for k, v := range req.Variables {
userVars[k] = v
}
missing := []string{}
for _, v := range templateVars {
if v.Required && strings.TrimSpace(userVars[v.Key]) == "" {
missing = append(missing, v.Key)
}
}
maskSecrets := func(env map[string]string, member resolvedMember) map[string]string {
out := map[string]string{}
for k, v := range env {
if member.secretKeys[k] || (varsByKey[k].Secret) {
out[k] = "********"
} else {
out[k] = v
}
}
return out
}
if len(config.Services) == 0 {
env, secretKeys, missingReq := mergeTemplateVariables(config.Environment, templateVars, req.Variables)
resolved := resolvedMember{
spec: TemplateServiceSpec{Key: "service", Type: config.Type, Runtime: config.Runtime, Port: config.Port},
env: env,
secretKeys: secretKeys,
}
c.JSON(http.StatusOK, gin.H{
"members": []templatePlanMember{{
Key: "service",
Type: config.Type,
Runtime: config.Runtime,
Port: config.Port,
Environment: maskSecrets(resolved.env, resolved),
}},
"missing_variables": missingReq,
"unresolved": []string{},
})
return
}
sorted, err := topoSortServices(config.Services)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateTemplateGraph(sorted); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
graph := resolveTemplateGraph(sorted, userVars)
members := make([]templatePlanMember, 0, len(graph.members))
for _, m := range graph.members {
name := m.spec.Name
if name == "" {
name = m.spec.Key
}
members = append(members, templatePlanMember{
Key: m.spec.Key,
Name: name,
Type: normalizeTemplateMemberType(m.spec),
Runtime: m.spec.Runtime,
Port: m.spec.Port,
DependsOn: m.spec.DependsOn,
Environment: maskSecrets(m.env, m),
})
}
c.JSON(http.StatusOK, gin.H{
"members": members,
"missing_variables": missing,
"unresolved": graph.unresolved,
})
}
// ---- Graph deploy ---------------------------------------------------------
type graphCreatedResource struct {
Key string `json:"key"`
Kind string `json:"kind"` // service | database
ID string `json:"id"`
Name string `json:"name"`
DeploymentID string `json:"deployment_id,omitempty"`
}
// deployTemplateGraph runs a resolved graph against a project: databases
// first (async provision + project-network alias), then services, then
// per-service deploy jobs. Partial failure removes rows/containers created
// by this call.
func deployTemplateGraph(
c *gin.Context,
db *database.DB,
engine *deployment.DeploymentEngine,
dbHandler *DatabaseHandler,
projectID uuid.UUID,
userID string,
specs []TemplateServiceSpec,
templateVars []TemplateVariable,
userVars map[string]string,
plan, region string,
source string,
) ([]graphCreatedResource, *gin.H) {
sorted, err := topoSortServices(specs)
if err != nil {
return nil, &gin.H{"error": err.Error()}
}
if err := validateTemplateGraph(sorted); err != nil {
return nil, &gin.H{"error": err.Error()}
}
missing := []string{}
for _, v := range templateVars {
if v.Required && strings.TrimSpace(userVars[v.Key]) == "" {
missing = append(missing, v.Key)
}
}
if len(missing) > 0 {
return nil, &gin.H{
"error": fmt.Sprintf("Missing required template variables: %s", strings.Join(missing, ", ")),
"missing_variables": missing,
}
}
graph := resolveTemplateGraph(sorted, userVars)
if len(graph.unresolved) > 0 {
return nil, &gin.H{
"error": fmt.Sprintf("Unresolved template references: %s", strings.Join(dedupStrings(graph.unresolved), ", ")),
"unresolved": dedupStrings(graph.unresolved),
}
}
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
if engine != nil {
// Project network must exist before database containers attach.
if _, err := engine.EnsureProjectNetwork(ctx, projectID.String()); err != nil {
log.Printf("containr: template deploy: ensure project network: %v", err)
}
}
created := []graphCreatedResource{}
createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{}
cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs {
if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid)
}
_ = q.DeleteEnvVarsForService(context.Background(), sid)
_ = q.DeleteServiceByID(context.Background(), sid)
}
for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
}
_ = q.DeleteDatabaseServiceByID(context.Background(), did)
}
}
secretTemplateKeys := map[string]bool{}
for _, v := range templateVars {
if v.Secret {
secretTemplateKeys[v.Key] = true
}
}
for _, m := range graph.members {
memberName := strings.TrimSpace(m.spec.Name)
if memberName == "" {
memberName = m.spec.Key
}
memberType := normalizeTemplateMemberType(m.spec)
if memberType == "database" {
if dbHandler == nil {
cleanup()
return nil, &gin.H{"error": "Database handler unavailable"}
}
databaseID, err := dbHandler.createManagedDatabaseAndProvision(ctx, userID, managedDatabaseCreateRequest{
Name: memberName,
Type: m.spec.Runtime,
Plan: plan,
Region: region,
RuntimeVariables: m.env,
ProjectID: projectID.String(),
NetworkAlias: m.spec.Key,
})
if err != nil {
cleanup()
switch {
case errors.Is(err, errDatabaseNameRequired), errors.Is(err, errUnsupportedDatabaseType), errors.Is(err, errUnsupportedDatabasePlan):
return nil, &gin.H{"error": err.Error()}
case errors.Is(err, errDatabaseNameAlreadyInUse):
return nil, &gin.H{"error": err.Error(), "code": "CONFLICT"}
default:
return nil, &gin.H{"error": "Failed to create managed database: " + err.Error()}
}
}
createdDatabaseIDs = append(createdDatabaseIDs, databaseID)
created = append(created, graphCreatedResource{Key: m.spec.Key, Kind: "database", ID: databaseID, Name: memberName})
LogAudit(userID, "database", databaseID, "create", map[string]interface{}{
"source": source, "name": memberName, "type": normalizeDatabaseType(m.spec.Runtime), "project_id": projectID.String(),
})
continue
}
count, err := queries.CountServicesByProjectAndName(ctx, sqlcdb.CountServicesByProjectAndNameParams{
ProjectID: projectID,
Name: memberName,
})
if err != nil {
cleanup()
return nil, &gin.H{"error": "Failed to validate service name"}
}
if count > 0 {
cleanup()
return nil, &gin.H{"error": fmt.Sprintf("Service name %q already exists in this project", memberName), "code": "CONFLICT"}
}
serviceImage := resolveTemplateRuntimeImage(m.spec.Runtime)
serviceCommand := strings.TrimSpace(m.spec.StartCommand)
buildCommand := strings.TrimSpace(m.spec.BuildCommand)
cpu, memory := defaultTemplateResources(memberType)
environmentID, err := getProjectEnvironmentID(db, projectID, "production")
if err != nil {
cleanup()
return nil, &gin.H{"error": "Failed to resolve service environment"}
}
serviceID := uuid.New()
now := time.Now()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
cleanup()
return nil, &gin.H{"error": "Failed to initialize service"}
}
txQueries := queries.WithTx(tx)
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
ID: serviceID,
ProjectID: projectID,
Name: memberName,
EnvironmentID: environmentID,
ServiceType: memberType,
SourceType: "template",
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: sql.NullString{String: memberType, Valid: true},
Status: sql.NullString{String: "stopped", Valid: true},
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Environment: sql.NullString{String: "production", Valid: true},
Cpu: sql.NullString{String: cpu, Valid: true},
Memory: sql.NullString{String: memory, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
tx.Rollback()
cleanup()
return nil, &gin.H{"error": "Failed to create service"}
}
var volumesJSON interface{}
if len(m.spec.Volumes) > 0 {
if raw, err := json.Marshal(m.spec.Volumes); err == nil {
volumesJSON = string(raw)
}
}
builder := strings.TrimSpace(m.spec.Builder)
if builder == "" {
builder = "auto"
}
var volumesRaw json.RawMessage
if s, ok := volumesJSON.(string); ok {
volumesRaw = json.RawMessage(s)
}
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
Port: int32(m.spec.Port),
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
Column3: volumesRaw,
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback()
cleanup()
return nil, &gin.H{"error": "Failed to configure service"}
}
for key, value := range m.env {
if strings.TrimSpace(key) == "" {
continue
}
isSecret := m.secretKeys[key] || secretTemplateKeys[key]
stored := value
if isSecret {
stored = secrets.Encrypt(value)
}
if err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: key,
Value: stored,
IsSecret: sql.NullBool{Bool: isSecret, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
}); err != nil {
tx.Rollback()
cleanup()
return nil, &gin.H{"error": "Failed to save service variables"}
}
}
if err := tx.Commit(); err != nil {
cleanup()
return nil, &gin.H{"error": "Failed to finalize service"}
}
createdServiceIDs = append(createdServiceIDs, serviceID)
res := graphCreatedResource{Key: m.spec.Key, Kind: "service", ID: serviceID.String(), Name: memberName}
if engine != nil {
service := Service{
ID: serviceID,
ProjectID: projectID,
Name: memberName,
Type: memberType,
Status: "building",
Image: serviceImage,
Command: serviceCommand,
Environment: "production",
GitRepo: strings.TrimSpace(m.spec.Repo),
GitBranch: strings.TrimSpace(m.spec.Branch),
Replicas: 1,
Port: m.spec.Port,
HealthCheckPath: strings.TrimSpace(m.spec.HealthCheck),
RestartPolicy: "unless-stopped",
Builder: builder,
}
depID := queueTemplateDeployment(c, db, engine, service, userID)
if depID != uuid.Nil {
res.DeploymentID = depID.String()
}
}
created = append(created, res)
LogAudit(userID, "service", serviceID.String(), "create", map[string]interface{}{
"source": source, "name": memberName, "type": memberType,
})
}
return created, nil
}
// queueTemplateDeployment creates a deployment row and enqueues the shared
// build+run job — the same path a manual POST /deployments takes.
func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service, userID string) uuid.UUID {
now := time.Now()
dep := DeploymentModel{
ID: uuid.New(),
ServiceID: service.ID,
Status: "queued",
CreatedAt: now,
UpdatedAt: now,
}
q := sqlcdb.New(db.DB)
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
ID: dep.ID,
ServiceID: dep.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
Status: sql.NullString{String: dep.Status, Valid: true},
ImageName: sql.NullString{String: "", Valid: true},
ImageTag: sql.NullString{String: "", Valid: true},
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
}); err != nil {
return uuid.Nil
}
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: dep.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db, engine, &dep, service, CreateDeploymentRequest{Trigger: "template"}, userID)
},
})
return dep.ID
}
// handleDeployInlineGraph deploys an ad-hoc service graph without a stored
// template — the companion to compose import: import returns a config,
// this deploys it.
func handleDeployInlineGraph(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Config struct {
Services []TemplateServiceSpec `json:"services"`
} `json:"config" 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.Config.Services) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "config.services is empty"})
return
}
projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID))
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 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,
req.Config.Services, nil, req.Variables, req.Plan, req.Region, "inline")
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,
})
}
// 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))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
// ---- Compose import --------------------------------------------------------
type composeServiceDef struct {
Image string `yaml:"image"`
Build interface{} `yaml:"build"`
Command interface{} `yaml:"command"`
Entrypoint interface{} `yaml:"entrypoint"`
Environment interface{} `yaml:"environment"`
Ports []interface{} `yaml:"ports"`
DependsOn interface{} `yaml:"depends_on"`
Volumes []interface{} `yaml:"volumes"`
Restart string `yaml:"restart"`
}
type composeFileDef struct {
Services map[string]composeServiceDef `yaml:"services"`
}
// composeImageDatabase maps well-known compose image names to managed
// database engines so imported stacks get real managed databases.
var composeImageDatabase = map[string]string{
"postgres": "postgresql", "postgresql": "postgresql",
"redis": "redis", "dragonfly": "dragonfly", "dragonflydb": "dragonfly",
"mongo": "mongodb", "mongodb": "mongodb",
"mysql": "mysql", "mariadb": "mariadb",
"clickhouse": "clickhouse", "clickhouse-server": "clickhouse",
}
// handleImportCompose converts a docker-compose YAML document into a v2
// template config. It does not persist anything — the response feeds the
// template create form or a direct deploy.
func handleImportCompose(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
var req struct {
ComposeYAML string `json:"compose_yaml" binding:"required"`
}
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
}
services, warnings, convErr := composeToSpecs(&file)
if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return
}
variables := []TemplateVariable{}
config := TemplateConfig{Version: 2, Services: services}
configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": variables,
"warnings": warnings,
})
}
// handleImportGitCompose fetches a compose file out of a git repository and
// runs it through the same conversion as handleImportCompose. Works with any
// git remote — connected providers (including GitHub App installs) supply
// credentials for private repos. Nothing is persisted; the response feeds
// the template create form or a direct deploy.
func handleImportGitCompose(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
var req struct {
Repo string `json:"repo" binding:"required"`
Path string `json:"path"`
Ref string `json:"ref"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
branch, commit := "", ""
if ref := strings.TrimSpace(req.Ref); ref != "" {
if isCommitSHA(ref) {
commit = ref
} else {
branch = ref
}
}
spec, err := resolveGitCloneSpec(db, userID, req.Repo, branch, commit, "")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute)
defer cancel()
checkoutRoot := filepath.Join(containrWorkDir, "repos", "import-"+uuid.NewString())
defer os.RemoveAll(checkoutRoot)
repoDir, err := source.Checkout(ctx, checkoutRoot, spec)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "git fetch failed: " + err.Error(), "code": "GIT_FETCH"})
return
}
// Default search order mirrors common compose conventions.
candidates := []string{req.Path}
if strings.TrimSpace(req.Path) == "" {
candidates = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
}
var raw []byte
for _, rel := range candidates {
p, rerr := resolveRepoFile(repoDir, rel)
if rerr != nil {
continue
}
raw, err = os.ReadFile(p)
if err == nil {
break
}
}
if raw == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "compose file not found in repository", "code": "NOT_FOUND"})
return
}
if len(raw) > 512*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"})
return
}
var file composeFileDef
if err := yaml.Unmarshal(raw, &file); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
return
}
services, warnings, convErr := composeToSpecs(&file)
if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return
}
config := TemplateConfig{Version: 2, Services: services}
configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": []TemplateVariable{},
"warnings": warnings,
})
}
// resolveRepoFile maps a repo-relative file path onto the checkout and
// rejects escapes — same containment rule as ResolveBuildPath, for files.
func resolveRepoFile(checkoutDir, rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" || filepath.IsAbs(rel) {
return "", fmt.Errorf("invalid repo path %q", rel)
}
clean := filepath.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes the checkout: %q", rel)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("path %q not found in checkout", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("path %q is not a file", rel)
}
return resolved, nil
}
func isCommitSHA(ref string) bool {
if len(ref) != 40 {
return false
}
for _, r := range ref {
if !('0' <= r && r <= '9') && !('a' <= r && r <= 'f') {
return false
}
}
return true
}
// 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)
}
sort.Strings(names)
for _, name := range names {
svc := file.Services[name]
key := normalizeGraphAlias(name)
if key == "" {
warnings = append(warnings, fmt.Sprintf("service %q skipped: name is not usable as a key", name))
continue
}
spec := TemplateServiceSpec{
Key: key,
Name: name,
DependsOn: composeDependsOn(svc.DependsOn),
}
imageName := strings.TrimSpace(svc.Image)
engineHint := ""
if imageName != "" {
base := imageName
if idx := strings.IndexAny(base, ":@"); idx > 0 {
base = base[:idx]
}
if slash := strings.LastIndex(base, "/"); slash >= 0 {
base = base[slash+1:]
}
engineHint = composeImageDatabase[strings.ToLower(base)]
}
if engineHint != "" && svc.Build == nil {
spec.Type = "database"
spec.Runtime = engineHint
spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
} else {
spec.Type = "web"
spec.Runtime = imageName
spec.Environment = composeEnvironment(svc.Environment, &warnings, key)
spec.Port = composeFirstPort(svc.Ports)
spec.StartCommand = composeCommand(svc.Command)
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))
}
}
for _, vol := range svc.Volumes {
v := composeVolume(vol)
if v != nil {
spec.Volumes = append(spec.Volumes, *v)
}
}
services = append(services, spec)
}
if len(services) == 0 {
return nil, warnings, fmt.Errorf("compose file produced no importable services")
}
return services, warnings, nil
}
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))
continue
}
out[k] = fmt.Sprintf("%v", v)
}
case []interface{}:
for _, item := range e {
s, ok := item.(string)
if !ok {
continue
}
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))
}
}
}
return out
}
func composeDependsOn(dep interface{}) []string {
switch d := dep.(type) {
case []interface{}:
out := []string{}
for _, item := range d {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case map[string]interface{}:
out := make([]string, 0, len(d))
for k := range d {
out = append(out, k)
}
return out
}
return nil
}
func composeFirstPort(ports []interface{}) int {
for _, p := range ports {
switch v := p.(type) {
case string:
// "8080:80", "127.0.0.1:8080:80", "80"
parts := strings.Split(v, ":")
last := parts[len(parts)-1]
if slash := strings.Index(last, "/"); slash > 0 {
last = last[:slash]
}
if n, err := strconv.Atoi(last); err == nil && n > 0 {
return n
}
case int:
return v
case float64:
return int(v)
case map[string]interface{}:
if t, ok := v["target"]; ok {
if f, ok := t.(float64); ok {
return int(f)
}
if i, ok := t.(int); ok {
return i
}
}
}
}
return 0
}
func composeCommand(cmd interface{}) string {
switch v := cmd.(type) {
case string:
return v
case []interface{}:
parts := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
parts = append(parts, s)
}
}
return strings.Join(parts, " ")
}
return ""
}
func composeVolume(vol interface{}) *ServiceVolume {
s, ok := vol.(string)
if !ok {
return nil
}
parts := strings.SplitN(s, ":", 3)
if len(parts) < 2 {
return nil
}
source := strings.TrimSpace(parts[0])
target := strings.TrimSpace(parts[1])
if source == "" || target == "" {
return nil
}
v := &ServiceVolume{Source: source, Target: target}
if strings.HasPrefix(source, "/") || strings.HasPrefix(source, ".") {
v.Type = "bind"
} else {
v.Type = "volume"
}
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
v.ReadOnly = true
}
return v
}