refactor: convert filesystem migration markers to plugin properties
The docker-options DOCKER_OPTIONS_<PHASE>.migrated sentinels and the storage data/storage-registry/migrations/<app> flag files are now stored as per-app properties (`migrated-<phase>` and `legacy-mounts-migrated` respectively), so they survive a property-store backup/restore and align with the existing `migrated-from-files`, `env-migrated`, and `nginx-conf-sigil-migrated` patterns. The new markers are written only when a non-empty legacy source was actually migrated, distinguishing apps that never had legacy state from apps that did and were drained. A one-cycle upgrade-cycle conversion drains any leftover filesystem sentinels into the new properties and removes the files; for docker-options the conversion deliberately runs before the global `migrated-from-files` short-circuit so installs upgrading from the previous release still get their `.migrated` files converted.
This commit is contained in:
@@ -245,22 +245,66 @@ func CommandReport(appName string, format string, infoFlag string) error {
|
||||
return ReportSingleApp(appName, format, infoFlag)
|
||||
}
|
||||
|
||||
// migrateLegacyDockerOptionsFiles converts pre-properties DOCKER_OPTIONS_*
|
||||
// files into property lists. It is gated by a single global marker so it never
|
||||
// re-runs - even if a user later restores a DOCKER_OPTIONS_* file by hand.
|
||||
func migrateLegacyDockerOptionsFiles() error {
|
||||
if common.PropertyExists("docker-options", "--global", "migrated-from-files") {
|
||||
// migratedPropertyKey returns the per-app property name recording that
|
||||
// an app's DOCKER_OPTIONS_<PHASE> file was drained into the property
|
||||
// store. Surfaces in the property store as `docker-options/<app>/migrated-<phase>`.
|
||||
func migratedPropertyKey(phase string) string {
|
||||
return "migrated-" + strings.ToLower(phase)
|
||||
}
|
||||
|
||||
// convertLegacyMigratedMarker drains a leftover DOCKER_OPTIONS_<PHASE>.migrated
|
||||
// sentinel into the new per-phase property and removes the file. Runs
|
||||
// as part of the upgrade-cycle pass; no-op when the file is absent.
|
||||
// TODO(post-deprecation): remove this helper and its caller.
|
||||
func convertLegacyMigratedMarker(appName, phase string) error {
|
||||
migratedPath := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)+".migrated")
|
||||
if !common.FileExists(migratedPath) {
|
||||
return nil
|
||||
}
|
||||
if err := common.PropertyWrite("docker-options", appName, migratedPropertyKey(phase), "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(migratedPath)
|
||||
}
|
||||
|
||||
// migrateLegacyDockerOptionsFiles converts pre-properties
|
||||
// DOCKER_OPTIONS_* files into property lists. Idempotency comes from
|
||||
// two layers: a per-phase per-app `migrated-<phase>` property recording
|
||||
// what was drained, and a global `migrated-from-files` short-circuit so
|
||||
// the steady-state install path returns immediately.
|
||||
//
|
||||
// The upgrade-cycle conversion of leftover `.migrated` filesystem
|
||||
// sentinels runs BEFORE the global gate so users upgrading from the
|
||||
// previous release - who have `migrated-from-files` set AND `.migrated`
|
||||
// files on disk - still get those files drained into properties.
|
||||
func migrateLegacyDockerOptionsFiles() error {
|
||||
apps, err := common.DokkuApps()
|
||||
if err != nil {
|
||||
if errors.Is(err, common.NoAppsExist) {
|
||||
return common.PropertyWrite("docker-options", "--global", "migrated-from-files", "true")
|
||||
if !common.PropertyExists("docker-options", "--global", "migrated-from-files") {
|
||||
return common.PropertyWrite("docker-options", "--global", "migrated-from-files", "true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Upgrade-cycle conversion: drain any `.migrated` sentinels left
|
||||
// behind by the previous release into the new per-phase properties.
|
||||
// Always runs; cheap when nothing to do.
|
||||
// TODO(post-deprecation): remove this loop.
|
||||
for _, appName := range apps {
|
||||
for _, phase := range availablePhases {
|
||||
if err := convertLegacyMigratedMarker(appName, phase); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if common.PropertyExists("docker-options", "--global", "migrated-from-files") {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, appName := range apps {
|
||||
for _, phase := range availablePhases {
|
||||
legacyPath := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase))
|
||||
@@ -273,16 +317,19 @@ func migrateLegacyDockerOptionsFiles() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := common.PropertyListWrite("docker-options", appName, propertyKey(DefaultProcessType, phase), lines); err != nil {
|
||||
return err
|
||||
if len(lines) > 0 {
|
||||
if err := common.PropertyListWrite("docker-options", appName, propertyKey(DefaultProcessType, phase), lines); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.PropertyWrite("docker-options", appName, migratedPropertyKey(phase), "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
common.LogInfo1(fmt.Sprintf("Migrated %s to docker-options properties", legacyPath))
|
||||
}
|
||||
|
||||
migratedPath := legacyPath + ".migrated"
|
||||
if err := os.Rename(legacyPath, migratedPath); err != nil {
|
||||
return fmt.Errorf("Unable to rename migrated file %s: %s", legacyPath, err.Error())
|
||||
if err := os.Remove(legacyPath); err != nil {
|
||||
return fmt.Errorf("Unable to remove migrated file %s: %s", legacyPath, err.Error())
|
||||
}
|
||||
|
||||
common.LogInfo1(fmt.Sprintf("Migrated %s to docker-options properties", legacyPath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +359,7 @@ func readLegacyOptionsFile(path string) ([]string, error) {
|
||||
// removeMigratedLegacyFiles deletes any leftover DOCKER_OPTIONS_*.migrated
|
||||
// files for an app. Called from post-delete so we don't leak files into a
|
||||
// directory that is about to be torn down anyway.
|
||||
// TODO(post-deprecation): remove this helper and the call in TriggerPostDelete.
|
||||
func removeMigratedLegacyFiles(appName string) {
|
||||
for _, phase := range availablePhases {
|
||||
path := filepath.Join(common.AppRoot(appName), "DOCKER_OPTIONS_"+strings.ToUpper(phase)+".migrated")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
@@ -89,21 +90,28 @@ func TestMigrateLegacyDockerOptionsFiles_MigratesAndIsIdempotent(t *testing.T) {
|
||||
t.Errorf("migrated-from-files marker not set after first migration")
|
||||
}
|
||||
|
||||
migratedPhases := map[string]map[string]bool{
|
||||
"alpha": {"build": true, "deploy": true},
|
||||
"beta": {"deploy": true},
|
||||
}
|
||||
for _, app := range []string{"alpha", "beta"} {
|
||||
for _, phase := range []string{"BUILD", "DEPLOY", "RUN"} {
|
||||
legacy := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase)
|
||||
migrated := legacy + ".migrated"
|
||||
if app == "alpha" && (phase == "BUILD" || phase == "DEPLOY") || app == "beta" && phase == "DEPLOY" {
|
||||
if _, err := os.Stat(migrated); err != nil {
|
||||
t.Errorf("expected %s to exist: %v", migrated, err)
|
||||
}
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Errorf("expected %s to be gone, got err=%v", legacy, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Errorf("did not expect %s to exist, got err=%v", legacy, err)
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Errorf("expected %s to be gone, got err=%v", legacy, err)
|
||||
}
|
||||
if _, err := os.Stat(migrated); !os.IsNotExist(err) {
|
||||
t.Errorf("expected %s to be gone (no more rename), got err=%v", migrated, err)
|
||||
}
|
||||
lowerPhase := strings.ToLower(phase)
|
||||
exists := common.PropertyExists("docker-options", app, "migrated-"+lowerPhase)
|
||||
if migratedPhases[app][lowerPhase] {
|
||||
if !exists {
|
||||
t.Errorf("expected migrated-%s property for %s to be set", lowerPhase, app)
|
||||
}
|
||||
} else if exists {
|
||||
t.Errorf("did not expect migrated-%s property for %s to be set", lowerPhase, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +143,89 @@ func TestMigrateLegacyDockerOptionsFiles_MigratesAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateLegacyDockerOptionsFiles_ConvertsLegacyMigratedMarker
|
||||
// covers the upgrade-cycle path where a previous-release `.migrated`
|
||||
// sentinel exists on disk. The conversion writes the per-phase
|
||||
// property and removes the file.
|
||||
func TestMigrateLegacyDockerOptionsFiles_ConvertsLegacyMigratedMarker(t *testing.T) {
|
||||
dokkuRoot := setupMigrationEnv(t)
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
sentinel := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY.migrated")
|
||||
if err := os.WriteFile(sentinel, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("WriteFile sentinel: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
||||
t.Fatalf("migrateLegacyDockerOptionsFiles: %v", err)
|
||||
}
|
||||
|
||||
if !common.PropertyExists("docker-options", "alpha", "migrated-deploy") {
|
||||
t.Errorf("expected migrated-deploy property to be set")
|
||||
}
|
||||
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
|
||||
t.Errorf("expected sentinel file gone, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateLegacyDockerOptionsFiles_ConvertsMigratedMarkerEvenWhenGloballyMigrated
|
||||
// is the regression test for the upgrade-cycle ordering fix: users on
|
||||
// the previous release have `migrated-from-files` ALREADY set AND
|
||||
// `.migrated` sentinels on disk. The conversion pass must run before
|
||||
// the global short-circuit so these sentinels still get drained.
|
||||
func TestMigrateLegacyDockerOptionsFiles_ConvertsMigratedMarkerEvenWhenGloballyMigrated(t *testing.T) {
|
||||
dokkuRoot := setupMigrationEnv(t)
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
sentinel := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY.migrated")
|
||||
if err := os.WriteFile(sentinel, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("WriteFile sentinel: %v", err)
|
||||
}
|
||||
if err := common.PropertyWrite("docker-options", "--global", "migrated-from-files", "true"); err != nil {
|
||||
t.Fatalf("seed global marker: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
||||
t.Fatalf("migrateLegacyDockerOptionsFiles: %v", err)
|
||||
}
|
||||
|
||||
if !common.PropertyExists("docker-options", "alpha", "migrated-deploy") {
|
||||
t.Errorf("expected migrated-deploy property to be set despite global short-circuit")
|
||||
}
|
||||
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
|
||||
t.Errorf("expected sentinel file gone, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateLegacyDockerOptionsFiles_SkipsEmptyContentFiles verifies
|
||||
// that a legacy file containing only comments/whitespace gets removed
|
||||
// but does NOT receive a per-phase property write - the property is
|
||||
// only set when actual content was drained.
|
||||
func TestMigrateLegacyDockerOptionsFiles_SkipsEmptyContentFiles(t *testing.T) {
|
||||
dokkuRoot := setupMigrationEnv(t)
|
||||
|
||||
writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "DEPLOY", "# only a comment\n\n \n")
|
||||
|
||||
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
||||
t.Fatalf("migrateLegacyDockerOptionsFiles: %v", err)
|
||||
}
|
||||
|
||||
if common.PropertyExists("docker-options", "alpha", "migrated-deploy") {
|
||||
t.Errorf("did not expect migrated-deploy property to be set for empty-content file")
|
||||
}
|
||||
legacy := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY")
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Errorf("expected empty-content legacy file removed, got err=%v", err)
|
||||
}
|
||||
if common.PropertyExists("docker-options", "alpha", "_default_.deploy") {
|
||||
t.Errorf("did not expect _default_.deploy property list for empty-content file")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user