feat: volume scopes redesign — replace shared/isolated with 6 scopes
Replace confusing shared/isolated volume modes with explicit scopes: - instance: per-deploy isolated directory - stage: shared within a stage across deploys - project: shared across all stages - project_named: named group within a project - named: global named volume across projects - ephemeral: tmpfs in-memory mount Includes schema migration (shared→project, isolated→instance), backward-compatible deployer resolution, scope metadata API endpoint, and redesigned volume editor UI with scope guide cards and hints.
This commit is contained in:
@@ -192,6 +192,9 @@ func (s *Server) Router() chi.Router {
|
||||
r.Get("/settings", s.getSettings)
|
||||
r.Get("/settings/npm-certificates", s.listNpmCertificates)
|
||||
|
||||
// Volume scope metadata (read-only).
|
||||
r.Get("/volumes/scopes", s.listVolumeScopes)
|
||||
|
||||
// Stale container endpoints (read).
|
||||
r.Get("/containers/stale", s.listStaleContainers)
|
||||
|
||||
|
||||
+129
-28
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -21,26 +22,98 @@ func validateVolumePath(source string) bool {
|
||||
type volumeRequest struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Mode string `json:"mode"`
|
||||
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// validScopes lists all accepted scope values.
|
||||
var validScopes = map[string]bool{
|
||||
"instance": true, "stage": true, "project": true,
|
||||
"project_named": true, "named": true, "ephemeral": true,
|
||||
}
|
||||
|
||||
// validateVolumeScope validates the scope and name combination.
|
||||
func validateVolumeScope(scope, name string) string {
|
||||
if !validScopes[scope] {
|
||||
return "scope must be one of: instance, stage, project, project_named, named, ephemeral"
|
||||
}
|
||||
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
|
||||
return "name is required for " + scope + " scope"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scopeDescriptions returns metadata about each scope for the UI.
|
||||
type scopeInfo struct {
|
||||
Scope string `json:"scope"`
|
||||
Description string `json:"description"`
|
||||
NeedsName bool `json:"needs_name"`
|
||||
PathExample string `json:"path_example"`
|
||||
}
|
||||
|
||||
// listVolumeScopes handles GET /api/volumes/scopes.
|
||||
// Returns all available scopes with descriptions for UI hints.
|
||||
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
|
||||
scopes := []scopeInfo{
|
||||
{
|
||||
Scope: "instance",
|
||||
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
|
||||
NeedsName: false,
|
||||
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
|
||||
},
|
||||
{
|
||||
Scope: "stage",
|
||||
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
|
||||
NeedsName: false,
|
||||
PathExample: "{base}/{project}/{stage}/{source}",
|
||||
},
|
||||
{
|
||||
Scope: "project",
|
||||
Description: "Shared across all stages of the project. Good for common config or shared assets.",
|
||||
NeedsName: false,
|
||||
PathExample: "{base}/{project}/{source}",
|
||||
},
|
||||
{
|
||||
Scope: "project_named",
|
||||
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
|
||||
NeedsName: true,
|
||||
PathExample: "{base}/{project}/_named/{name}/{source}",
|
||||
},
|
||||
{
|
||||
Scope: "named",
|
||||
Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.",
|
||||
NeedsName: true,
|
||||
PathExample: "{base}/_named/{name}/{source}",
|
||||
},
|
||||
{
|
||||
Scope: "ephemeral",
|
||||
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.",
|
||||
NeedsName: false,
|
||||
PathExample: "(tmpfs — no host path)",
|
||||
},
|
||||
}
|
||||
respondJSON(w, http.StatusOK, scopes)
|
||||
}
|
||||
|
||||
// listVolumes handles GET /api/projects/{id}/volumes.
|
||||
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
|
||||
// Verify project exists.
|
||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
|
||||
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
vols, err := s.store.GetVolumesByProjectID(projectID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error())
|
||||
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to list volumes")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,13 +124,13 @@ func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
|
||||
// Verify project exists.
|
||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
|
||||
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,27 +139,41 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Source == "" {
|
||||
respondError(w, http.StatusBadRequest, "source is required")
|
||||
return
|
||||
// Ephemeral volumes don't need a source path.
|
||||
if req.Scope != "ephemeral" {
|
||||
if req.Source == "" {
|
||||
respondError(w, http.StatusBadRequest, "source is required")
|
||||
return
|
||||
}
|
||||
if !validateVolumePath(req.Source) {
|
||||
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Target == "" {
|
||||
respondError(w, http.StatusBadRequest, "target is required")
|
||||
return
|
||||
}
|
||||
if !validateVolumePath(req.Source) {
|
||||
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
||||
return
|
||||
}
|
||||
if !validateVolumePath(req.Target) {
|
||||
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
||||
return
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "shared"
|
||||
|
||||
// Resolve scope — support legacy mode field.
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
switch req.Mode {
|
||||
case "isolated":
|
||||
scope = "instance"
|
||||
case "shared":
|
||||
scope = "project"
|
||||
default:
|
||||
scope = "project"
|
||||
}
|
||||
}
|
||||
if req.Mode != "shared" && req.Mode != "isolated" {
|
||||
respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'")
|
||||
|
||||
if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" {
|
||||
respondError(w, http.StatusBadRequest, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,10 +181,12 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
||||
ProjectID: projectID,
|
||||
Source: req.Source,
|
||||
Target: req.Target,
|
||||
Mode: req.Mode,
|
||||
Scope: scope,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error())
|
||||
slog.Error("failed to create volume", "project_id", projectID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to create volume")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, vol)
|
||||
@@ -113,7 +202,8 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
||||
respondNotFound(w, "volume")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error())
|
||||
slog.Error("failed to get volume", "volume_id", volID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get volume")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,21 +214,31 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated := existing
|
||||
if req.Source != "" {
|
||||
if !validateVolumePath(req.Source) {
|
||||
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
||||
return
|
||||
}
|
||||
updated.Source = req.Source
|
||||
}
|
||||
if req.Target != "" {
|
||||
updated.Target = req.Target
|
||||
}
|
||||
if req.Mode != "" {
|
||||
if req.Mode != "shared" && req.Mode != "isolated" {
|
||||
respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'")
|
||||
if !validateVolumePath(req.Target) {
|
||||
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
||||
return
|
||||
}
|
||||
updated.Mode = req.Mode
|
||||
updated.Target = req.Target
|
||||
}
|
||||
if req.Scope != "" {
|
||||
if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" {
|
||||
respondError(w, http.StatusBadRequest, errMsg)
|
||||
return
|
||||
}
|
||||
updated.Scope = req.Scope
|
||||
updated.Name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
|
||||
if err := s.store.UpdateVolume(updated); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error())
|
||||
slog.Error("failed to update volume", "volume_id", volID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to update volume")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
@@ -152,7 +252,8 @@ func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
|
||||
respondNotFound(w, "volume")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error())
|
||||
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete volume")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
|
||||
|
||||
@@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy(
|
||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||
envVars := d.mergeEnvVars(project, stage.ID)
|
||||
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
|
||||
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
||||
|
||||
containerCfg := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
|
||||
@@ -282,7 +282,7 @@ func (d *Deployer) executeDeploy(
|
||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||
envVars := d.mergeEnvVars(project, stage.ID)
|
||||
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
|
||||
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
||||
|
||||
containerCfg := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
@@ -619,9 +619,14 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
|
||||
}
|
||||
|
||||
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
||||
// For shared mode, source is used as-is.
|
||||
// For isolated mode, source gets /{stage}-{tag}/ appended.
|
||||
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount {
|
||||
// Resolves the host path based on the volume's scope:
|
||||
// - instance: {base}/{project}/{stage}-{tag}/{source}
|
||||
// - stage: {base}/{project}/{stage}/{source}
|
||||
// - project: {base}/{project}/{source}
|
||||
// - project_named: {base}/{project}/_named/{name}/{source}
|
||||
// - named: {base}/_named/{name}/{source}
|
||||
// - ephemeral: tmpfs mount (no host path)
|
||||
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
|
||||
vols, err := d.store.GetVolumesByProjectID(projectID)
|
||||
if err != nil {
|
||||
slog.Warn("get project volumes", "project_id", projectID, "error", err)
|
||||
@@ -634,14 +639,44 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath
|
||||
|
||||
mounts := make([]mount.Mount, 0, len(vols))
|
||||
for _, vol := range vols {
|
||||
source := vol.Source
|
||||
// Prepend base path if source is relative (doesn't start with /).
|
||||
if basePath != "" && !filepath.IsAbs(source) {
|
||||
source = filepath.Join(basePath, source)
|
||||
// Resolve scope — use Scope field, fall back to Mode for backward compat.
|
||||
scope := vol.Scope
|
||||
if scope == "" {
|
||||
switch vol.Mode {
|
||||
case "isolated":
|
||||
scope = "instance"
|
||||
default:
|
||||
scope = "project"
|
||||
}
|
||||
}
|
||||
if vol.Mode == "isolated" {
|
||||
source = filepath.Join(source, fmt.Sprintf("%s-%s", stageName, imageTag))
|
||||
|
||||
// Ephemeral volumes use tmpfs — no host path.
|
||||
if scope == "ephemeral" {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeTmpfs,
|
||||
Target: vol.Target,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Build host path based on scope.
|
||||
var source string
|
||||
switch scope {
|
||||
case "instance":
|
||||
source = filepath.Join(basePath, projectName, fmt.Sprintf("%s-%s", stageName, imageTag), vol.Source)
|
||||
case "stage":
|
||||
source = filepath.Join(basePath, projectName, stageName, vol.Source)
|
||||
case "project":
|
||||
source = filepath.Join(basePath, projectName, vol.Source)
|
||||
case "project_named":
|
||||
source = filepath.Join(basePath, projectName, "_named", vol.Name, vol.Source)
|
||||
case "named":
|
||||
source = filepath.Join(basePath, "_named", vol.Name, vol.Source)
|
||||
default:
|
||||
// Fallback: treat as project scope.
|
||||
source = filepath.Join(basePath, projectName, vol.Source)
|
||||
}
|
||||
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeBind,
|
||||
Source: source,
|
||||
|
||||
@@ -109,13 +109,44 @@ type StageEnv struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VolumeScope defines the sharing scope for a volume mount.
|
||||
// Valid scopes: instance, stage, project, project_named, named, ephemeral.
|
||||
type VolumeScope string
|
||||
|
||||
const (
|
||||
VolumeScopeInstance VolumeScope = "instance"
|
||||
VolumeScopeStage VolumeScope = "stage"
|
||||
VolumeScopeProject VolumeScope = "project"
|
||||
VolumeScopeProjectNamed VolumeScope = "project_named"
|
||||
VolumeScopeNamed VolumeScope = "named"
|
||||
VolumeScopeEphemeral VolumeScope = "ephemeral"
|
||||
)
|
||||
|
||||
// ValidVolumeScopes contains all valid scope values for validation.
|
||||
var ValidVolumeScopes = []VolumeScope{
|
||||
VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject,
|
||||
VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral,
|
||||
}
|
||||
|
||||
// IsValidVolumeScope returns true if the given string is a valid scope.
|
||||
func IsValidVolumeScope(s string) bool {
|
||||
for _, v := range ValidVolumeScopes {
|
||||
if string(v) == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Volume represents a volume mount configuration for a project.
|
||||
type Volume struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Mode string `json:"mode"` // shared or isolated
|
||||
Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat
|
||||
Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral
|
||||
Name string `json:"name"` // required for project_named and named scopes
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
|
||||
// Add last_alive_at to instances for stale container detection (2026-03-30).
|
||||
`ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`,
|
||||
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
|
||||
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
@@ -112,6 +115,12 @@ func (s *Store) runMigrations() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Data migration: copy mode→scope for volumes that have scope still empty.
|
||||
// shared→project, isolated→instance.
|
||||
_, _ = s.db.Exec(`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`)
|
||||
_, _ = s.db.Exec(`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`)
|
||||
_, _ = s.db.Exec(`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+20
-13
@@ -8,21 +8,30 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// volumeColumns is the canonical column list for volume queries.
|
||||
const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at`
|
||||
|
||||
// CreateVolume inserts a new volume configuration for a project.
|
||||
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
|
||||
vol.ID = uuid.New().String()
|
||||
vol.CreatedAt = Now()
|
||||
vol.UpdatedAt = vol.CreatedAt
|
||||
|
||||
if vol.Mode == "" {
|
||||
vol.Mode = "shared"
|
||||
// Default scope for backward compatibility.
|
||||
if vol.Scope == "" {
|
||||
switch vol.Mode {
|
||||
case "isolated":
|
||||
vol.Scope = "instance"
|
||||
default:
|
||||
vol.Scope = "project"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO volumes (id, project_id, source, target, mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO volumes (`+volumeColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode,
|
||||
vol.CreatedAt, vol.UpdatedAt,
|
||||
vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Volume{}, fmt.Errorf("insert volume: %w", err)
|
||||
@@ -33,8 +42,7 @@ func (s *Store) CreateVolume(vol Volume) (Volume, error) {
|
||||
// GetVolumesByProjectID returns all volume configurations for a project.
|
||||
func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, source, target, mode, created_at, updated_at
|
||||
FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
|
||||
`SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query volumes: %w", err)
|
||||
@@ -56,10 +64,9 @@ func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
|
||||
func (s *Store) GetVolumeByID(id string) (Volume, error) {
|
||||
var vol Volume
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, source, target, mode, created_at, updated_at
|
||||
FROM volumes WHERE id = ?`, id,
|
||||
`SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id,
|
||||
).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
|
||||
&vol.CreatedAt, &vol.UpdatedAt)
|
||||
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound)
|
||||
}
|
||||
@@ -73,9 +80,9 @@ func (s *Store) GetVolumeByID(id string) (Volume, error) {
|
||||
func (s *Store) UpdateVolume(vol Volume) error {
|
||||
vol.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE volumes SET source=?, target=?, mode=?, updated_at=?
|
||||
`UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
vol.Source, vol.Target, vol.Mode, vol.UpdatedAt, vol.ID,
|
||||
vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update volume: %w", err)
|
||||
@@ -104,7 +111,7 @@ func (s *Store) DeleteVolume(id string) error {
|
||||
func scanVolume(rows *sql.Rows) (Volume, error) {
|
||||
var vol Volume
|
||||
err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
|
||||
&vol.CreatedAt, &vol.UpdatedAt)
|
||||
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
|
||||
if err != nil {
|
||||
return Volume{}, fmt.Errorf("scan volume: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user