feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)

service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.

- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
  (ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
  (base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
  responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
  --maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
  bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast

Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 16:51:03 +02:00
1 parent 2f8f595d6e
commit e995c98d05
19 files changed
+1565 -42

No files matched your search

+10
View File
@@ -401,6 +401,12 @@ func runDeploymentAndSyncWithImage(
// ephemeral ports. // ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := ""
if maintenanceMode {
maintURL = maintenanceURL(db)
}
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(), ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(), ServiceID: service.ID.String(),
@@ -417,10 +423,14 @@ func runDeploymentAndSyncWithImage(
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID), VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{ Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory), MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU), CPUQuota: parseCPULimit(service.CPU),
}, },
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
}, },
Trigger: deployment.TriggerConfig{ Trigger: deployment.TriggerConfig{
Type: req.Trigger, Type: req.Trigger,
+375
View File
@@ -0,0 +1,375 @@
package api
import (
"context"
"database/sql"
"net"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ServiceDomain is one hostname routed to a service. services.domain stays
// the derived default; this table is the source of truth for the full set.
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
type BasicAuthCred struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func validHostname(d string) bool {
return len(d) <= 253 && hostnameRE.MatchString(d)
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
}
}
return out
}
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
domains := loadServiceDomains(db, serviceID)
out := make([]string, 0, len(domains))
for _, d := range domains {
out = append(out, d.Domain)
}
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
return out
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
}
// maintenanceURL is the redirect target for maintenance mode — the public
// base URL of this API plus the maintenance page path.
func maintenanceURL(db *database.DB) string {
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
if base == "" {
return ""
}
return base + "/api/v1/maintenance"
}
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
pairs := make([]string, 0, len(creds))
for _, cred := range creds {
if cred.Username == "" || cred.Password == "" {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
pairs = append(pairs, cred.Username+":"+string(hash))
}
return strings.Join(pairs, ","), nil
}
// basicAuthUsernames returns just the usernames for API responses — never
// the hashes.
func basicAuthUsernames(encoded string) []string {
var out []string
for _, pair := range strings.Split(encoded, ",") {
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
out = append(out, u)
}
}
return out
}
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 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(&ok)
return err == nil && ok
}
func handleListServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
func handleAddServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
var req struct {
Domain string `json:"domain" binding:"required"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if !validHostname(domain) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
return
}
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
defer tx.Rollback()
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
return
}
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
if err := tx.Commit(); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
}
func handleDeleteServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
}
func handleSetDefaultDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
// handleCheckServiceDomains resolves each domain's DNS and compares it to
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
// base_url host). Statuses: ok | wrong-target | pending.
func handleCheckServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
dbh := db.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
expectedHost := ""
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
expectedHost, _, _ = strings.Cut(u, "/")
expectedHost, _, _ = strings.Cut(expectedHost, ":")
}
}
type result struct {
Domain string `json:"domain"`
Status string `json:"status"` // ok | wrong-target | pending
Resolved []string `json:"resolved"`
}
results := []result{}
resolver := net.DefaultResolver
for _, d := range loadServiceDomains(dbh, serviceID) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
addrs, _ := resolver.LookupHost(ctx, d.Domain)
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
cancel()
r := result{Domain: d.Domain, Resolved: addrs}
cname = strings.TrimSuffix(cname, ".")
switch {
case len(addrs) == 0:
r.Status = "pending"
case expectedIP != "":
r.Status = "wrong-target"
for _, a := range addrs {
if a == expectedIP {
r.Status = "ok"
}
}
case expectedHost != "":
r.Status = "ok" // resolves; CNAME match is advisory only
if cname != "" && cname != d.Domain && cname != expectedHost {
r.Status = "wrong-target"
}
default:
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
// handleMaintenancePage serves the maintenance-mode landing page. Public:
// browsers hit it via the Traefik redirect before any auth.
func handleMaintenancePage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
</style></head><body><div class="card">
<div class="title">This service is under maintenance</div>
<div class="sub">Please check back shortly.</div>
<div class="brand">Containr</div>
</div></body></html>`))
}
+6
View File
@@ -248,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap) public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin) public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
} }
// Read routes — public browsing. OptionalAuth resolves a session when // Read routes — public browsing. OptionalAuth resolves a session when
@@ -268,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/metrics", handleGetServiceMetrics) read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime) read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment) read.GET("/deployments/:id", handleGetDeployment)
@@ -326,6 +329,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/services/:id", handleUpdateService) authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService) authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/domains", handleAddServiceDomain)
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
authed.POST("/services/:id/start", handleServiceStart) authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart) authed.POST("/services/:id/restart", handleServiceRestart)
+5
View File
@@ -54,6 +54,11 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.NanoCPUs = parseCPULimit(service.CPU) spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory) spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.Volumes = loadServiceVolumes(db, service.ID) spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
if spec.Port > 0 { if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port) env["PORT"] = strconv.Itoa(service.Port)
} }
+80 -4
View File
@@ -38,6 +38,9 @@ type Service struct {
RestartPolicy string `json:"restart_policy" db:"restart_policy"` RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB 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
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -112,6 +115,8 @@ type CreateServiceRequest struct {
HealthCheckPath string `json:"healthcheck_path"` HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes []ServiceVolume `json:"volumes"` Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuth []BasicAuthCred `json:"basic_auth"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -132,6 +137,8 @@ type UpdateServiceRequest struct {
HealthCheckPath *string `json:"healthcheck_path"` HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"` Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -359,6 +366,26 @@ func handleCreateService(c *gin.Context) {
return 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)
c.JSON(http.StatusCreated, gin.H{"service": service}) c.JSON(http.StatusCreated, gin.H{"service": service})
} }
@@ -441,6 +468,10 @@ func handleGetService(c *gin.Context) {
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly, Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
}) })
} }
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth)
c.JSON(http.StatusOK, gin.H{"service": service}) c.JSON(http.StatusOK, gin.H{"service": service})
} }
@@ -555,14 +586,17 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" { if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy existingService.RestartPolicy = req.RestartPolicy
} }
var volumesJSON []byte // 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 req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil { if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return return
} }
existingService.Volumes = *req.Volumes existingService.Volumes = *req.Volumes
volumesJSON, _ = json.Marshal(*req.Volumes) volumesJSON, _ := json.Marshal(*req.Volumes)
volumesArg = volumesJSON
} }
existingService.UpdatedAt = time.Now() existingService.UpdatedAt = time.Now()
@@ -573,14 +607,14 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5, SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16 restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
WHERE id = $18`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory, existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain, existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy, existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, volumesJSON, existingService.ID, existingService.UpdatedAt, volumesArg, existingService.ID,
) )
if err != nil { if err != nil {
@@ -588,6 +622,48 @@ func handleUpdateService(c *gin.Context) {
return 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 // A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy. // containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build. // Runs through the deploy queue so it can't overlap a build.
+155 -1
View File
@@ -179,6 +179,21 @@ var servicesUpdateCmd = &cobra.Command{
} }
body["volumes"] = volumes body["volumes"] = volumes
} }
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
}
if cmd.Flags().Changed("basic-auth") {
creds := []map[string]interface{}{}
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
for _, p := range pairs {
user, pass, ok := strings.Cut(p, ":")
if ok {
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
}
}
body["basic_auth"] = creds
}
if len(body) == 0 { if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError} return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
} }
@@ -220,6 +235,141 @@ func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interfac
return out return out
} }
var servicesDomainsCmd = &cobra.Command{
Use: "domains",
Short: "Manage service domains",
}
var domainsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List domains attached to a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
def := ""
if b, ok := d["is_default"].(bool); ok && b {
def = "*"
}
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
}
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
return nil
},
}
var domainsAddCmd = &cobra.Command{
Use: "add <service-id> <domain>",
Short: "Attach a domain to a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
def, _ := cmd.Flags().GetBool("default")
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
"domain": args[1], "is_default": def,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Added domain %s\n", args[1])
return nil
},
}
var domainsRemoveCmd = &cobra.Command{
Use: "remove <service-id> <domain-id>",
Short: "Detach a domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
}
fmt.Printf("Removed domain %s\n", args[1])
return nil
},
}
var domainsDefaultCmd = &cobra.Command{
Use: "default <service-id> <domain-id>",
Short: "Set the default domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Default domain updated\n")
return nil
},
}
var domainsCheckCmd = &cobra.Command{
Use: "check <service-id>",
Short: "DNS preflight for all service domains",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
resolved, _ := d["resolved"].([]interface{})
addrs := make([]string, 0, len(resolved))
for _, a := range resolved {
if s, ok := a.(string); ok {
addrs = append(addrs, s)
}
}
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
}
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
return nil
},
}
// serviceAction returns a RunE for the simple POST action endpoints. // serviceAction returns a RunE for the simple POST action endpoints.
func serviceAction(verb string) func(*cobra.Command, []string) error { func serviceAction(verb string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
@@ -271,6 +421,8 @@ func init() {
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)") uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)") uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
uf.Bool("clear-volumes", false, "remove all volume mounts") uf.Bool("clear-volumes", false, "remove all volume mounts")
uf.String("maintenance", "", "maintenance mode: on|off")
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
for _, a := range []string{"start", "stop", "restart", "redeploy"} { for _, a := range []string{"start", "stop", "restart", "redeploy"} {
verb := a verb := a
@@ -281,5 +433,7 @@ func init() {
RunE: serviceAction(verb), RunE: serviceAction(verb),
}) })
} }
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd) domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
} }
@@ -202,6 +202,19 @@ type Service struct {
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"` PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"` Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt sql.NullTime `json:"last_checked_at"`
CreatedAt time.Time `json:"created_at"`
} }
type ServiceTemplate struct { type ServiceTemplate struct {
+24 -15
View File
@@ -57,6 +57,11 @@ type ServiceConfig struct {
PublicPort int32 `json:"public_port,omitempty"` PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik. // Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// Domains is the full hostname set (includes Domain when set).
Domains []string `json:"domains,omitempty"`
Maintenance bool `json:"maintenance,omitempty"`
MaintenanceURL string `json:"maintenance_url,omitempty"`
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health. // HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"` HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0 // PublishedPort is the host port bound by the previous deployment; replica 0
@@ -312,21 +317,25 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
cfg := deployment.Config cfg := deployment.Config
spec := RuntimeSpec{ spec := RuntimeSpec{
ProjectID: deployment.ProjectID, ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID, ServiceID: deployment.ServiceID,
Name: cfg.Name, Name: cfg.Name,
Image: deployment.ImageName, Image: deployment.ImageName,
Command: cfg.Command, Command: cfg.Command,
Env: cfg.Environment, Env: cfg.Environment,
Replicas: cfg.Replicas, Replicas: cfg.Replicas,
Port: cfg.PublicPort, Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort, PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain, Domain: cfg.Domain,
HealthPath: cfg.HealthPath, HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy, RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes, MemoryBytes: cfg.Resources.MemoryBytes,
NanoCPUs: cfg.Resources.CPUQuota, NanoCPUs: cfg.Resources.CPUQuota,
Volumes: cfg.VolumeMounts, Volumes: cfg.VolumeMounts,
Domains: cfg.Domains,
Maintenance: cfg.Maintenance,
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
} }
for _, pm := range cfg.PortMappings { for _, pm := range cfg.PortMappings {
if spec.Port == 0 { if spec.Port == 0 {
+60 -19
View File
@@ -34,21 +34,25 @@ const (
// RuntimeSpec is everything needed to run a service's containers. // RuntimeSpec is everything needed to run a service's containers.
type RuntimeSpec struct { type RuntimeSpec struct {
ProjectID string ProjectID string
ServiceID string ServiceID string
Name string // DNS alias on the project network Name string // DNS alias on the project network
Image string Image string
Command []string Command []string
Env map[string]string // already resolved Env map[string]string // already resolved
Replicas int Replicas int
Port int32 // container port to expose publicly; 0 = none Port int32 // container port to expose publicly; 0 = none
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
Domain string // public hostname routed via Traefik; empty = none Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string RestartPolicy string
MemoryBytes int64 MemoryBytes int64
NanoCPUs int64 NanoCPUs int64
Volumes []VolumeMount // applied to every replica Volumes []VolumeMount // applied to every replica
Domains []string // all public hostnames; Domain is the default
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
} }
// RuntimeContainer describes one live replica. // RuntimeContainer describes one live replica.
@@ -227,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}}, projectNet: {Aliases: []string{spec.Name}},
} }
if edgeNet != "" { if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{} endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true" labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)" labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web" labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
} }
cfg := docker.ContainerConfig{ cfg := docker.ContainerConfig{
@@ -490,8 +527,12 @@ func specHash(spec RuntimeSpec) string {
Port int32 Port int32
MemoryBytes, NanoCPUs int64 MemoryBytes, NanoCPUs int64
Volumes []VolumeMount Volumes []VolumeMount
Domains []string
Maintenance bool
BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes}) spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload) sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
} }
+8 -1
View File
@@ -61,7 +61,14 @@ func init() {
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear.", "PUT", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Docker volumes (admin) // Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false}) register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
@@ -0,0 +1,30 @@
-- +goose Up
-- Multi-domain support. services.domain stays as the derived default for
-- compatibility; service_domains is the source of truth for the full
-- hostname set. Backfills existing services.domain rows as defaults.
CREATE TABLE IF NOT EXISTS service_domains (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
cert_status varchar(32) NOT NULL DEFAULT 'pending',
last_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
INSERT INTO service_domains (service_id, domain, is_default)
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
ON CONFLICT (service_id, domain) DO NOTHING;
-- Access control: maintenance mode redirects to a platform page; basic auth
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
DROP TABLE IF EXISTS service_domains;
+15 -1
View File
@@ -59,7 +59,21 @@ CREATE TABLE services (
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '', healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped', restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
published_port INTEGER NOT NULL DEFAULT 0, published_port INTEGER NOT NULL DEFAULT 0,
volumes JSONB NOT NULL DEFAULT '[]'::jsonb volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
basic_auth_users TEXT NOT NULL DEFAULT ''
);
CREATE TABLE service_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt',
cert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
); );
CREATE TABLE deployments ( CREATE TABLE deployments (
@@ -36,6 +36,13 @@ import {
getServiceRuntime, getServiceRuntime,
type CronJobEntity, type CronJobEntity,
type ServiceVolume, type ServiceVolume,
type ServiceDomain,
type DomainCheckResult,
listServiceDomains,
addServiceDomain,
removeServiceDomain,
setDefaultServiceDomain,
checkServiceDomains,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
@@ -438,6 +445,23 @@ export function ServiceDetailPage() {
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string; port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null); } | null>(null);
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null); const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const domainsQuery = useQuery({
queryKey: ['service-domains', serviceId],
queryFn: () => listServiceDomains(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const domainMutation = useMutation({
mutationFn: (fn: () => Promise<unknown>) => fn(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
invalidateService();
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -2194,6 +2218,174 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
<button
type="button"
disabled={domainMutation.isPending}
onClick={async () => {
const results = await checkServiceDomains(serviceId);
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
}}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Check DNS
</button>
</div>
<div className="space-y-2 mb-3">
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
)}
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
const check = domainChecks?.[d.domain];
return (
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
{check && (
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
{check.status}
</span>
)}
<span className="flex-1" />
{!d.is_default && (
<button
type="button"
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
set default
</button>
)}
<button
type="button"
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
className="text-[var(--error)]"
>
<X size={12} />
</button>
</div>
);
})}
</div>
<div className="flex items-center gap-2">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="app.example.com"
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<button
type="button"
disabled={domainMutation.isPending || !domainInput.trim()}
onClick={() =>
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
onSuccess: () => setDomainInput(''),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
{domainMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
)}
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
{accessForm === null && (
<button
type="button"
onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{accessForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={accessForm.maintenance}
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
/>
Maintenance mode — redirect all traffic to the maintenance page
</label>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
<textarea
value={accessForm.basicAuth}
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
rows={2}
placeholder="admin:s3cret"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
const creds = accessForm.basicAuth
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const i = l.indexOf(':');
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
})
.filter((x): x is { username: string; password: string } => x !== null);
updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
{ onSuccess: () => setAccessForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setAccessForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
</div>
</div>
)}
</div>
)}
{!isDemoMode && ( {!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
+279
View File
@@ -971,6 +971,245 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/services/{id}/domains": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List service domains
* @description All hostnames attached to the service; the default is mirrored to services.domain
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Domain list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
domains?: components["schemas"]["ServiceDomain"][];
};
};
};
};
};
put?: never;
/**
* Attach a domain
* @description Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
domain: string;
is_default?: boolean;
};
};
};
responses: {
/** @description Domain attached */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
domain?: components["schemas"]["ServiceDomain"];
};
};
};
/** @description Domain already attached */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/domains/check": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* DNS preflight
* @description Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Per-domain DNS status */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
domains?: components["schemas"]["DomainCheckResult"][];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/domains/{domain_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Detach a domain */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
domain_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Domain removed */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/domains/{domain_id}/default": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Set the default domain
* @description Marks the domain default and mirrors it to services.domain
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
domain_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Updated domain list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
domains?: components["schemas"]["ServiceDomain"][];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/maintenance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Maintenance page
* @description Public HTML page Traefik redirects to while a service is in maintenance mode
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: never;
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/deployments": { "/services/{id}/deployments": {
parameters: { parameters: {
query?: never; query?: never;
@@ -7805,6 +8044,12 @@ export interface components {
restart_policy?: string; restart_policy?: string;
/** @description Volume/bind mounts applied to every replica */ /** @description Volume/bind mounts applied to every replica */
volumes?: components["schemas"]["ServiceVolume"][]; volumes?: components["schemas"]["ServiceVolume"][];
/** @description All attached hostnames; `domain` is the derived default */
domains?: components["schemas"]["ServiceDomain"][];
/** @description Redirect all traffic to the platform maintenance page */
maintenance_mode?: boolean;
/** @description Basic-auth usernames gating the route (hashes never returned) */
basic_auth?: string[];
/** @description Computed public URL (published port or domain) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -7902,6 +8147,40 @@ export interface components {
restart_policy?: string; restart_policy?: string;
/** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */ /** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */
volumes?: components["schemas"]["ServiceVolume"][]; volumes?: components["schemas"]["ServiceVolume"][];
/** @description Redirect all traffic to the platform maintenance page */
maintenance_mode?: boolean;
/** @description Replaces all basic-auth credentials; [] clears the gate */
basic_auth?: components["schemas"]["BasicAuthCred"][];
};
ServiceDomain: {
/** Format: uuid */
id?: string;
/** Format: uuid */
service_id?: string;
domain?: string;
is_default?: boolean;
cert_type?: string;
/** @enum {string} */
cert_status?: "pending" | "issued" | "failed";
/** Format: date-time */
last_checked_at?: string;
/** Format: date-time */
created_at?: string;
};
DomainCheckResult: {
domain?: string;
/**
* @description ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
* @enum {string}
*/
status?: "ok" | "wrong-target" | "pending";
/** @description A/AAAA records resolved */
resolved?: string[];
};
BasicAuthCred: {
username: string;
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
password: string;
}; };
ServiceVolume: { ServiceVolume: {
/** /**
+1 -1
View File
@@ -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\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|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\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`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel <id>` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" "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 domains list|add|remove|default|check multi-domain + DNS preflight\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|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\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`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+53
View File
@@ -55,9 +55,29 @@ export type ServiceEntity = {
healthcheckPath?: string; healthcheckPath?: string;
restartPolicy?: string; restartPolicy?: string;
volumes?: ServiceVolume[]; volumes?: ServiceVolume[];
domains?: ServiceDomain[];
maintenanceMode?: boolean;
basicAuth?: string[];
publicUrl?: string; publicUrl?: string;
}; };
export type ServiceDomain = {
id: string;
service_id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
last_checked_at?: string;
created_at?: string;
};
export type DomainCheckResult = {
domain: string;
status: 'ok' | 'wrong-target' | 'pending';
resolved?: string[];
};
export type ServiceVolume = { export type ServiceVolume = {
type?: 'volume' | 'bind'; type?: 'volume' | 'bind';
source: string; source: string;
@@ -561,6 +581,9 @@ function normalizeService(service: RawService): ServiceEntity | null {
healthcheckPath: service.healthcheck_path, healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy, restartPolicy: service.restart_policy,
volumes: service.volumes as ServiceVolume[] | undefined, volumes: service.volumes as ServiceVolume[] | undefined,
domains: service.domains as ServiceDomain[] | undefined,
maintenanceMode: service.maintenance_mode,
basicAuth: service.basic_auth as string[] | undefined,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -1100,6 +1123,36 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed; return parsed;
} }
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
return payload.domains ?? [];
}
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
method: 'POST',
body: JSON.stringify({ domain, is_default: isDefault }),
});
return payload.domain;
}
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
}
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
`/services/${serviceId}/domains/${domainId}/default`,
{ method: 'POST' },
);
return payload.domains ?? [];
}
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
return payload.domains ?? [];
}
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> { export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, { const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
method: 'PUT', method: 'PUT',
+230
View File
@@ -667,6 +667,163 @@ paths:
'200': '200':
description: Reconciled runtime state description: Reconciled runtime state
/services/{id}/domains:
get:
tags:
- Services
summary: List service domains
description: All hostnames attached to the service; the default is mirrored to services.domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Domain list
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
post:
tags:
- Services
summary: Attach a domain
description: Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- domain
properties:
domain:
type: string
is_default:
type: boolean
responses:
'201':
description: Domain attached
content:
application/json:
schema:
type: object
properties:
domain:
$ref: '#/components/schemas/ServiceDomain'
'409':
description: Domain already attached
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/services/{id}/domains/check:
get:
tags:
- Services
summary: DNS preflight
description: Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Per-domain DNS status
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/DomainCheckResult'
/services/{id}/domains/{domain_id}:
delete:
tags:
- Services
summary: Detach a domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: domain_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Domain removed
/services/{id}/domains/{domain_id}/default:
post:
tags:
- Services
summary: Set the default domain
description: Marks the domain default and mirrors it to services.domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: domain_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Updated domain list
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
/maintenance:
get:
tags:
- Services
summary: Maintenance page
description: Public HTML page Traefik redirects to while a service is in maintenance mode
/services/{id}/deployments: /services/{id}/deployments:
get: get:
tags: tags:
@@ -5003,6 +5160,19 @@ components:
items: items:
$ref: '#/components/schemas/ServiceVolume' $ref: '#/components/schemas/ServiceVolume'
description: Volume/bind mounts applied to every replica description: Volume/bind mounts applied to every replica
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
description: All attached hostnames; `domain` is the derived default
maintenance_mode:
type: boolean
description: Redirect all traffic to the platform maintenance page
basic_auth:
type: array
items:
type: string
description: Basic-auth usernames gating the route (hashes never returned)
public_url: public_url:
type: string type: string
description: Computed public URL (published port or domain) description: Computed public URL (published port or domain)
@@ -5134,6 +5304,66 @@ components:
items: items:
$ref: '#/components/schemas/ServiceVolume' $ref: '#/components/schemas/ServiceVolume'
description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile.
maintenance_mode:
type: boolean
description: Redirect all traffic to the platform maintenance page
basic_auth:
type: array
items:
$ref: '#/components/schemas/BasicAuthCred'
description: Replaces all basic-auth credentials; [] clears the gate
ServiceDomain:
type: object
properties:
id:
type: string
format: uuid
service_id:
type: string
format: uuid
domain:
type: string
is_default:
type: boolean
cert_type:
type: string
cert_status:
type: string
enum: [pending, issued, failed]
last_checked_at:
type: string
format: date-time
created_at:
type: string
format: date-time
DomainCheckResult:
type: object
properties:
domain:
type: string
status:
type: string
enum: [ok, wrong-target, pending]
description: ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
resolved:
type: array
items:
type: string
description: A/AAAA records resolved
BasicAuthCred:
type: object
required:
- username
- password
properties:
username:
type: string
password:
type: string
description: Plaintext on write — stored as bcrypt htpasswd; never returned
ServiceVolume: ServiceVolume:
type: object type: object
+28
View File
@@ -38,6 +38,7 @@ auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens tokens list|create|revoke personal access tokens
projects list|get|create|delete projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy services list|get|create|update|delete|start|stop|restart|redeploy
services domains list|add|remove|default|check multi-domain + DNS preflight
volumes list|delete docker volume inventory + cleanup (admin) volumes list|delete docker volume inventory + cleanup (admin)
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch) deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel deployments list|get|logs|rollback|cancel
@@ -87,6 +88,33 @@ redeploy — `containr deploy <id>` to apply immediately.
`volumes list` shows the node's docker volume inventory with in-use `volumes list` shows the node's docker volume inventory with in-use
flags; `volumes delete <name>` removes an unused volume (admin scope). flags; `volumes delete <name>` removes an unused volume (admin scope).
## Service domains and access
A service can carry multiple hostnames; the default is mirrored to
`services.domain`:
```bash
containr services domains add <id> app.example.com --default
containr services domains check <id> # DNS preflight per domain
containr services domains remove <id> <domain-id>
```
DNS check compares each domain to the expected target — set
`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
meaningful; without it any resolvable domain reports `ok`.
Access gates apply via Traefik middlewares (no extra proxy):
```bash
containr services update <id> --maintenance on
containr services update <id> --basic-auth admin:secret --basic-auth ops:pw2
```
Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
— set `app_settings.base_url` or `BASE_URL`; without it the service is
taken offline with empty responses. Basic-auth credentials are stored
bcrypt-hashed (htpasswd) and gate every request.
## MCP server ## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools `containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
+1
View File
@@ -17,6 +17,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1. - **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers. - **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin). - **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects ## Core objects