mirror of
https://github.com/dokku/dokku.git
synced 2026-09-01 19:49:25 +02:00
Options drained out of the pre-0.38.0 `DOCKER_OPTIONS_<PHASE>` files were copied verbatim rather than re-serialized the way `docker-options:add` stores them, so `docker-options:remove` compared the canonical string it builds against a stored value that could never match it and exited successfully without removing anything. Removal now matches stored options by shell word, and stored options are rewritten into the canonical form once on upgrade, which additionally splits an entry that carried several flags on a single line into one entry per flag so a single flag can be removed and so the readers that match on a flag prefix see one value per entry. The leftover `.migrated` sentinel drain is restored to running ahead of the global short-circuit that had made it unreachable, and the plugin's Go tests are added to the test target that had never run them.
433 lines
15 KiB
Go
433 lines
15 KiB
Go
package dockeroptions
|
|
|
|
import (
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dokku/dokku/plugins/common"
|
|
)
|
|
|
|
// setupMigrationEnv points the dokku env at temporary directories and tells the
|
|
// permission helpers to chown files to the current user (a no-op) so the test
|
|
// works without root.
|
|
func setupMigrationEnv(t *testing.T) (dokkuRoot string) {
|
|
t.Helper()
|
|
|
|
libRoot := t.TempDir()
|
|
dokkuRoot = t.TempDir()
|
|
|
|
t.Setenv("DOKKU_LIB_ROOT", libRoot)
|
|
t.Setenv("DOKKU_ROOT", dokkuRoot)
|
|
t.Setenv("PLUGIN_PATH", filepath.Join(libRoot, "plugins"))
|
|
|
|
current, err := user.Current()
|
|
if err != nil {
|
|
t.Fatalf("user.Current: %v", err)
|
|
}
|
|
group, err := user.LookupGroupId(current.Gid)
|
|
if err != nil {
|
|
t.Fatalf("user.LookupGroupId: %v", err)
|
|
}
|
|
t.Setenv("DOKKU_SYSTEM_USER", current.Username)
|
|
t.Setenv("DOKKU_SYSTEM_GROUP", group.Name)
|
|
|
|
return dokkuRoot
|
|
}
|
|
|
|
func writeLegacyDockerOptionsFile(t *testing.T, dokkuRoot, app, phase, contents string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Join(dokkuRoot, app), 0755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
path := filepath.Join(dokkuRoot, app, "DOCKER_OPTIONS_"+phase)
|
|
if err := os.WriteFile(path, []byte(contents), 0644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyDockerOptionsFiles_MigratesAndIsIdempotent(t *testing.T) {
|
|
dokkuRoot := setupMigrationEnv(t)
|
|
|
|
writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "DEPLOY", "-v /var/log:/log\n# a comment\n\n--restart=on-failure:5\n")
|
|
writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "BUILD", "--build-arg FOO=bar\n")
|
|
writeLegacyDockerOptionsFile(t, dokkuRoot, "beta", "DEPLOY", "-p 8080:5000\n")
|
|
|
|
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
|
t.Fatalf("first migration: %v", err)
|
|
}
|
|
|
|
deploy, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy")
|
|
if err != nil {
|
|
t.Fatalf("PropertyListGet alpha deploy: %v", err)
|
|
}
|
|
wantDeploy := []string{"-v /var/log:/log", "--restart=on-failure:5"}
|
|
if !equalStrings(deploy, wantDeploy) {
|
|
t.Errorf("alpha deploy = %v, want %v", deploy, wantDeploy)
|
|
}
|
|
|
|
build, err := common.PropertyListGet("docker-options", "alpha", "_default_.build")
|
|
if err != nil {
|
|
t.Fatalf("PropertyListGet alpha build: %v", err)
|
|
}
|
|
wantBuild := []string{"--build-arg FOO=bar"}
|
|
if !equalStrings(build, wantBuild) {
|
|
t.Errorf("alpha build = %v, want %v", build, wantBuild)
|
|
}
|
|
|
|
betaDeploy, err := common.PropertyListGet("docker-options", "beta", "_default_.deploy")
|
|
if err != nil {
|
|
t.Fatalf("PropertyListGet beta deploy: %v", err)
|
|
}
|
|
wantBetaDeploy := []string{"-p 8080:5000"}
|
|
if !equalStrings(betaDeploy, wantBetaDeploy) {
|
|
t.Errorf("beta deploy = %v, want %v", betaDeploy, wantBetaDeploy)
|
|
}
|
|
|
|
if !common.PropertyExists("docker-options", "--global", "migrated-from-files") {
|
|
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 _, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
sneakyPath := filepath.Join(dokkuRoot, "alpha", "DOCKER_OPTIONS_DEPLOY")
|
|
sneakyContents := "-v /tmp/sneaky:/sneaky\n"
|
|
if err := os.WriteFile(sneakyPath, []byte(sneakyContents), 0644); err != nil {
|
|
t.Fatalf("re-create legacy file: %v", err)
|
|
}
|
|
|
|
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
|
t.Fatalf("second migration: %v", err)
|
|
}
|
|
|
|
deployAfter, err := common.PropertyListGet("docker-options", "alpha", "_default_.deploy")
|
|
if err != nil {
|
|
t.Fatalf("PropertyListGet alpha deploy (post re-run): %v", err)
|
|
}
|
|
if !equalStrings(deployAfter, wantDeploy) {
|
|
t.Errorf("alpha deploy after rerun = %v, want %v (idempotency violated)", deployAfter, wantDeploy)
|
|
}
|
|
|
|
gotSneaky, err := os.ReadFile(sneakyPath)
|
|
if err != nil {
|
|
t.Fatalf("expected sneaky legacy file untouched, got err=%v", err)
|
|
}
|
|
if string(gotSneaky) != sneakyContents {
|
|
t.Errorf("sneaky legacy file modified: %q", gotSneaky)
|
|
}
|
|
}
|
|
|
|
// 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 TestRepairTraefikLabelBackticks(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
changed bool
|
|
}{
|
|
{"traefik rule space form", "--label 'traefik.http.routers.web.rule=Host(\\`app.example.com\\`)'", "--label 'traefik.http.routers.web.rule=Host(`app.example.com`)'", true},
|
|
{"traefik rule whole-quoted equals form", "'--label=traefik.http.routers.web.rule=Host(\\`x\\`)'", "'--label=traefik.http.routers.web.rule=Host(`x`)'", true},
|
|
{"non-traefik label left untouched", "--label 'some.key=Host(\\`x\\`)'", "--label 'some.key=Host(\\`x\\`)'", false},
|
|
{"already valid traefik label", "--label 'traefik.rule=Host(`x`)'", "--label 'traefik.rule=Host(`x`)'", false},
|
|
{"non-label option left untouched", "-v /tmp", "-v /tmp", false},
|
|
{"traefik label without backticks", "--label 'traefik.enable=true'", "--label 'traefik.enable=true'", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, changed := repairTraefikLabelBackticks(tc.in)
|
|
if got != tc.want || changed != tc.changed {
|
|
t.Errorf("repairTraefikLabelBackticks(%q) = (%q, %v), want (%q, %v)", tc.in, got, changed, tc.want, tc.changed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMigrateTraefikLabelBackticks(t *testing.T) {
|
|
dokkuRoot := setupMigrationEnv(t)
|
|
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
broken := "--label 'traefik.http.routers.web.rule=Host(\\`app.example.com\\`)'"
|
|
if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{broken}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
if err := migrateTraefikLabelBackticks(); err != nil {
|
|
t.Fatalf("migrateTraefikLabelBackticks: %v", err)
|
|
}
|
|
|
|
got, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase: %v", err)
|
|
}
|
|
want := "--label 'traefik.http.routers.web.rule=Host(`app.example.com`)'"
|
|
if !equalStrings(got, []string{want}) {
|
|
t.Errorf("got %q, want [%q]", got, []string{want})
|
|
}
|
|
if common.PropertyGet("docker-options", "--global", traefikLabelMigrationKey) != "true" {
|
|
t.Errorf("expected %s guard to be set", traefikLabelMigrationKey)
|
|
}
|
|
}
|
|
|
|
// TestMigrateLegacyDockerOptionsFiles_CanonicalizesLines covers the
|
|
// regression behind #8904: legacy lines were drained verbatim, so an
|
|
// option holding shell metacharacters could never match the
|
|
// canonically re-serialized string `docker-options:remove` builds.
|
|
func TestMigrateLegacyDockerOptionsFiles_CanonicalizesLines(t *testing.T) {
|
|
dokkuRoot := setupMigrationEnv(t)
|
|
|
|
writeLegacyDockerOptionsFile(t, dokkuRoot, "alpha", "DEPLOY", "--group-add $(getent group docker | cut -d: -f3)\n-v /a:/a -v /b:/b\n")
|
|
|
|
if err := migrateLegacyDockerOptionsFiles(); err != nil {
|
|
t.Fatalf("migrateLegacyDockerOptionsFiles: %v", err)
|
|
}
|
|
|
|
got, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase: %v", err)
|
|
}
|
|
want := []string{
|
|
"--group-add '$(getent group docker | cut -d: -f3)'",
|
|
"-v /a:/a",
|
|
"-v /b:/b",
|
|
}
|
|
if !equalStrings(got, want) {
|
|
t.Errorf("alpha deploy = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestMigrateNonCanonicalOptions covers the repair pass for installs
|
|
// that already drained their legacy files under an earlier release, so
|
|
// the non-canonical values are sitting in the property store.
|
|
func TestMigrateNonCanonicalOptions(t *testing.T) {
|
|
dokkuRoot := setupMigrationEnv(t)
|
|
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{
|
|
"--group-add $(getent group docker | cut -d: -f3)",
|
|
"-v /a:/a -v /b:/b",
|
|
}); err != nil {
|
|
t.Fatalf("seed default scope: %v", err)
|
|
}
|
|
if err := common.PropertyListWrite("docker-options", "alpha", "web.deploy", []string{
|
|
"--label FOO=$BAR",
|
|
}); err != nil {
|
|
t.Fatalf("seed web scope: %v", err)
|
|
}
|
|
if err := common.PropertyListWrite("docker-options", "alpha", "_default_.build", []string{
|
|
"--build-arg FOO=bar",
|
|
}); err != nil {
|
|
t.Fatalf("seed build scope: %v", err)
|
|
}
|
|
|
|
if err := migrateNonCanonicalOptions(); err != nil {
|
|
t.Fatalf("migrateNonCanonicalOptions: %v", err)
|
|
}
|
|
|
|
deploy, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase deploy: %v", err)
|
|
}
|
|
wantDeploy := []string{
|
|
"--group-add '$(getent group docker | cut -d: -f3)'",
|
|
"-v /a:/a",
|
|
"-v /b:/b",
|
|
}
|
|
if !equalStrings(deploy, wantDeploy) {
|
|
t.Errorf("alpha deploy = %q, want %q", deploy, wantDeploy)
|
|
}
|
|
|
|
web, err := GetDockerOptionsForProcessPhase("alpha", "web", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase web.deploy: %v", err)
|
|
}
|
|
if !equalStrings(web, []string{"--label 'FOO=$BAR'"}) {
|
|
t.Errorf("alpha web.deploy = %q, want [--label 'FOO=$BAR']", web)
|
|
}
|
|
|
|
build, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "build")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase build: %v", err)
|
|
}
|
|
if !equalStrings(build, []string{"--build-arg FOO=bar"}) {
|
|
t.Errorf("alpha build = %q, want [--build-arg FOO=bar]", build)
|
|
}
|
|
|
|
if common.PropertyGet("docker-options", "--global", canonicalOptionsMigrationKey) != "true" {
|
|
t.Errorf("expected %s guard to be set", canonicalOptionsMigrationKey)
|
|
}
|
|
|
|
if err := migrateNonCanonicalOptions(); err != nil {
|
|
t.Fatalf("second migrateNonCanonicalOptions: %v", err)
|
|
}
|
|
deployAfter, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase deploy (post re-run): %v", err)
|
|
}
|
|
if !equalStrings(deployAfter, wantDeploy) {
|
|
t.Errorf("alpha deploy after rerun = %q, want %q (idempotency violated)", deployAfter, wantDeploy)
|
|
}
|
|
}
|
|
|
|
// TestRemoveDockerOptionFromPhases_MatchesNonCanonicalStoredOption is
|
|
// the regression test for #8904 itself: an option left in the store in
|
|
// its pre-0.38.25 unquoted form must still be removable using the
|
|
// string a user would type.
|
|
func TestRemoveDockerOptionFromPhases_MatchesNonCanonicalStoredOption(t *testing.T) {
|
|
dokkuRoot := setupMigrationEnv(t)
|
|
if err := os.MkdirAll(filepath.Join(dokkuRoot, "alpha"), 0755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
stored := "--group-add $(getent group docker | cut -d: -f3)"
|
|
if err := common.PropertyListWrite("docker-options", "alpha", "_default_.deploy", []string{
|
|
stored,
|
|
"-v /keep:/keep",
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
options, _, err := SplitOptionString(stored)
|
|
if err != nil {
|
|
t.Fatalf("SplitOptionString: %v", err)
|
|
}
|
|
if len(options) != 1 {
|
|
t.Fatalf("SplitOptionString returned %d options, want 1", len(options))
|
|
}
|
|
|
|
if err := RemoveDockerOptionFromPhases("alpha", []string{"deploy"}, options[0]); err != nil {
|
|
t.Fatalf("RemoveDockerOptionFromPhases: %v", err)
|
|
}
|
|
|
|
got, err := GetDockerOptionsForProcessPhase("alpha", "_default_", "deploy")
|
|
if err != nil {
|
|
t.Fatalf("GetDockerOptionsForProcessPhase: %v", err)
|
|
}
|
|
if !equalStrings(got, []string{"-v /keep:/keep"}) {
|
|
t.Errorf("alpha deploy = %q, want [-v /keep:/keep]", got)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|