mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): volume mounts + admin volume inventory (C1)
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.
- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].
Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.
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
747b714a02
commit
2f8f595d6e
21 files changed
+863
-93
No files matched your search
@@ -416,6 +416,7 @@ func runDeploymentAndSyncWithImage(
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
|
||||
@@ -423,6 +423,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
scalingHandler.RegisterAdminRoutes(admin)
|
||||
agentHandler.SetupAdminRoutes(admin)
|
||||
|
||||
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||
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.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||
if spec.Port > 0 {
|
||||
env["PORT"] = strconv.Itoa(service.Port)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,53 +31,107 @@ type Service struct {
|
||||
CPU string `json:"cpu" db:"cpu"`
|
||||
Memory string `json:"memory" db:"memory"`
|
||||
// Runtime spec
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||
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
|
||||
type CreateServiceRequest struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes []ServiceVolume `json:"volumes"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
type UpdateServiceRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes *[]ServiceVolume `json:"volumes"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -262,6 +320,15 @@ func handleCreateService(c *gin.Context) {
|
||||
if service.RestartPolicy == "" {
|
||||
service.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
if err := validateServiceVolumes(req.Volumes); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
service.Volumes = req.Volumes
|
||||
volumesJSON, _ := json.Marshal(req.Volumes)
|
||||
if len(req.Volumes) == 0 {
|
||||
volumesJSON = []byte("[]")
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
@@ -277,14 +344,14 @@ func handleCreateService(c *gin.Context) {
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, 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)`,
|
||||
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, $28)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -369,6 +436,11 @@ func handleGetService(c *gin.Context) {
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -483,6 +555,15 @@ func handleUpdateService(c *gin.Context) {
|
||||
if 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()
|
||||
|
||||
@@ -492,14 +573,14 @@ func handleUpdateService(c *gin.Context) {
|
||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||
restart_policy = $15, updated_at = $16
|
||||
WHERE id = $17`,
|
||||
restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16
|
||||
WHERE id = $18`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||
existingService.UpdatedAt, existingService.ID,
|
||||
existingService.UpdatedAt, volumesJSON, existingService.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -509,21 +590,21 @@ func handleUpdateService(c *gin.Context) {
|
||||
|
||||
// A replica change applies immediately when the service has live
|
||||
// containers; other spec fields take effect on the next deploy.
|
||||
// Runs through the deploy queue so it can't overlap a build.
|
||||
if req.Replicas != nil && existingService.Status == "running" {
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
serviceCopy := existingService
|
||||
go func() {
|
||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||
dbCopy := db.(*database.DB)
|
||||
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
||||
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
||||
persistPublishedPort(dbCopy, 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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.
|
||||
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
@@ -177,6 +257,21 @@ func init() {
|
||||
f.Int("port", 0, "container port")
|
||||
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"} {
|
||||
verb := a
|
||||
ServicesCmd.AddCommand(&cobra.Command{
|
||||
@@ -186,5 +281,5 @@ func init() {
|
||||
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.ExecCmd)
|
||||
rootCmd.AddCommand(commands.VariablesCmd)
|
||||
rootCmd.AddCommand(commands.VolumesCmd)
|
||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||
rootCmd.AddCommand(commands.CronCmd)
|
||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||
|
||||
@@ -168,39 +168,40 @@ type ProjectMember struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||
PublicUrl sql.NullString `json:"public_url"`
|
||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||
Status sql.NullString `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||
PublicUrl sql.NullString `json:"public_url"`
|
||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||
Status sql.NullString `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
Volumes json.RawMessage `json:"volumes"`
|
||||
}
|
||||
|
||||
type ServiceTemplate struct {
|
||||
|
||||
@@ -326,6 +326,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
Volumes: cfg.VolumeMounts,
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
@@ -336,8 +337,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
@@ -47,6 +48,7 @@ type RuntimeSpec struct {
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
Volumes []VolumeMount // applied to every replica
|
||||
}
|
||||
|
||||
// RuntimeContainer describes one live replica.
|
||||
@@ -245,6 +247,19 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
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
|
||||
if spec.Port > 0 {
|
||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||
@@ -474,8 +489,9 @@ func specHash(spec RuntimeSpec) string {
|
||||
Env map[string]string
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
Volumes []VolumeMount
|
||||
}{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)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func init() {
|
||||
// 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_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_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})
|
||||
@@ -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_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||
|
||||
// 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
|
||||
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 '',
|
||||
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||
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 (
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
stopService,
|
||||
getServiceRuntime,
|
||||
type CronJobEntity,
|
||||
type ServiceVolume,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
@@ -436,6 +437,7 @@ export function ServiceDetailPage() {
|
||||
const [networkForm, setNetworkForm] = useState<{
|
||||
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||
} | null>(null);
|
||||
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
|
||||
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
@@ -2066,6 +2068,132 @@ export function ServiceDetailPage() {
|
||||
</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('/')) },
|
||||
{ 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 && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -1890,6 +1890,95 @@ export interface paths {
|
||||
};
|
||||
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}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7714,6 +7803,8 @@ export interface components {
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy (default unless-stopped) */
|
||||
restart_policy?: string;
|
||||
/** @description Volume/bind mounts applied to every replica */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
@@ -7769,6 +7860,8 @@ export interface components {
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy */
|
||||
restart_policy?: string;
|
||||
/** @description Volume/bind mounts applied to every replica */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
};
|
||||
UpdateServiceRequest: {
|
||||
/** @description Service name */
|
||||
@@ -7807,6 +7900,33 @@ export interface components {
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy */
|
||||
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: {
|
||||
/** @description Deployment ID */
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"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",
|
||||
|
||||
@@ -54,9 +54,17 @@ export type ServiceEntity = {
|
||||
domain?: string;
|
||||
healthcheckPath?: string;
|
||||
restartPolicy?: string;
|
||||
volumes?: ServiceVolume[];
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
export type ServiceVolume = {
|
||||
type?: 'volume' | 'bind';
|
||||
source: string;
|
||||
target: string;
|
||||
read_only?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeContainer = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -552,6 +560,7 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
domain: service.domain,
|
||||
healthcheckPath: service.healthcheck_path,
|
||||
restartPolicy: service.restart_policy,
|
||||
volumes: service.volumes as ServiceVolume[] | undefined,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1310,6 +1310,51 @@ paths:
|
||||
message:
|
||||
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}:
|
||||
get:
|
||||
tags:
|
||||
@@ -4953,6 +4998,11 @@ components:
|
||||
restart_policy:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
description: Computed public URL (published port or domain)
|
||||
@@ -5023,6 +5073,11 @@ components:
|
||||
restart_policy:
|
||||
type: string
|
||||
description: Docker restart policy
|
||||
volumes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceVolume'
|
||||
description: Volume/bind mounts applied to every replica
|
||||
|
||||
UpdateServiceRequest:
|
||||
type: object
|
||||
@@ -5074,6 +5129,54 @@ components:
|
||||
restart_policy:
|
||||
type: string
|
||||
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:
|
||||
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
|
||||
tokens list|create|revoke personal access tokens
|
||||
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)
|
||||
deployments list|get|logs|rollback|cancel
|
||||
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
|
||||
`~/.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
|
||||
|
||||
`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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Core objects
|
||||
|
||||
Reference in new issue
Block a user