mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): volume mounts + admin volume inventory (C1)
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.
- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].
Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
747b714a02
commit
2f8f595d6e
21 files changed
+863
-93
No files matched your search
@@ -416,6 +416,7 @@ func runDeploymentAndSyncWithImage(
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
|
||||
@@ -423,6 +423,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
scalingHandler.RegisterAdminRoutes(admin)
|
||||
agentHandler.SetupAdminRoutes(admin)
|
||||
|
||||
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
}
|
||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||
if spec.Port > 0 {
|
||||
env["PORT"] = strconv.Itoa(service.Port)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,53 +31,107 @@ type Service struct {
|
||||
CPU string `json:"cpu" db:"cpu"`
|
||||
Memory string `json:"memory" db:"memory"`
|
||||
// Runtime spec
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||
type ServiceVolume struct {
|
||||
Type string `json:"type"` // volume | bind (empty = volume)
|
||||
Source string `json:"source"` // volume name or host path
|
||||
Target string `json:"target"` // container path (absolute)
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||
// to no mounts rather than failing the request.
|
||||
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var stored []ServiceVolume
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]deployment.VolumeMount, 0, len(stored))
|
||||
for _, v := range stored {
|
||||
out = append(out, deployment.VolumeMount{
|
||||
Type: v.Type,
|
||||
Source: v.Source,
|
||||
Destination: v.Target,
|
||||
ReadOnly: v.ReadOnly,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateServiceVolumes enforces the mount contract — bad paths fail
|
||||
// fast at write time instead of surfacing as docker create errors later.
|
||||
func validateServiceVolumes(vols []ServiceVolume) error {
|
||||
for i, v := range vols {
|
||||
if v.Type == "" {
|
||||
v.Type = "volume"
|
||||
}
|
||||
if v.Type != "volume" && v.Type != "bind" {
|
||||
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
||||
}
|
||||
if strings.TrimSpace(v.Source) == "" {
|
||||
return fmt.Errorf("volumes[%d].source is required", i)
|
||||
}
|
||||
if !strings.HasPrefix(v.Target, "/") {
|
||||
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateServiceRequest represents a request to create a service
|
||||
type CreateServiceRequest struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes []ServiceVolume `json:"volumes"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
type UpdateServiceRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Volumes *[]ServiceVolume `json:"volumes"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -262,6 +320,15 @@ func handleCreateService(c *gin.Context) {
|
||||
if service.RestartPolicy == "" {
|
||||
service.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
if err := validateServiceVolumes(req.Volumes); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
service.Volumes = req.Volumes
|
||||
volumesJSON, _ := json.Marshal(req.Volumes)
|
||||
if len(req.Volumes) == 0 {
|
||||
volumesJSON = []byte("[]")
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
@@ -277,14 +344,14 @@ func handleCreateService(c *gin.Context) {
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
|
||||
healthcheck_path, restart_policy, volumes, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -369,6 +436,11 @@ func handleGetService(c *gin.Context) {
|
||||
}
|
||||
|
||||
liveServiceStatus(c, db.(*database.DB), &service)
|
||||
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
|
||||
service.Volumes = append(service.Volumes, ServiceVolume{
|
||||
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -483,6 +555,15 @@ func handleUpdateService(c *gin.Context) {
|
||||
if req.RestartPolicy != "" {
|
||||
existingService.RestartPolicy = req.RestartPolicy
|
||||
}
|
||||
var volumesJSON []byte
|
||||
if req.Volumes != nil {
|
||||
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
existingService.Volumes = *req.Volumes
|
||||
volumesJSON, _ = json.Marshal(*req.Volumes)
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
@@ -492,14 +573,14 @@ func handleUpdateService(c *gin.Context) {
|
||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||
restart_policy = $15, updated_at = $16
|
||||
WHERE id = $17`,
|
||||
restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16
|
||||
WHERE id = $18`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||
existingService.UpdatedAt, existingService.ID,
|
||||
existingService.UpdatedAt, volumesJSON, existingService.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -509,21 +590,21 @@ func handleUpdateService(c *gin.Context) {
|
||||
|
||||
// A replica change applies immediately when the service has live
|
||||
// containers; other spec fields take effect on the next deploy.
|
||||
// Runs through the deploy queue so it can't overlap a build.
|
||||
if req.Replicas != nil && existingService.Status == "running" {
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
serviceCopy := existingService
|
||||
go func() {
|
||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||
dbCopy := db.(*database.DB)
|
||||
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
||||
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
||||
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
||||
}
|
||||
}()
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VolumeInfo is one docker volume plus whether any container mounts it.
|
||||
type VolumeInfo struct {
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Scope string `json:"scope"`
|
||||
InUse bool `json:"in_use"`
|
||||
UsedBy []string `json:"used_by,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
|
||||
v, exists := c.Get("docker_client")
|
||||
if !exists || v == nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
|
||||
return nil, false
|
||||
}
|
||||
return v.(*docker.Client), true
|
||||
}
|
||||
|
||||
// handleAdminListVolumes returns the node's docker volume inventory with
|
||||
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
|
||||
func handleAdminListVolumes(c *gin.Context) {
|
||||
client, ok := dockerClientOr500(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
vols, err := client.ListVolumes(ctx)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
containers, err := client.ListContainers(ctx, true)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
|
||||
return
|
||||
}
|
||||
inUse := map[string][]string{}
|
||||
for _, cont := range containers {
|
||||
for _, m := range cont.Mounts {
|
||||
if m.Type == "volume" {
|
||||
name := cont.ID[:12]
|
||||
if len(cont.Names) > 0 {
|
||||
name = cont.Names[0]
|
||||
}
|
||||
inUse[m.Name] = append(inUse[m.Name], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]VolumeInfo, 0, len(vols.Volumes))
|
||||
for _, v := range vols.Volumes {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
info := VolumeInfo{
|
||||
Name: v.Name,
|
||||
Driver: v.Driver,
|
||||
Mountpoint: v.Mountpoint,
|
||||
Scope: v.Scope,
|
||||
Labels: v.Labels,
|
||||
UsedBy: inUse[v.Name],
|
||||
}
|
||||
info.InUse = len(info.UsedBy) > 0
|
||||
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
|
||||
info.CreatedAt = t
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"volumes": out})
|
||||
}
|
||||
|
||||
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
|
||||
// of in-use volumes — that error is surfaced as a conflict.
|
||||
func handleAdminDeleteVolume(c *gin.Context) {
|
||||
client, ok := dockerClientOr500(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
|
||||
return
|
||||
}
|
||||
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": name})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -140,6 +141,85 @@ var servicesDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var servicesUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a service (volumes, replicas, domain, ...)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
key := f
|
||||
switch f {
|
||||
case "restart-policy":
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("replicas") {
|
||||
v, _ := cmd.Flags().GetInt("replicas")
|
||||
body["replicas"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("port") {
|
||||
v, _ := cmd.Flags().GetInt("port")
|
||||
body["port"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
||||
volumes := []map[string]interface{}{}
|
||||
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
||||
volumes = append(volumes, spec)
|
||||
}
|
||||
body["volumes"] = volumes
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("PUT", "/services/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Updated service %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
for _, f := range flags {
|
||||
vals, _ := cmd.Flags().GetStringArray(f)
|
||||
for _, v := range vals {
|
||||
parts := strings.SplitN(v, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"type": f,
|
||||
"source": parts[0],
|
||||
"target": parts[1],
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "ro" {
|
||||
m["read_only"] = true
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// serviceAction returns a RunE for the simple POST action endpoints.
|
||||
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
@@ -177,6 +257,21 @@ func init() {
|
||||
f.Int("port", 0, "container port")
|
||||
f.Int("replicas", 0, "replica count")
|
||||
|
||||
uf := servicesUpdateCmd.Flags()
|
||||
uf.String("name", "", "service name")
|
||||
uf.String("image", "", "container image")
|
||||
uf.String("command", "", "container start command")
|
||||
uf.String("domain", "", "public domain (empty string clears)")
|
||||
uf.String("restart-policy", "", "docker restart policy")
|
||||
uf.String("healthcheck-path", "", "HTTP health check path")
|
||||
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||
uf.String("memory", "", "memory limit (e.g. 512m)")
|
||||
uf.Int("replicas", 0, "replica count")
|
||||
uf.Int("port", 0, "container port")
|
||||
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
||||
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
||||
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||
|
||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||
verb := a
|
||||
ServicesCmd.AddCommand(&cobra.Command{
|
||||
@@ -186,5 +281,5 @@ func init() {
|
||||
RunE: serviceAction(verb),
|
||||
})
|
||||
}
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesDeleteCmd)
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// VolumesCmd manages docker volumes on the node (admin).
|
||||
var VolumesCmd = &cobra.Command{
|
||||
Use: "volumes",
|
||||
Aliases: []string{"volume"},
|
||||
Short: "Manage docker volumes (admin)",
|
||||
}
|
||||
|
||||
var volumesListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List docker volumes with in-use flags",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/admin/volumes", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "volumes")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, v := range items {
|
||||
inUse := "no"
|
||||
if b, ok := v["in_use"].(bool); ok && b {
|
||||
inUse = "yes"
|
||||
}
|
||||
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
|
||||
}
|
||||
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var volumesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: "Delete an unused docker volume",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted volume %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
|
||||
}
|
||||
@@ -73,6 +73,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.LogsCmd)
|
||||
rootCmd.AddCommand(commands.ExecCmd)
|
||||
rootCmd.AddCommand(commands.VariablesCmd)
|
||||
rootCmd.AddCommand(commands.VolumesCmd)
|
||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||
rootCmd.AddCommand(commands.CronCmd)
|
||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||
|
||||
@@ -168,39 +168,40 @@ type ProjectMember struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||
PublicUrl sql.NullString `json:"public_url"`
|
||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||
Status sql.NullString `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||
PublicUrl sql.NullString `json:"public_url"`
|
||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||
Status sql.NullString `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
Volumes json.RawMessage `json:"volumes"`
|
||||
}
|
||||
|
||||
type ServiceTemplate struct {
|
||||
|
||||
@@ -326,6 +326,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
Volumes: cfg.VolumeMounts,
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
@@ -336,8 +337,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
spec.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
|
||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
||||
// container config of every replica via spec extension later if needed.
|
||||
state, err := de.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
@@ -47,6 +48,7 @@ type RuntimeSpec struct {
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
Volumes []VolumeMount // applied to every replica
|
||||
}
|
||||
|
||||
// RuntimeContainer describes one live replica.
|
||||
@@ -245,6 +247,19 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
Networks: endpoints,
|
||||
}
|
||||
|
||||
for _, v := range spec.Volumes {
|
||||
mountType := mount.TypeVolume
|
||||
if v.Type == "bind" {
|
||||
mountType = mount.TypeBind
|
||||
}
|
||||
cfg.Mounts = append(cfg.Mounts, mount.Mount{
|
||||
Type: mountType,
|
||||
Source: v.Source,
|
||||
Target: v.Destination,
|
||||
ReadOnly: v.ReadOnly,
|
||||
})
|
||||
}
|
||||
|
||||
var wantPort nat.Port
|
||||
if spec.Port > 0 {
|
||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||
@@ -474,8 +489,9 @@ func specHash(spec RuntimeSpec) string {
|
||||
Env map[string]string
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
Volumes []VolumeMount
|
||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func init() {
|
||||
// Services
|
||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||
@@ -61,6 +61,11 @@ func init() {
|
||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||
|
||||
// Docker volumes (admin)
|
||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
||||
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||
|
||||
// Variables
|
||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||
|
||||
Reference in new issue
Block a user