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.
|
// 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,
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user