feat: storage attachments, docker-args triggers, install-time migration
storage:mount and storage:unmount now accept the named-entry form (--container-dir, --phase, --process-type, --volume-subpath, --volume-readonly, --volume-chown), persisting attachments via the property list. The legacy host:container colon form is preserved for back-compat. Storage now emits its own docker-args-deploy and docker-args-run triggers so docker-local apps continue to receive bind mounts even after their data moves out of docker-options. The install trigger walks every app once per upgrade and converts legacy -v lines into legacy-<hash> entries plus attachments, draining the originals from docker-options last so a partial run leaves the old behavior intact. Lifecycle triggers post-delete, post-app-clone-setup, and post-app-rename-setup keep the attachment store in sync with app state.
This commit is contained in:
190
plugins/storage/migrate.go
Normal file
190
plugins/storage/migrate.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
dockeroptions "github.com/dokku/dokku/plugins/docker-options"
|
||||
)
|
||||
|
||||
// migrationFlagDir contains per-app flag files written after a successful
|
||||
// per-app migration; presence means the app's legacy -v lines have been
|
||||
// drained into the attachment store.
|
||||
func migrationFlagDir() string {
|
||||
root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku")
|
||||
return filepath.Join(root, "config", "storage", ".migrated")
|
||||
}
|
||||
|
||||
func migrationFlagFile(appName string) string {
|
||||
return filepath.Join(migrationFlagDir(), appName)
|
||||
}
|
||||
|
||||
// MigrateLegacyMounts walks every app and converts its legacy `-v`
|
||||
// docker-options entries into named storage entries plus attachments.
|
||||
// Idempotent: per-app flag files short-circuit re-runs.
|
||||
func MigrateLegacyMounts() error {
|
||||
if err := os.MkdirAll(migrationFlagDir(), 0755); err != nil {
|
||||
return fmt.Errorf("unable to create migration flag dir: %w", err)
|
||||
}
|
||||
|
||||
apps, err := common.DokkuApps()
|
||||
if err != nil {
|
||||
if errors.Is(err, common.NoAppsExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, app := range apps {
|
||||
if _, err := os.Stat(migrationFlagFile(app)); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := migrateApp(app); err != nil {
|
||||
return fmt.Errorf("storage migration failed for app %q: %w", app, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateApp performs the per-app migration. Order is intentional: write
|
||||
// the entry, write the attachment, drain the docker-options line. Any
|
||||
// step failing aborts before drain, so the original behavior is
|
||||
// preserved on partial failure.
|
||||
func migrateApp(appName string) error {
|
||||
deployLines, err := dockeroptions.GetDockerOptionsForPhase(appName, PhaseDeploy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runLines, err := dockeroptions.GetDockerOptionsForPhase(appName, PhaseRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deployMounts := filterMountLines(deployLines)
|
||||
runMounts := filterMountLines(runLines)
|
||||
|
||||
// Group identical mount strings across phases.
|
||||
phaseMap := map[string][]string{}
|
||||
for _, mount := range deployMounts {
|
||||
phaseMap[mount] = appendUnique(phaseMap[mount], PhaseDeploy)
|
||||
}
|
||||
for _, mount := range runMounts {
|
||||
phaseMap[mount] = appendUnique(phaseMap[mount], PhaseRun)
|
||||
}
|
||||
|
||||
mounts := make([]string, 0, len(phaseMap))
|
||||
for mount := range phaseMap {
|
||||
mounts = append(mounts, mount)
|
||||
}
|
||||
sort.Strings(mounts)
|
||||
|
||||
for _, mount := range mounts {
|
||||
if err := migrateMount(appName, mount, phaseMap[mount]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return touchMigrationFlag(appName)
|
||||
}
|
||||
|
||||
func migrateMount(appName string, mount string, phases []string) error {
|
||||
parsed := ParseMountPath(mount)
|
||||
entry := LegacyMountToEntry(mount)
|
||||
|
||||
if EntryExists(entry.Name) {
|
||||
existing, err := LoadEntry(entry.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.HostPath != entry.HostPath || existing.Scheduler != entry.Scheduler {
|
||||
return fmt.Errorf("legacy entry %q already exists with conflicting fields", entry.Name)
|
||||
}
|
||||
} else {
|
||||
if err := SaveEntry(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
containerPath := parsed.ContainerPath
|
||||
if containerPath == "" {
|
||||
// Defensive: legacy mounts must have a container path; skip if not.
|
||||
return nil
|
||||
}
|
||||
|
||||
attachment := &Attachment{
|
||||
EntryName: entry.Name,
|
||||
ContainerPath: containerPath,
|
||||
Phases: phases,
|
||||
ProcessType: DefaultProcessType,
|
||||
}
|
||||
if parsed.VolumeOptions != "" {
|
||||
if parsed.VolumeOptions == "ro" {
|
||||
attachment.Readonly = true
|
||||
} else {
|
||||
attachment.VolumeOptions = parsed.VolumeOptions
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := LoadAttachments(appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !attachmentExists(existing, attachment) {
|
||||
existing = append(existing, attachment)
|
||||
if err := SaveAttachments(appName, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mountedPhases := []string{}
|
||||
for _, phase := range phases {
|
||||
mountedPhases = append(mountedPhases, phase)
|
||||
}
|
||||
if err := dockeroptions.RemoveDockerOptionFromPhases(appName, mountedPhases, fmt.Sprintf("-v %s", mount)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func touchMigrationFlag(appName string) error {
|
||||
f, err := os.Create(migrationFlagFile(appName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func filterMountLines(lines []string) []string {
|
||||
out := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "-v ") {
|
||||
out = append(out, strings.TrimPrefix(line, "-v "))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUnique(slice []string, value string) []string {
|
||||
for _, existing := range slice {
|
||||
if existing == value {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, value)
|
||||
}
|
||||
|
||||
func attachmentExists(attachments []*Attachment, candidate *Attachment) bool {
|
||||
for _, existing := range attachments {
|
||||
if existing.EntryName == candidate.EntryName &&
|
||||
existing.ContainerPath == candidate.ContainerPath &&
|
||||
existing.ProcessType == candidate.ProcessType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user