mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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>
106 lines
2.9 KiB
Go
106 lines
2.9 KiB
Go
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})
|
|
}
|