diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 6c320b3..180c2c8 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -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), diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 4b463b7..e0e9bdc 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index 781f891..e74e144 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -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) } diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index b9d9b4d..ccf1ac5 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -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) } - }() + }}) } } diff --git a/app/backend/internal/api/volumes.go b/app/backend/internal/api/volumes.go new file mode 100644 index 0000000..2dd96b3 --- /dev/null +++ b/app/backend/internal/api/volumes.go @@ -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}) +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 5c9313c..d85f353 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -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 ", + 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) } diff --git a/app/backend/internal/cli/commands/volumes.go b/app/backend/internal/cli/commands/volumes.go new file mode 100644 index 0000000..3e1e028 --- /dev/null +++ b/app/backend/internal/cli/commands/volumes.go @@ -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 ", + 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) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index b17525c..186c433 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -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) diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index d1521eb..9f0b1ef 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -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 { diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index bc406c6..7dfdbc1 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -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 diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index d73c87b..6bd0819 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -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]) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 48ad55e..b7797d8 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -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}) diff --git a/app/backend/migrations_goose/20261009000000_service_volumes.sql b/app/backend/migrations_goose/20261009000000_service_volumes.sql new file mode 100644 index 0000000..a3dd4cd --- /dev/null +++ b/app/backend/migrations_goose/20261009000000_service_volumes.sql @@ -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; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 564a2a4..d07b9e2 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -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 ( diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 627233d..8259ed3 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -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(null); const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; @@ -2066,6 +2068,132 @@ export function ServiceDetailPage() { )} + {!isDemoMode && ( +
+
+

Volumes

+ {volumesForm === null && ( + + )} +
+ + {volumesForm === null ? ( + (service.volumes ?? []).length === 0 ? ( +

No volume mounts configured.

+ ) : ( +
+ {(service.volumes ?? []).map((v, i) => ( +
+ {v.type || 'volume'} + {v.source} + → + {v.target} + {v.read_only && (read-only)} +
+ ))} +
+ ) + ) : ( +
+ {volumesForm.map((v, i) => ( +
+ + { + 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)]" + /> + { + 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)]" + /> + + +
+ ))} +
+ + + +

Replaces all mounts; applies on next deploy or redeploy

+
+ {updateServiceMutation.isError && ( +

{(updateServiceMutation.error as Error).message}

+ )} +
+ )} +
+ )} + {!isDemoMode && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index ea269da..53b37e8 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -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 */ diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index bfe42ba..6aa2769 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- 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 \ncontainr up --project --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..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 ` 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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- 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 \ncontainr up --project --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..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --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 ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` 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 ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 8264155..14da15d 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -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, }; } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 931ac6d..d3b2afb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 1a94c82..200180b 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -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 trigger a deployment (--no-cache, --commit-hash, --branch) deployments list|get|logs|rollback|cancel logs [--follow] runtime logs (SSE follow) @@ -70,6 +71,22 @@ containr up --project --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..project_id`). +## Service volumes + +`services update` manages mounts; flags replace the whole mount list: + +```bash +containr services update --volume data:/data --bind /srv/cfg:/etc/app:ro +containr services update --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 ` to apply immediately. + +`volumes list` shows the node's docker volume inventory with in-use +flags; `volumes delete ` removes an unused volume (admin scope). + ## MCP server `containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools diff --git a/llms.txt b/llms.txt index 42997ba..9e68cc7 100644 --- a/llms.txt +++ b/llms.txt @@ -16,6 +16,7 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`. - **Idempotency**: send `Idempotency-Key: ` 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