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:
Tomas DvorakandDevin committed 2026-10-08 16:38:46 +02:00
1 parent 747b714a02
commit 2f8f595d6e
21 files changed
+863 -93

No files matched your search

+1
View File
@@ -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),
+2
View File
@@ -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)
+1
View File
@@ -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)
}
+133 -52
View File
@@ -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)
}
}()
}})
}
}
+105
View File
@@ -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})
}
+96 -1
View File
@@ -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)
}
+1
View File
@@ -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)
+34 -33
View File
@@ -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 {
+1 -2
View File
@@ -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 -1
View File
@@ -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])
}
+6 -1
View File
@@ -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;
+2 -1
View File
@@ -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">
+120
View File
@@ -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 */
+1 -1
View File
@@ -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",
+9
View File
@@ -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,
};
}
+103
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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