mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #20 from Dvorinka/feat/service-volumes
C1: service volume mounts + admin volume inventory
This commit is contained in:
21 files changed
+867
-93
No files matched your search
@@ -416,6 +416,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Domain: service.Domain,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
RestartPolicy: service.RestartPolicy,
|
||||||
|
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||||
Resources: deployment.ResourceLimits{
|
Resources: deployment.ResourceLimits{
|
||||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
CPUQuota: parseCPULimit(service.CPU),
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
|
|||||||
@@ -423,6 +423,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
scalingHandler.RegisterAdminRoutes(admin)
|
scalingHandler.RegisterAdminRoutes(admin)
|
||||||
agentHandler.SetupAdminRoutes(admin)
|
agentHandler.SetupAdminRoutes(admin)
|
||||||
|
|
||||||
|
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||||
|
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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)
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
env["PORT"] = strconv.Itoa(service.Port)
|
env["PORT"] = strconv.Itoa(service.Port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,53 +31,107 @@ type Service struct {
|
|||||||
CPU string `json:"cpu" db:"cpu"`
|
CPU string `json:"cpu" db:"cpu"`
|
||||||
Memory string `json:"memory" db:"memory"`
|
Memory string `json:"memory" db:"memory"`
|
||||||
// Runtime spec
|
// Runtime spec
|
||||||
Replicas int `json:"replicas" db:"replicas"`
|
Replicas int `json:"replicas" db:"replicas"`
|
||||||
Port int `json:"port" db:"port"` // container port to expose
|
Port int `json:"port" db:"port"` // container port to expose
|
||||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||||
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
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||||
|
type ServiceVolume struct {
|
||||||
|
Type string `json:"type"` // volume | bind (empty = volume)
|
||||||
|
Source string `json:"source"` // volume name or host path
|
||||||
|
Target string `json:"target"` // container path (absolute)
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||||
|
// to no mounts rather than failing the request.
|
||||||
|
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var stored []ServiceVolume
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]deployment.VolumeMount, 0, len(stored))
|
||||||
|
for _, v := range stored {
|
||||||
|
out = append(out, deployment.VolumeMount{
|
||||||
|
Type: v.Type,
|
||||||
|
Source: v.Source,
|
||||||
|
Destination: v.Target,
|
||||||
|
ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateServiceVolumes enforces the mount contract — bad paths fail
|
||||||
|
// fast at write time instead of surfacing as docker create errors later.
|
||||||
|
func validateServiceVolumes(vols []ServiceVolume) error {
|
||||||
|
for i, v := range vols {
|
||||||
|
if v.Type == "" {
|
||||||
|
v.Type = "volume"
|
||||||
|
}
|
||||||
|
if v.Type != "volume" && v.Type != "bind" {
|
||||||
|
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(v.Source) == "" {
|
||||||
|
return fmt.Errorf("volumes[%d].source is required", i)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(v.Target, "/") {
|
||||||
|
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
Replicas int `json:"replicas"`
|
Replicas int `json:"replicas"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
HealthCheckPath string `json:"healthcheck_path"`
|
HealthCheckPath string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes []ServiceVolume `json:"volumes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
Replicas *int `json:"replicas"`
|
Replicas *int `json:"replicas"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
HealthCheckPath *string `json:"healthcheck_path"`
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes *[]ServiceVolume `json:"volumes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -262,6 +320,15 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.RestartPolicy == "" {
|
if service.RestartPolicy == "" {
|
||||||
service.RestartPolicy = "unless-stopped"
|
service.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
if err := validateServiceVolumes(req.Volumes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.Volumes = req.Volumes
|
||||||
|
volumesJSON, _ := json.Marshal(req.Volumes)
|
||||||
|
if len(req.Volumes) == 0 {
|
||||||
|
volumesJSON = []byte("[]")
|
||||||
|
}
|
||||||
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,14 +344,14 @@ func handleCreateService(c *gin.Context) {
|
|||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
healthcheck_path, restart_policy, created_at, updated_at)
|
healthcheck_path, restart_policy, volumes, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
service.CreatedAt, service.UpdatedAt,
|
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -369,6 +436,11 @@ func handleGetService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
liveServiceStatus(c, db.(*database.DB), &service)
|
liveServiceStatus(c, db.(*database.DB), &service)
|
||||||
|
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
|
||||||
|
service.Volumes = append(service.Volumes, ServiceVolume{
|
||||||
|
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +555,15 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.RestartPolicy != "" {
|
if req.RestartPolicy != "" {
|
||||||
existingService.RestartPolicy = req.RestartPolicy
|
existingService.RestartPolicy = req.RestartPolicy
|
||||||
}
|
}
|
||||||
|
var volumesJSON []byte
|
||||||
|
if req.Volumes != nil {
|
||||||
|
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingService.Volumes = *req.Volumes
|
||||||
|
volumesJSON, _ = json.Marshal(*req.Volumes)
|
||||||
|
}
|
||||||
|
|
||||||
existingService.UpdatedAt = time.Now()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -492,14 +573,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, updated_at = $16
|
restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16
|
||||||
WHERE id = $17`,
|
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, existingService.ID,
|
existingService.UpdatedAt, volumesJSON, existingService.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -509,21 +590,21 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
|
|
||||||
// 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.
|
||||||
if req.Replicas != nil && existingService.Status == "running" {
|
if req.Replicas != nil && existingService.Status == "running" {
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
engine := engineValue.(*deployment.DeploymentEngine)
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
serviceCopy := existingService
|
serviceCopy := existingService
|
||||||
go func() {
|
dbCopy := db.(*database.DB)
|
||||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
||||||
defer cancel()
|
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
||||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
|
||||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
|
||||||
}
|
}
|
||||||
}()
|
}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolumeInfo is one docker volume plus whether any container mounts it.
|
||||||
|
type VolumeInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
InUse bool `json:"in_use"`
|
||||||
|
UsedBy []string `json:"used_by,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
|
||||||
|
v, exists := c.Get("docker_client")
|
||||||
|
if !exists || v == nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return v.(*docker.Client), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminListVolumes returns the node's docker volume inventory with
|
||||||
|
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
|
||||||
|
func handleAdminListVolumes(c *gin.Context) {
|
||||||
|
client, ok := dockerClientOr500(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
vols, err := client.ListVolumes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := client.ListContainers(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inUse := map[string][]string{}
|
||||||
|
for _, cont := range containers {
|
||||||
|
for _, m := range cont.Mounts {
|
||||||
|
if m.Type == "volume" {
|
||||||
|
name := cont.ID[:12]
|
||||||
|
if len(cont.Names) > 0 {
|
||||||
|
name = cont.Names[0]
|
||||||
|
}
|
||||||
|
inUse[m.Name] = append(inUse[m.Name], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]VolumeInfo, 0, len(vols.Volumes))
|
||||||
|
for _, v := range vols.Volumes {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := VolumeInfo{
|
||||||
|
Name: v.Name,
|
||||||
|
Driver: v.Driver,
|
||||||
|
Mountpoint: v.Mountpoint,
|
||||||
|
Scope: v.Scope,
|
||||||
|
Labels: v.Labels,
|
||||||
|
UsedBy: inUse[v.Name],
|
||||||
|
}
|
||||||
|
info.InUse = len(info.UsedBy) > 0
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
|
||||||
|
info.CreatedAt = t
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"volumes": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
|
||||||
|
// of in-use volumes — that error is surfaced as a conflict.
|
||||||
|
func handleAdminDeleteVolume(c *gin.Context) {
|
||||||
|
client, ok := dockerClientOr500(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
|
||||||
|
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": name})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -140,6 +141,85 @@ var servicesDeleteCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var servicesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a service (volumes, replicas, domain, ...)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
||||||
|
if cmd.Flags().Changed(f) {
|
||||||
|
key := f
|
||||||
|
switch f {
|
||||||
|
case "restart-policy":
|
||||||
|
key = "restart_policy"
|
||||||
|
case "healthcheck-path":
|
||||||
|
key = "healthcheck_path"
|
||||||
|
}
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("replicas") {
|
||||||
|
v, _ := cmd.Flags().GetInt("replicas")
|
||||||
|
body["replicas"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("port") {
|
||||||
|
v, _ := cmd.Flags().GetInt("port")
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
||||||
|
volumes := []map[string]interface{}{}
|
||||||
|
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
||||||
|
volumes = append(volumes, spec)
|
||||||
|
}
|
||||||
|
body["volumes"] = volumes
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/services/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Updated service %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||||
|
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||||
|
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, f := range flags {
|
||||||
|
vals, _ := cmd.Flags().GetStringArray(f)
|
||||||
|
for _, v := range vals {
|
||||||
|
parts := strings.SplitN(v, ":", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"type": f,
|
||||||
|
"source": parts[0],
|
||||||
|
"target": parts[1],
|
||||||
|
}
|
||||||
|
if len(parts) == 3 && parts[2] == "ro" {
|
||||||
|
m["read_only"] = true
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -177,6 +257,21 @@ func init() {
|
|||||||
f.Int("port", 0, "container port")
|
f.Int("port", 0, "container port")
|
||||||
f.Int("replicas", 0, "replica count")
|
f.Int("replicas", 0, "replica count")
|
||||||
|
|
||||||
|
uf := servicesUpdateCmd.Flags()
|
||||||
|
uf.String("name", "", "service name")
|
||||||
|
uf.String("image", "", "container image")
|
||||||
|
uf.String("command", "", "container start command")
|
||||||
|
uf.String("domain", "", "public domain (empty string clears)")
|
||||||
|
uf.String("restart-policy", "", "docker restart policy")
|
||||||
|
uf.String("healthcheck-path", "", "HTTP health check path")
|
||||||
|
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||||
|
uf.String("memory", "", "memory limit (e.g. 512m)")
|
||||||
|
uf.Int("replicas", 0, "replica count")
|
||||||
|
uf.Int("port", 0, "container port")
|
||||||
|
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
||||||
|
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
||||||
|
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||||
|
|
||||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||||
verb := a
|
verb := a
|
||||||
ServicesCmd.AddCommand(&cobra.Command{
|
ServicesCmd.AddCommand(&cobra.Command{
|
||||||
@@ -186,5 +281,5 @@ func init() {
|
|||||||
RunE: serviceAction(verb),
|
RunE: serviceAction(verb),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesDeleteCmd)
|
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolumesCmd manages docker volumes on the node (admin).
|
||||||
|
var VolumesCmd = &cobra.Command{
|
||||||
|
Use: "volumes",
|
||||||
|
Aliases: []string{"volume"},
|
||||||
|
Short: "Manage docker volumes (admin)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List docker volumes with in-use flags",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/admin/volumes", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "volumes")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
inUse := "no"
|
||||||
|
if b, ok := v["in_use"].(bool); ok && b {
|
||||||
|
inUse = "yes"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <name>",
|
||||||
|
Short: "Delete an unused docker volume",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted volume %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.LogsCmd)
|
rootCmd.AddCommand(commands.LogsCmd)
|
||||||
rootCmd.AddCommand(commands.ExecCmd)
|
rootCmd.AddCommand(commands.ExecCmd)
|
||||||
rootCmd.AddCommand(commands.VariablesCmd)
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
|||||||
@@ -168,39 +168,40 @@ type ProjectMember struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||||
ServiceType string `json:"service_type"`
|
ServiceType string `json:"service_type"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
SourceUrl sql.NullString `json:"source_url"`
|
SourceUrl sql.NullString `json:"source_url"`
|
||||||
ImageName sql.NullString `json:"image_name"`
|
ImageName sql.NullString `json:"image_name"`
|
||||||
BuildCommand sql.NullString `json:"build_command"`
|
BuildCommand sql.NullString `json:"build_command"`
|
||||||
StartCommand sql.NullString `json:"start_command"`
|
StartCommand sql.NullString `json:"start_command"`
|
||||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||||
PublicUrl sql.NullString `json:"public_url"`
|
PublicUrl sql.NullString `json:"public_url"`
|
||||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Type sql.NullString `json:"type"`
|
Type sql.NullString `json:"type"`
|
||||||
Image sql.NullString `json:"image"`
|
Image sql.NullString `json:"image"`
|
||||||
Command sql.NullString `json:"command"`
|
Command sql.NullString `json:"command"`
|
||||||
Environment sql.NullString `json:"environment"`
|
Environment sql.NullString `json:"environment"`
|
||||||
GitRepo sql.NullString `json:"git_repo"`
|
GitRepo sql.NullString `json:"git_repo"`
|
||||||
GitBranch sql.NullString `json:"git_branch"`
|
GitBranch sql.NullString `json:"git_branch"`
|
||||||
BuildPath sql.NullString `json:"build_path"`
|
BuildPath sql.NullString `json:"build_path"`
|
||||||
Cpu sql.NullString `json:"cpu"`
|
Cpu sql.NullString `json:"cpu"`
|
||||||
Memory sql.NullString `json:"memory"`
|
Memory sql.NullString `json:"memory"`
|
||||||
Replicas int32 `json:"replicas"`
|
Replicas int32 `json:"replicas"`
|
||||||
Port int32 `json:"port"`
|
Port int32 `json:"port"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
HealthcheckPath string `json:"healthcheck_path"`
|
HealthcheckPath string `json:"healthcheck_path"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceTemplate struct {
|
type ServiceTemplate struct {
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
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,
|
||||||
}
|
}
|
||||||
for _, pm := range cfg.PortMappings {
|
for _, pm := range cfg.PortMappings {
|
||||||
if spec.Port == 0 {
|
if spec.Port == 0 {
|
||||||
@@ -336,8 +337,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
spec.RestartPolicy = "unless-stopped"
|
spec.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
|
||||||
// container config of every replica via spec extension later if needed.
|
|
||||||
state, err := de.ReconcileService(ctx, spec)
|
state, err := de.ReconcileService(ctx, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
@@ -47,6 +48,7 @@ type RuntimeSpec struct {
|
|||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
MemoryBytes int64
|
MemoryBytes int64
|
||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
|
Volumes []VolumeMount // applied to every replica
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeContainer describes one live replica.
|
// RuntimeContainer describes one live replica.
|
||||||
@@ -245,6 +247,19 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
Networks: endpoints,
|
Networks: endpoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range spec.Volumes {
|
||||||
|
mountType := mount.TypeVolume
|
||||||
|
if v.Type == "bind" {
|
||||||
|
mountType = mount.TypeBind
|
||||||
|
}
|
||||||
|
cfg.Mounts = append(cfg.Mounts, mount.Mount{
|
||||||
|
Type: mountType,
|
||||||
|
Source: v.Source,
|
||||||
|
Target: v.Destination,
|
||||||
|
ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var wantPort nat.Port
|
var wantPort nat.Port
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||||
@@ -474,8 +489,9 @@ func specHash(spec RuntimeSpec) string {
|
|||||||
Env map[string]string
|
Env map[string]string
|
||||||
Port int32
|
Port int32
|
||||||
MemoryBytes, NanoCPUs int64
|
MemoryBytes, NanoCPUs int64
|
||||||
|
Volumes []VolumeMount
|
||||||
}{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.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes})
|
||||||
sum := sha256.Sum256(payload)
|
sum := sha256.Sum256(payload)
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func init() {
|
|||||||
// Services
|
// Services
|
||||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||||
@@ -61,6 +61,11 @@ 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})
|
||||||
|
|
||||||
|
// 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_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||||
|
|
||||||
// Variables
|
// Variables
|
||||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
|
||||||
|
-- read_only}]. Applied to every replica's container config at deploy and
|
||||||
|
-- reconcile time. The baseline schema declared the column nullable with
|
||||||
|
-- no default — this normalizes it.
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
|
||||||
|
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
|
||||||
@@ -58,7 +58,8 @@ CREATE TABLE services (
|
|||||||
domain VARCHAR(255) NOT NULL DEFAULT '',
|
domain VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
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
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE deployments (
|
CREATE TABLE deployments (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
stopService,
|
stopService,
|
||||||
getServiceRuntime,
|
getServiceRuntime,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
|
type ServiceVolume,
|
||||||
} 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';
|
||||||
@@ -436,6 +437,7 @@ export function ServiceDetailPage() {
|
|||||||
const [networkForm, setNetworkForm] = useState<{
|
const [networkForm, setNetworkForm] = useState<{
|
||||||
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 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;
|
||||||
@@ -2066,6 +2068,136 @@ 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)]">Volumes</h3>
|
||||||
|
{volumesForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm([...(service.volumes ?? [])])}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{volumesForm === null ? (
|
||||||
|
(service.volumes ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No volume mounts configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(service.volumes ?? []).map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-tertiary)] uppercase tracking-wide">{v.type || 'volume'}</span>
|
||||||
|
<span className="mono text-[var(--text-primary)]">{v.source}</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">→</span>
|
||||||
|
<span className="mono text-[var(--text-primary)]">{v.target}</span>
|
||||||
|
{v.read_only && <span className="text-[var(--text-tertiary)]">(read-only)</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-3">
|
||||||
|
{volumesForm.map((v, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[80px_1fr_1fr_auto_auto] items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={v.type || 'volume'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, type: e.target.value as 'volume' | 'bind' };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="volume">volume</option>
|
||||||
|
<option value="bind">bind</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={v.source}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, source: e.target.value };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
placeholder={v.type === 'bind' ? '/host/path' : 'volume-name'}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={v.target}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, target: e.target.value };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
placeholder="/container/path"
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(v.read_only)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, read_only: e.target.checked };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
ro
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm(volumesForm.filter((_, j) => j !== i))}
|
||||||
|
className="px-2 py-1 text-xs text-[var(--error)]"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm([...volumesForm, { type: 'volume', source: '', target: '', read_only: false }])}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Add mount
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateServiceMutation.mutate(
|
||||||
|
{
|
||||||
|
volumes: volumesForm
|
||||||
|
.filter((v) => v.source.trim() && v.target.startsWith('/'))
|
||||||
|
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setVolumesForm(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={() => setVolumesForm(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)]">Replaces all mounts; applies on next deploy or redeploy</p>
|
||||||
|
</div>
|
||||||
|
{updateServiceMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -1890,6 +1890,95 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/volumes": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List docker volumes
|
||||||
|
* @description Returns the node's docker volume inventory with in-use flags. Admin only.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Volume list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
volumes?: components["schemas"]["VolumeInfo"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/volumes/{name}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete a docker volume
|
||||||
|
* @description Removes an unused docker volume. Docker refuses in-use volumes — returned as 409.
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Volume deleted */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Volume in use or removal failed */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/templates/{id}": {
|
"/templates/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7714,6 +7803,8 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy (default unless-stopped) */
|
/** @description Docker restart policy (default unless-stopped) */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Volume/bind mounts applied to every replica */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
/** @description Computed public URL (published port or domain) */
|
/** @description Computed public URL (published port or domain) */
|
||||||
public_url?: string;
|
public_url?: string;
|
||||||
/**
|
/**
|
||||||
@@ -7769,6 +7860,8 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy */
|
/** @description Docker restart policy */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Volume/bind mounts applied to every replica */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
};
|
};
|
||||||
UpdateServiceRequest: {
|
UpdateServiceRequest: {
|
||||||
/** @description Service name */
|
/** @description Service name */
|
||||||
@@ -7807,6 +7900,33 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy */
|
/** @description Docker restart policy */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
|
};
|
||||||
|
ServiceVolume: {
|
||||||
|
/**
|
||||||
|
* @description volume = docker named volume, bind = host path
|
||||||
|
* @default volume
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
type: "volume" | "bind";
|
||||||
|
/** @description Volume name or host path */
|
||||||
|
source: string;
|
||||||
|
/** @description Absolute container path */
|
||||||
|
target: string;
|
||||||
|
/** @default false */
|
||||||
|
read_only: boolean;
|
||||||
|
};
|
||||||
|
VolumeInfo: {
|
||||||
|
name?: string;
|
||||||
|
driver?: string;
|
||||||
|
mountpoint?: string;
|
||||||
|
scope?: string;
|
||||||
|
in_use?: boolean;
|
||||||
|
/** @description Container names mounting this volume */
|
||||||
|
used_by?: string[];
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Deployment: {
|
Deployment: {
|
||||||
/** @description Deployment ID */
|
/** @description Deployment ID */
|
||||||
|
|||||||
@@ -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|delete|start|stop|restart|redeploy\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## 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\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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -54,9 +54,17 @@ export type ServiceEntity = {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
healthcheckPath?: string;
|
healthcheckPath?: string;
|
||||||
restartPolicy?: string;
|
restartPolicy?: string;
|
||||||
|
volumes?: ServiceVolume[];
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServiceVolume = {
|
||||||
|
type?: 'volume' | 'bind';
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
read_only?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeContainer = {
|
export type RuntimeContainer = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -552,6 +560,7 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
domain: service.domain,
|
domain: service.domain,
|
||||||
healthcheckPath: service.healthcheck_path,
|
healthcheckPath: service.healthcheck_path,
|
||||||
restartPolicy: service.restart_policy,
|
restartPolicy: service.restart_policy,
|
||||||
|
volumes: service.volumes as ServiceVolume[] | undefined,
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1310,6 +1310,51 @@ paths:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/admin/volumes:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
summary: List docker volumes
|
||||||
|
description: Returns the node's docker volume inventory with in-use flags. Admin only.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Volume list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/VolumeInfo'
|
||||||
|
|
||||||
|
/admin/volumes/{name}:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
summary: Delete a docker volume
|
||||||
|
description: Removes an unused docker volume. Docker refuses in-use volumes — returned as 409.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Volume deleted
|
||||||
|
'409':
|
||||||
|
description: Volume in use or removal failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/templates/{id}:
|
/templates/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -4953,6 +4998,11 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy (default unless-stopped)
|
description: Docker restart policy (default unless-stopped)
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Volume/bind mounts applied to every replica
|
||||||
public_url:
|
public_url:
|
||||||
type: string
|
type: string
|
||||||
description: Computed public URL (published port or domain)
|
description: Computed public URL (published port or domain)
|
||||||
@@ -5023,6 +5073,11 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy
|
description: Docker restart policy
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Volume/bind mounts applied to every replica
|
||||||
|
|
||||||
UpdateServiceRequest:
|
UpdateServiceRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -5074,6 +5129,54 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy
|
description: Docker restart policy
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile.
|
||||||
|
|
||||||
|
ServiceVolume:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- source
|
||||||
|
- target
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [volume, bind]
|
||||||
|
default: volume
|
||||||
|
description: volume = docker named volume, bind = host path
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
description: Volume name or host path
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
description: Absolute container path
|
||||||
|
read_only:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
VolumeInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
driver:
|
||||||
|
type: string
|
||||||
|
mountpoint:
|
||||||
|
type: string
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
in_use:
|
||||||
|
type: boolean
|
||||||
|
used_by:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Container names mounting this volume
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
Deployment:
|
Deployment:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+18
-1
@@ -37,7 +37,8 @@ Resolution order for every command: `--token`/`--api-url` flags >
|
|||||||
auth login|logout|status token auth and profiles
|
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|delete|start|stop|restart|redeploy
|
services list|get|create|update|delete|start|stop|restart|redeploy
|
||||||
|
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
|
||||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||||
@@ -70,6 +71,22 @@ containr up --project <id> --image ghcr.io/example/app:latest # image, no buil
|
|||||||
Set `project_id` on the active profile to omit `--project` (stored in
|
Set `project_id` on the active profile to omit `--project` (stored in
|
||||||
`~/.containr.yaml` under `profiles.<name>.project_id`).
|
`~/.containr.yaml` under `profiles.<name>.project_id`).
|
||||||
|
|
||||||
|
## Service volumes
|
||||||
|
|
||||||
|
`services update` manages mounts; flags replace the whole mount list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro
|
||||||
|
containr services update <id> --clear-volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
`type` is `volume` (docker named volume, auto-created) or `bind` (host
|
||||||
|
path). `target` must be absolute. Mounts apply on the next deploy or
|
||||||
|
redeploy — `containr deploy <id>` to apply immediately.
|
||||||
|
|
||||||
|
`volumes list` shows the node's docker volume inventory with in-use
|
||||||
|
flags; `volumes delete <name>` removes an unused volume (admin scope).
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|||||||
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
||||||
- **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).
|
||||||
- **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