mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
2f8f595d6e
commit
e995c98d05
19 files changed
+1565
-42
No files matched your search
@@ -401,6 +401,12 @@ func runDeploymentAndSyncWithImage(
|
||||
// ephemeral ports.
|
||||
_ = 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{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
@@ -417,10 +423,14 @@ func runDeploymentAndSyncWithImage(
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
},
|
||||
Maintenance: maintenanceMode,
|
||||
MaintenanceURL: maintURL,
|
||||
BasicAuthUsers: basicAuthUsers,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
|
||||
@@ -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>`))
|
||||
}
|
||||
@@ -248,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
public.GET("/maintenance", handleMaintenancePage)
|
||||
}
|
||||
|
||||
// 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/runtime", handleGetServiceRuntime)
|
||||
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/:id", handleGetDeployment)
|
||||
|
||||
@@ -326,6 +329,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
|
||||
authed.PUT("/services/:id", handleUpdateService)
|
||||
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/stop", handleServiceStop)
|
||||
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||
|
||||
@@ -54,6 +54,11 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
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 {
|
||||
env["PORT"] = strconv.Itoa(service.Port)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ type Service struct {
|
||||
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
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -112,6 +115,8 @@ type CreateServiceRequest struct {
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes []ServiceVolume `json:"volumes"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
@@ -132,6 +137,8 @@ type UpdateServiceRequest struct {
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes *[]ServiceVolume `json:"volumes"`
|
||||
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -359,6 +366,26 @@ func handleCreateService(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -441,6 +468,10 @@ func handleGetService(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -555,14 +586,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
if 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 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)
|
||||
volumesJSON, _ := json.Marshal(*req.Volumes)
|
||||
volumesArg = volumesJSON
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
@@ -573,14 +607,14 @@ func handleUpdateService(c *gin.Context) {
|
||||
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, volumes), updated_at = $16
|
||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
|
||||
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, volumesJSON, existingService.ID,
|
||||
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -588,6 +622,48 @@ func handleUpdateService(c *gin.Context) {
|
||||
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.
|
||||
|
||||
@@ -179,6 +179,21 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
func serviceAction(verb string) func(*cobra.Command, []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("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
||||
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"} {
|
||||
verb := a
|
||||
@@ -281,5 +433,7 @@ func init() {
|
||||
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"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
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 {
|
||||
|
||||
@@ -57,6 +57,11 @@ type ServiceConfig struct {
|
||||
PublicPort int32 `json:"public_port,omitempty"`
|
||||
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||
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 string `json:"health_path,omitempty"`
|
||||
// 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
|
||||
|
||||
spec := RuntimeSpec{
|
||||
ProjectID: deployment.ProjectID,
|
||||
ServiceID: deployment.ServiceID,
|
||||
Name: cfg.Name,
|
||||
Image: deployment.ImageName,
|
||||
Command: cfg.Command,
|
||||
Env: cfg.Environment,
|
||||
Replicas: cfg.Replicas,
|
||||
Port: cfg.PublicPort,
|
||||
PublishedPort: cfg.PublishedPort,
|
||||
Domain: cfg.Domain,
|
||||
HealthPath: cfg.HealthPath,
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
Volumes: cfg.VolumeMounts,
|
||||
ProjectID: deployment.ProjectID,
|
||||
ServiceID: deployment.ServiceID,
|
||||
Name: cfg.Name,
|
||||
Image: deployment.ImageName,
|
||||
Command: cfg.Command,
|
||||
Env: cfg.Environment,
|
||||
Replicas: cfg.Replicas,
|
||||
Port: cfg.PublicPort,
|
||||
PublishedPort: cfg.PublishedPort,
|
||||
Domain: cfg.Domain,
|
||||
HealthPath: cfg.HealthPath,
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
Volumes: cfg.VolumeMounts,
|
||||
Domains: cfg.Domains,
|
||||
Maintenance: cfg.Maintenance,
|
||||
MaintenanceURL: cfg.MaintenanceURL,
|
||||
BasicAuthUsers: cfg.BasicAuthUsers,
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
|
||||
@@ -34,21 +34,25 @@ const (
|
||||
|
||||
// RuntimeSpec is everything needed to run a service's containers.
|
||||
type RuntimeSpec struct {
|
||||
ProjectID string
|
||||
ServiceID string
|
||||
Name string // DNS alias on the project network
|
||||
Image string
|
||||
Command []string
|
||||
Env map[string]string // already resolved
|
||||
Replicas int
|
||||
Port int32 // container port to expose publicly; 0 = none
|
||||
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
|
||||
Domain string // public hostname routed via Traefik; empty = none
|
||||
HealthPath string // http path probed on Port for container healthcheck
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
Volumes []VolumeMount // applied to every replica
|
||||
ProjectID string
|
||||
ServiceID string
|
||||
Name string // DNS alias on the project network
|
||||
Image string
|
||||
Command []string
|
||||
Env map[string]string // already resolved
|
||||
Replicas int
|
||||
Port int32 // container port to expose publicly; 0 = none
|
||||
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
|
||||
Domain string // public hostname routed via Traefik; empty = none
|
||||
HealthPath string // http path probed on Port for container healthcheck
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
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.
|
||||
@@ -227,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
projectNet: {Aliases: []string{spec.Name}},
|
||||
}
|
||||
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{}
|
||||
labels["traefik.enable"] = "true"
|
||||
labels["traefik.docker.network"] = edgeNet
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
||||
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
|
||||
labels["traefik.http.routers."+router+".entrypoints"] = "web"
|
||||
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{
|
||||
@@ -490,8 +527,12 @@ func specHash(spec RuntimeSpec) string {
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
Volumes []VolumeMount
|
||||
Domains []string
|
||||
Maintenance bool
|
||||
BasicAuth string
|
||||
}{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)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -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_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_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)
|
||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
||||
|
||||
Reference in new issue
Block a user