mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
* 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]>
1176 lines
44 KiB
Go
1176 lines
44 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/deployment"
|
|
"containr/internal/deployqueue"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Service represents a service in the system
|
|
type Service struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
|
Name string `json:"name" db:"name"`
|
|
Type string `json:"type" db:"type"` // web, worker, database, etc.
|
|
Status string `json:"status" db:"status"` // building, running, failed, stopped
|
|
Image string `json:"image" db:"image"`
|
|
Command string `json:"command" db:"command"`
|
|
Environment string `json:"environment" db:"environment"` // production, preview, development
|
|
GitRepo string `json:"git_repo" db:"git_repo"`
|
|
GitBranch string `json:"git_branch" db:"git_branch"`
|
|
BuildPath string `json:"build_path" db:"build_path"`
|
|
CPU string `json:"cpu" db:"cpu"`
|
|
Memory string `json:"memory" db:"memory"`
|
|
// Runtime spec
|
|
Replicas int `json:"replicas" db:"replicas"`
|
|
Port int `json:"port" db:"port"` // container port to expose
|
|
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
|
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
|
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
|
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
|
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
|
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
|
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
|
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
|
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
|
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
|
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
|
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
|
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
|
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
|
// NodeID pins the service to a node agent ("" = local Docker host,
|
|
// resolved "auto" picks the least-loaded online agent at write time).
|
|
NodeID string `json:"node_id,omitempty" db:"-"`
|
|
NodeName string `json:"node_name,omitempty" db:"-"`
|
|
// Spread distributes replicas across every online, schedulable agent.
|
|
// Mutually exclusive with an explicit NodeID pin.
|
|
Spread bool `json:"spread" db:"-"`
|
|
// PlacementTags restrict auto/spread candidates to agents carrying all
|
|
// listed tags. An explicit pin overrides them — pin + tags is rejected.
|
|
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
|
|
// TraefikLabels holds per-service middleware overrides —
|
|
// "middlewares.<name>.<type>.<field>" → value. Lazy-read.
|
|
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// loadServiceSleep fills the sleep-mode columns, which live outside the
|
|
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
|
|
func loadServiceSleep(db *database.DB, s *Service) {
|
|
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
|
|
if err == nil {
|
|
s.SleepEnabled = row.SleepEnabled
|
|
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
|
|
}
|
|
}
|
|
|
|
// serviceNodeID reads the pin column directly — kept out of the wide service
|
|
// SELECTs so pre-migration schemas keep working.
|
|
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
|
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
|
|
if err == nil {
|
|
return id.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// serviceSpread reads the spread column with the same lazy pattern.
|
|
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
|
|
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
|
|
return err == nil && spread
|
|
}
|
|
|
|
// servicePlacementTags reads the placement_tags column with the lazy pattern.
|
|
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
|
|
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var tags []string
|
|
if err := json.Unmarshal(raw, &tags); err != nil {
|
|
return nil
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// normalizePlacementTags trims, lowercases, and dedupes tag input. Tags are
|
|
// matched verbatim against node_agents.tags.
|
|
func normalizePlacementTags(tags []string) []string {
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(tags))
|
|
for _, t := range tags {
|
|
t = strings.ToLower(strings.TrimSpace(t))
|
|
if t == "" || seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// traefikLabelKey matches "middlewares.<name>.<type>" with an optional
|
|
// ".<subkey>" tail — e.g. "middlewares.rl.ratelimit.average".
|
|
var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`)
|
|
|
|
// traefikMiddlewareTypes are the Traefik middleware kinds a service may
|
|
// define. Router/service/provider-level keys are never accepted — those
|
|
// would let a tenant hijack someone else's routing.
|
|
var traefikMiddlewareTypes = map[string]bool{
|
|
"addprefix": true, "basicauth": true, "buffering": true, "chain": true,
|
|
"circuitbreaker": true, "compress": true, "digestauth": true,
|
|
"errors": true, "forwardauth": true, "grpcweb": true, "headers": true,
|
|
"inflightreq": true, "ipallowlist": true, "ipwhitelist": true,
|
|
"passtlsclientcert": true, "ratelimit": true, "redirectregex": true,
|
|
"redirectscheme": true, "replacepath": true, "replacepathregex": true,
|
|
"retry": true, "stripprefix": true, "stripprefixregex": true,
|
|
}
|
|
|
|
// normalizeTraefikLabels validates a raw override map. Returns the cleaned
|
|
// map (middleware type lowercased — Traefik label keys are canonical-lower)
|
|
// or an error naming the offending key.
|
|
func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
|
|
if len(raw) > 32 {
|
|
return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw))
|
|
}
|
|
out := make(map[string]string, len(raw))
|
|
names := map[string]bool{}
|
|
for k, v := range raw {
|
|
m := traefikLabelKey.FindStringSubmatch(k)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares.<name>.<type>[.<field>]", k)
|
|
}
|
|
mwType := strings.ToLower(m[2])
|
|
if !traefikMiddlewareTypes[mwType] {
|
|
return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2])
|
|
}
|
|
if len(v) == 0 {
|
|
return nil, fmt.Errorf("traefik_labels: %q has an empty value", k)
|
|
}
|
|
if len(v) > 512 || strings.ContainsAny(v, "\r\n") {
|
|
return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k)
|
|
}
|
|
out["middlewares."+m[1]+"."+mwType+m[3]] = v
|
|
names[m[1]] = true
|
|
}
|
|
if len(names) > 16 {
|
|
return nil, fmt.Errorf("traefik_labels: at most 16 middleware names")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// serviceTraefikLabels lazy-reads the traefik_labels column.
|
|
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
|
|
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var labels map[string]string
|
|
if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 {
|
|
return nil
|
|
}
|
|
return labels
|
|
}
|
|
|
|
// traefikLabelsJSON marshals an override map for the jsonb column.
|
|
func traefikLabelsJSON(labels map[string]string) []byte {
|
|
if len(labels) == 0 {
|
|
return []byte("{}")
|
|
}
|
|
raw, _ := json.Marshal(labels)
|
|
return raw
|
|
}
|
|
|
|
// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
|
|
func tagsJSON(tags []string) []byte {
|
|
if len(tags) == 0 {
|
|
return []byte("[]")
|
|
}
|
|
raw, _ := json.Marshal(tags)
|
|
return raw
|
|
}
|
|
|
|
// loadServiceNode fills the pin + spread + placement tags + the agent's
|
|
// display name for responses.
|
|
func loadServiceNode(db *database.DB, s *Service) {
|
|
s.NodeID = serviceNodeID(db, s.ID)
|
|
s.Spread = serviceSpread(db, s.ID)
|
|
s.PlacementTags = servicePlacementTags(db, s.ID)
|
|
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
|
|
if s.NodeID == "" {
|
|
return
|
|
}
|
|
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
|
|
s.NodeName = name
|
|
}
|
|
}
|
|
|
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
|
type ServiceVolume struct {
|
|
Type string `json:"type"` // volume | bind (empty = volume)
|
|
Source string `json:"source"` // volume name or host path
|
|
Target string `json:"target"` // container path (absolute)
|
|
ReadOnly bool `json:"read_only"`
|
|
}
|
|
|
|
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
|
// to no mounts rather than failing the request.
|
|
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
|
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var stored []ServiceVolume
|
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
|
return nil
|
|
}
|
|
out := make([]deployment.VolumeMount, 0, len(stored))
|
|
for _, v := range stored {
|
|
out = append(out, deployment.VolumeMount{
|
|
Type: v.Type,
|
|
Source: v.Source,
|
|
Destination: v.Target,
|
|
ReadOnly: v.ReadOnly,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// validateServiceVolumes enforces the mount contract — bad paths fail
|
|
// fast at write time instead of surfacing as docker create errors later.
|
|
func validateServiceVolumes(vols []ServiceVolume) error {
|
|
for i, v := range vols {
|
|
if v.Type == "" {
|
|
v.Type = "volume"
|
|
}
|
|
if v.Type != "volume" && v.Type != "bind" {
|
|
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
|
}
|
|
if strings.TrimSpace(v.Source) == "" {
|
|
return fmt.Errorf("volumes[%d].source is required", i)
|
|
}
|
|
if !strings.HasPrefix(v.Target, "/") {
|
|
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateServiceRequest represents a request to create a service
|
|
type CreateServiceRequest struct {
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
|
Image string `json:"image"`
|
|
Command string `json:"command"`
|
|
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
|
GitRepo string `json:"git_repo"`
|
|
GitBranch string `json:"git_branch"`
|
|
BuildPath string `json:"build_path"`
|
|
CPU string `json:"cpu"`
|
|
Memory string `json:"memory"`
|
|
Replicas int `json:"replicas"`
|
|
Port int `json:"port"`
|
|
Domain string `json:"domain"`
|
|
HealthCheckPath string `json:"healthcheck_path"`
|
|
RestartPolicy string `json:"restart_policy"`
|
|
Volumes []ServiceVolume `json:"volumes"`
|
|
MaintenanceMode bool `json:"maintenance_mode"`
|
|
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
|
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
|
CPUReserve string `json:"cpu_reserve"`
|
|
MemoryReserve string `json:"memory_reserve"`
|
|
StaticBuildCmd string `json:"static_build_cmd"`
|
|
StaticDir string `json:"static_dir"`
|
|
// SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
|
|
// create time — previously update-only, which surprised API/CLI users.
|
|
SleepEnabled *bool `json:"sleep_enabled"`
|
|
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
|
// NodeID pins placement to a node agent; "auto" picks the least-loaded
|
|
// online agent, "local"/"" runs on the Containr host.
|
|
NodeID string `json:"node_id"`
|
|
// Spread distributes replicas across every online, schedulable agent.
|
|
// Mutually exclusive with an explicit node pin.
|
|
Spread *bool `json:"spread"`
|
|
// PlacementTags restrict auto/spread candidates to agents carrying all
|
|
// listed tags. Mutually exclusive with an explicit node pin.
|
|
PlacementTags []string `json:"placement_tags"`
|
|
// TraefikLabels defines extra Traefik middlewares for this service's
|
|
// router — keys "middlewares.<name>.<type>[.<field>]", validated against
|
|
// an allowlist. Defined middlewares attach to the router automatically.
|
|
TraefikLabels map[string]string `json:"traefik_labels"`
|
|
}
|
|
|
|
// UpdateServiceRequest represents a request to update a service
|
|
type UpdateServiceRequest struct {
|
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
|
Image string `json:"image"`
|
|
Command string `json:"command"`
|
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
|
GitRepo string `json:"git_repo"`
|
|
GitBranch string `json:"git_branch"`
|
|
BuildPath string `json:"build_path"`
|
|
CPU string `json:"cpu"`
|
|
Memory string `json:"memory"`
|
|
Replicas *int `json:"replicas"`
|
|
Port *int `json:"port"`
|
|
Domain *string `json:"domain"`
|
|
HealthCheckPath *string `json:"healthcheck_path"`
|
|
RestartPolicy string `json:"restart_policy"`
|
|
Volumes *[]ServiceVolume `json:"volumes"`
|
|
MaintenanceMode *bool `json:"maintenance_mode"`
|
|
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
|
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
|
CPUReserve *string `json:"cpu_reserve"`
|
|
MemoryReserve *string `json:"memory_reserve"`
|
|
StaticBuildCmd *string `json:"static_build_cmd"`
|
|
StaticDir *string `json:"static_dir"`
|
|
SleepEnabled *bool `json:"sleep_enabled"`
|
|
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
|
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
|
|
NodeID *string `json:"node_id"`
|
|
// Spread toggles multi-node replica distribution; pointer so false is
|
|
// a real update, not a missing field.
|
|
Spread *bool `json:"spread"`
|
|
// PlacementTags restrict auto/spread candidates to agents carrying all
|
|
// listed tags; pointer so [] clears the requirement. Mutually exclusive
|
|
// with an explicit node pin.
|
|
PlacementTags *[]string `json:"placement_tags"`
|
|
// TraefikLabels replaces the service's middleware overrides; pointer so
|
|
// {} clears them and nil leaves them untouched.
|
|
TraefikLabels *map[string]string `json:"traefik_labels"`
|
|
}
|
|
|
|
// handleGetServices retrieves all services for a project
|
|
func handleGetServices(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
|
projectID, err := uuid.Parse(projectIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
return
|
|
}
|
|
|
|
// Approved projects are public; unapproved ones need owner/member/admin.
|
|
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
|
if !projectExists {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
if !allowed {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
// Get services for the project
|
|
rows, err := db.(*database.DB).Query(
|
|
`SELECT id, project_id, name,
|
|
COALESCE(type, service_type, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(image, image_name, ''),
|
|
COALESCE(command, start_command, ''),
|
|
COALESCE(environment, ''),
|
|
COALESCE(git_repo, source_url, ''),
|
|
COALESCE(git_branch, ''),
|
|
COALESCE(build_path, ''),
|
|
COALESCE(cpu, ''),
|
|
COALESCE(memory, ''),
|
|
COALESCE(replicas, 1), COALESCE(port, 0),
|
|
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
|
COALESCE(restart_policy, 'unless-stopped'),
|
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
|
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
|
COALESCE(static_dir, ''),
|
|
COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15),
|
|
created_at, updated_at
|
|
FROM services
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC`,
|
|
projectID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve services"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []Service
|
|
for rows.Next() {
|
|
var service Service
|
|
err := rows.Scan(
|
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
&service.SleepEnabled, &service.SleepIdleMinutes,
|
|
&service.CreatedAt, &service.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
|
return
|
|
}
|
|
services = append(services, service)
|
|
}
|
|
|
|
// Reconcile stored status with live container state (no-op without Docker).
|
|
for i := range services {
|
|
liveServiceStatus(c, db.(*database.DB), &services[i])
|
|
loadServiceNode(db.(*database.DB), &services[i])
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"services": services})
|
|
}
|
|
|
|
// handleCreateService creates a new service
|
|
func handleCreateService(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
|
projectID, err := uuid.Parse(projectIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
return
|
|
}
|
|
|
|
var req CreateServiceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.ProjectID == uuid.Nil {
|
|
req.ProjectID = projectID
|
|
} else if req.ProjectID != projectID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
// Check if project exists and user has access
|
|
var project Project
|
|
err = db.(*database.DB).QueryRow(
|
|
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
|
req.ProjectID,
|
|
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
// Check if user owns the project
|
|
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Check if service name already exists in the project
|
|
var count int
|
|
err = db.(*database.DB).QueryRow(
|
|
"SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2",
|
|
req.ProjectID, req.Name,
|
|
).Scan(&count)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check service name"})
|
|
return
|
|
}
|
|
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in this project"})
|
|
return
|
|
}
|
|
|
|
// Create new service
|
|
service := Service{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
Name: req.Name,
|
|
Type: req.Type,
|
|
Status: "stopped", // Initial status
|
|
Image: req.Image,
|
|
Command: req.Command,
|
|
Environment: req.Environment,
|
|
GitRepo: req.GitRepo,
|
|
GitBranch: req.GitBranch,
|
|
BuildPath: req.BuildPath,
|
|
CPU: req.CPU,
|
|
Memory: req.Memory,
|
|
Replicas: req.Replicas,
|
|
Port: req.Port,
|
|
Domain: req.Domain,
|
|
HealthCheckPath: req.HealthCheckPath,
|
|
RestartPolicy: req.RestartPolicy,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Set default values if not provided; app_settings.default_cpu /
|
|
// default_memory override the hardcoded fallbacks.
|
|
if service.CPU == "" {
|
|
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
|
|
}
|
|
if service.Memory == "" {
|
|
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
|
|
}
|
|
if req.Builder == "" {
|
|
service.Builder = "auto"
|
|
} else {
|
|
service.Builder = req.Builder
|
|
}
|
|
service.CPUReserve = req.CPUReserve
|
|
service.MemoryReserve = req.MemoryReserve
|
|
service.StaticBuildCmd = req.StaticBuildCmd
|
|
service.StaticDir = req.StaticDir
|
|
if service.Builder == "static" && service.StaticDir == "" {
|
|
service.StaticDir = "dist"
|
|
}
|
|
if len(req.TraefikLabels) > 0 {
|
|
labels, err := normalizeTraefikLabels(req.TraefikLabels)
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
|
return
|
|
}
|
|
service.TraefikLabels = labels
|
|
}
|
|
for label, check := range map[string]func(string) error{
|
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
|
} {
|
|
v := map[string]string{
|
|
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
|
|
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
|
|
}[label]
|
|
if err := check(v); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
}
|
|
if service.Replicas < 1 {
|
|
service.Replicas = 1
|
|
}
|
|
if service.RestartPolicy == "" {
|
|
service.RestartPolicy = "unless-stopped"
|
|
}
|
|
if err := validateServiceVolumes(req.Volumes); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
service.Volumes = req.Volumes
|
|
volumesJSON, _ := json.Marshal(req.Volumes)
|
|
if len(req.Volumes) == 0 {
|
|
volumesJSON = []byte("[]")
|
|
}
|
|
|
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
|
return
|
|
}
|
|
|
|
sourceType := inferServiceSourceType(service)
|
|
|
|
if req.SleepEnabled != nil {
|
|
service.SleepEnabled = *req.SleepEnabled
|
|
}
|
|
if req.SleepIdleMinutes != nil {
|
|
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
service.SleepIdleMinutes = *req.SleepIdleMinutes
|
|
}
|
|
if req.Spread != nil {
|
|
service.Spread = *req.Spread
|
|
}
|
|
service.PlacementTags = normalizePlacementTags(req.PlacementTags)
|
|
if req.NodeID != "" {
|
|
if service.Spread && req.NodeID != "auto" && req.NodeID != "local" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if len(service.PlacementTags) > 0 && req.NodeID != "auto" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "placement tags only apply to remote placement — set node_id=\"auto\" or omit it", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID, service.PlacementTags)
|
|
if nodeErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if !service.Spread {
|
|
service.NodeID = nodeID
|
|
}
|
|
}
|
|
|
|
// Insert service into database
|
|
_, err = db.(*database.DB).Exec(
|
|
`INSERT INTO services
|
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
|
build_command, start_command, type, status, image, command, environment,
|
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
|
healthcheck_path, restart_policy, volumes,
|
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
|
node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
|
|
traefik_labels, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
|
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
|
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
|
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
|
service.StaticBuildCmd, service.StaticDir,
|
|
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
|
|
service.Spread,
|
|
tagsJSON(service.PlacementTags),
|
|
service.SleepEnabled,
|
|
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
|
|
traefikLabelsJSON(service.TraefikLabels),
|
|
service.CreatedAt, service.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service"})
|
|
return
|
|
}
|
|
|
|
// Domains live in service_domains; services.domain is the derived default.
|
|
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
|
|
_, _ = db.(*database.DB).Exec(
|
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
|
service.ID, strings.ToLower(req.Domain))
|
|
}
|
|
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
|
|
basicAuthUsers := ""
|
|
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
|
|
basicAuthUsers = encoded
|
|
}
|
|
_, _ = db.(*database.DB).Exec(
|
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
|
req.MaintenanceMode, basicAuthUsers, service.ID)
|
|
service.MaintenanceMode = req.MaintenanceMode
|
|
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
|
|
}
|
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
|
|
|
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
|
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
|
"node_id": service.NodeID,
|
|
})
|
|
loadServiceNode(db.(*database.DB), &service)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"service": service})
|
|
}
|
|
|
|
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
|
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
|
|
ProjectID: projectID,
|
|
Name: environment,
|
|
})
|
|
return row.ID, err
|
|
}
|
|
|
|
func inferServiceSourceType(service Service) string {
|
|
if service.GitRepo != "" {
|
|
return "github"
|
|
}
|
|
if service.Image != "" {
|
|
return "image"
|
|
}
|
|
return "dockerfile"
|
|
}
|
|
|
|
// handleGetService retrieves a specific service
|
|
func handleGetService(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
// Get service — public when its project is approved, otherwise
|
|
// owner/member/admin only.
|
|
userID := optionalUserUUID(c)
|
|
var service Service
|
|
err = db.(*database.DB).QueryRow(
|
|
`SELECT s.id, s.project_id, s.name,
|
|
COALESCE(s.type, s.service_type, ''),
|
|
COALESCE(s.status, ''),
|
|
COALESCE(s.image, s.image_name, ''),
|
|
COALESCE(s.command, s.start_command, ''),
|
|
COALESCE(s.environment, ''),
|
|
COALESCE(s.git_repo, s.source_url, ''),
|
|
COALESCE(s.git_branch, ''),
|
|
COALESCE(s.build_path, ''),
|
|
COALESCE(s.cpu, ''),
|
|
COALESCE(s.memory, ''),
|
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
COALESCE(s.static_dir, ''),
|
|
s.created_at, s.updated_at
|
|
FROM services s
|
|
JOIN projects p ON s.project_id = p.id
|
|
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
|
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
|
serviceID, userID, contextIsAdmin(c),
|
|
).Scan(
|
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
&service.CreatedAt, &service.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
|
|
liveServiceStatus(c, db.(*database.DB), &service)
|
|
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
|
|
service.Volumes = append(service.Volumes, ServiceVolume{
|
|
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
|
|
})
|
|
}
|
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
|
loadServiceSleep(db.(*database.DB), &service)
|
|
loadServiceNode(db.(*database.DB), &service)
|
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
|
service.MaintenanceMode = maintenance
|
|
service.BasicAuth = basicAuthUsernames(basicAuth)
|
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
|
}
|
|
|
|
// handleUpdateService updates a service
|
|
func handleUpdateService(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
var req UpdateServiceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
// Check if service exists and user has access
|
|
var existingService Service
|
|
err = db.(*database.DB).QueryRow(
|
|
`SELECT s.id, s.project_id, s.name,
|
|
COALESCE(s.type, s.service_type, ''),
|
|
COALESCE(s.status, ''),
|
|
COALESCE(s.image, s.image_name, ''),
|
|
COALESCE(s.command, s.start_command, ''),
|
|
COALESCE(s.environment, ''),
|
|
COALESCE(s.git_repo, s.source_url, ''),
|
|
COALESCE(s.git_branch, ''),
|
|
COALESCE(s.build_path, ''),
|
|
COALESCE(s.cpu, ''),
|
|
COALESCE(s.memory, ''),
|
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
COALESCE(s.static_dir, ''),
|
|
s.created_at, s.updated_at
|
|
FROM services s
|
|
JOIN projects p ON s.project_id = p.id
|
|
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
|
serviceID, userID, contextIsAdmin(c),
|
|
).Scan(
|
|
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
|
|
&existingService.Status, &existingService.Image, &existingService.Command,
|
|
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
|
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
|
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
|
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
|
&existingService.Builder, &existingService.CPUReserve,
|
|
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
|
|
&existingService.StaticDir,
|
|
&existingService.CreatedAt, &existingService.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
|
|
// Update fields if provided
|
|
if req.Name != "" {
|
|
existingService.Name = req.Name
|
|
}
|
|
if req.Type != "" {
|
|
existingService.Type = req.Type
|
|
}
|
|
if req.Image != "" {
|
|
existingService.Image = req.Image
|
|
}
|
|
if req.Command != "" {
|
|
existingService.Command = req.Command
|
|
}
|
|
if req.Environment != "" {
|
|
existingService.Environment = req.Environment
|
|
}
|
|
if req.GitRepo != "" {
|
|
existingService.GitRepo = req.GitRepo
|
|
}
|
|
if req.GitBranch != "" {
|
|
existingService.GitBranch = req.GitBranch
|
|
}
|
|
if req.BuildPath != "" {
|
|
existingService.BuildPath = req.BuildPath
|
|
}
|
|
if req.CPU != "" {
|
|
existingService.CPU = req.CPU
|
|
}
|
|
if req.Memory != "" {
|
|
existingService.Memory = req.Memory
|
|
}
|
|
if req.Replicas != nil {
|
|
existingService.Replicas = *req.Replicas
|
|
}
|
|
if req.Port != nil {
|
|
existingService.Port = *req.Port
|
|
}
|
|
if req.Domain != nil {
|
|
existingService.Domain = *req.Domain
|
|
}
|
|
if req.HealthCheckPath != nil {
|
|
existingService.HealthCheckPath = *req.HealthCheckPath
|
|
}
|
|
if req.RestartPolicy != "" {
|
|
existingService.RestartPolicy = req.RestartPolicy
|
|
}
|
|
if req.Builder != "" {
|
|
existingService.Builder = req.Builder
|
|
}
|
|
if req.CPUReserve != nil {
|
|
existingService.CPUReserve = *req.CPUReserve
|
|
}
|
|
if req.MemoryReserve != nil {
|
|
existingService.MemoryReserve = *req.MemoryReserve
|
|
}
|
|
if req.StaticBuildCmd != nil {
|
|
existingService.StaticBuildCmd = *req.StaticBuildCmd
|
|
}
|
|
if req.StaticDir != nil {
|
|
existingService.StaticDir = *req.StaticDir
|
|
}
|
|
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
|
existingService.StaticDir = "dist"
|
|
}
|
|
loadServiceSleep(db.(*database.DB), &existingService)
|
|
if req.SleepEnabled != nil {
|
|
existingService.SleepEnabled = *req.SleepEnabled
|
|
}
|
|
if req.SleepIdleMinutes != nil {
|
|
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
|
|
}
|
|
if req.TraefikLabels != nil {
|
|
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
|
return
|
|
}
|
|
existingService.TraefikLabels = labels
|
|
} else {
|
|
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
|
|
}
|
|
for label, check := range map[string]func(string) error{
|
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
|
} {
|
|
v := map[string]string{
|
|
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
|
|
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
|
|
}[label]
|
|
if err := check(v); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
}
|
|
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
|
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
|
var volumesArg interface{}
|
|
if req.Volumes != nil {
|
|
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
existingService.Volumes = *req.Volumes
|
|
volumesJSON, _ := json.Marshal(*req.Volumes)
|
|
volumesArg = volumesJSON
|
|
}
|
|
|
|
if req.Spread != nil {
|
|
existingService.Spread = *req.Spread
|
|
} else {
|
|
existingService.Spread = serviceSpread(db.(*database.DB), serviceID)
|
|
}
|
|
if req.PlacementTags != nil {
|
|
existingService.PlacementTags = normalizePlacementTags(*req.PlacementTags)
|
|
} else {
|
|
existingService.PlacementTags = servicePlacementTags(db.(*database.DB), serviceID)
|
|
}
|
|
if req.NodeID != nil {
|
|
nodeValue := strings.TrimSpace(*req.NodeID)
|
|
if existingService.Spread && nodeValue != "" && nodeValue != "auto" && nodeValue != "local" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive — set spread=false first", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if len(existingService.PlacementTags) > 0 && nodeValue != "" && nodeValue != "auto" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "service has placement tags — clear them (placement_tags=[]) before pinning or going local", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), nodeValue, existingService.PlacementTags)
|
|
if nodeErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
existingService.NodeID = nodeID
|
|
} else {
|
|
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
|
|
}
|
|
if existingService.Spread && existingService.NodeID != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before enabling spread", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if len(existingService.PlacementTags) > 0 && existingService.NodeID != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before setting placement tags", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
existingService.UpdatedAt = time.Now()
|
|
|
|
// Update service in database
|
|
_, err = db.(*database.DB).Exec(
|
|
`UPDATE services
|
|
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
|
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
|
static_build_cmd = $22, static_dir = $23,
|
|
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
|
|
placement_tags = $28::jsonb, traefik_labels = $29::jsonb
|
|
WHERE id = $18`,
|
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
|
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
|
existingService.UpdatedAt, volumesArg, existingService.ID,
|
|
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
|
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
|
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
|
|
existingService.Spread,
|
|
tagsJSON(existingService.PlacementTags),
|
|
traefikLabelsJSON(existingService.TraefikLabels),
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service"})
|
|
return
|
|
}
|
|
|
|
// Domain writes route through service_domains — services.domain is the
|
|
// derived default and gets rewritten by syncDefaultDomain.
|
|
if req.Domain != nil {
|
|
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
|
|
if domain == "" {
|
|
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
|
|
} else if validHostname(domain) {
|
|
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
|
|
_, _ = db.(*database.DB).Exec(
|
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
|
serviceID, domain)
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
|
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
|
|
}
|
|
|
|
if req.MaintenanceMode != nil || req.BasicAuth != nil {
|
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
|
|
if req.MaintenanceMode != nil {
|
|
maintenance = *req.MaintenanceMode
|
|
}
|
|
if req.BasicAuth != nil {
|
|
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
|
|
if encErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
|
|
return
|
|
}
|
|
basicAuth = encoded
|
|
}
|
|
_, _ = db.(*database.DB).Exec(
|
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
|
maintenance, basicAuth, serviceID)
|
|
existingService.MaintenanceMode = maintenance
|
|
}
|
|
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
|
|
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
|
|
existingService.BasicAuth = basicAuthUsernames(storedAuth)
|
|
|
|
// A replica change applies immediately when the service has live
|
|
// containers; other spec fields take effect on the next deploy.
|
|
// Runs through the deploy queue so it can't overlap a build.
|
|
if req.Replicas != nil && existingService.Status == "running" {
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
engine := engineValue.(*deployment.DeploymentEngine)
|
|
serviceCopy := existingService
|
|
dbCopy := db.(*database.DB)
|
|
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
|
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
|
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
|
}
|
|
}})
|
|
}
|
|
}
|
|
|
|
loadServiceNode(db.(*database.DB), &existingService)
|
|
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
|
}
|
|
|
|
// handleDeleteService deletes a service
|
|
func handleDeleteService(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
// Check if service exists and user has access
|
|
var projectOwnerID string
|
|
err = db.(*database.DB).QueryRow(
|
|
`SELECT p.owner_id
|
|
FROM services s
|
|
JOIN projects p ON s.project_id = p.id
|
|
WHERE s.id = $1`,
|
|
serviceID,
|
|
).Scan(&projectOwnerID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
|
|
// Check if user owns the project
|
|
if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Remove runtime containers before dropping the row.
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, serviceID)
|
|
}
|
|
}
|
|
|
|
// Delete service (cascade will handle related records)
|
|
_, err = db.(*database.DB).Exec(
|
|
"DELETE FROM services WHERE id = $1",
|
|
serviceID,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service"})
|
|
return
|
|
}
|
|
|
|
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
|
|
}
|