From d5a9ac31ff7e851e88f84f7cb02cf6f8f296c125 Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Mon, 19 Oct 2026 20:43:28 +0200 Subject: [PATCH] fix: explain an installation found in the plain output, and take over one started from docker-compose.yaml The plain output, used in a terminal smaller than 80x22, never showed a question's help: an installation found was offered without saying what it was or why it could not be taken over. An installation started from docker-compose.yaml, compose.yaml or compose.yml is now read from that file and taken over; its first backup or upgrade renames it docker-compose.yml. A missing compose file or .env, and one root cannot read, each get their own explanation. One already at the version the installer installs is taken over without the word upgrade. --- internal/cli/adopt.go | 76 ++++++++++++++++++++-- internal/cli/adopt_test.go | 40 ++++++++++++ internal/compose/ops.go | 46 ++++++++++++++ internal/compose/ops_test.go | 59 ++++++++++++++++++ internal/detect/containers.go | 104 +++++++++++++++++++++++++------ internal/detect/database_test.go | 2 +- internal/detect/detect.go | 6 +- internal/detect/detect_test.go | 51 ++++++++++++++- internal/i18n/locales/ca.json | 13 +++- internal/i18n/locales/de.json | 13 +++- internal/i18n/locales/en.json | 13 +++- internal/i18n/locales/es.json | 13 +++- internal/i18n/locales/fr.json | 13 +++- internal/i18n/locales/it.json | 13 +++- internal/i18n/locales/ja.json | 13 +++- internal/i18n/locales/nl.json | 13 +++- internal/i18n/locales/pl.json | 13 +++- internal/i18n/locales/pt.json | 13 +++- internal/i18n/locales/zh.json | 13 +++- internal/ui/plain_test.go | 29 +++++++++ internal/ui/ui.go | 26 ++++++++ 21 files changed, 530 insertions(+), 52 deletions(-) create mode 100644 internal/compose/ops_test.go create mode 100644 internal/ui/plain_test.go diff --git a/internal/cli/adopt.go b/internal/cli/adopt.go index e46a5cf..ba88cc7 100644 --- a/internal/cli/adopt.go +++ b/internal/cli/adopt.go @@ -3,6 +3,8 @@ package cli import ( "context" "errors" + "os" + "path/filepath" "strconv" "strings" @@ -10,6 +12,7 @@ import ( "github.com/ansibleforms/installer/internal/detect" "github.com/ansibleforms/installer/internal/i18n" "github.com/ansibleforms/installer/internal/kube" + "github.com/ansibleforms/installer/internal/releases" "github.com/ansibleforms/installer/internal/state" "github.com/ansibleforms/installer/internal/ui" ) @@ -80,7 +83,11 @@ func reason(i detect.Install) string { // an RTE tells its version only with its token key = "detect.why_unmanagedRunner" } - return i18n.T(key, "dir", i.Dir, "port", i.Port, "version", orStr(i.Version, i18n.T("detect.versionUnknown")), "app", orStr(i.ArgoApp, "?")) + if i.Problem == detect.NoComposeFile && os.Geteuid() == 0 { + // root may read any file : what stops it is the file's own (a mode, an SELinux label) + key = "detect.why_noComposeFileRoot" + } + return i18n.T(key, "dir", i.Dir, "file", fileOf(i), "port", i.Port, "version", orStr(i.Version, i18n.T("detect.versionUnknown")), "app", orStr(i.ArgoApp, "?")) } // foundLine is an installation found on one line: what and where, and why it cannot be @@ -98,7 +105,44 @@ func foundLine(i detect.Install) string { if i.Adoptable() || i.Problem == detect.Unmanaged { return describe(i) } - return describe(i) + ": " + i18n.T("detect.short_"+i.Problem, "app", orStr(i.ArgoApp, "?")) + return describe(i) + ": " + i18n.T("detect.short_"+i.Problem, "app", orStr(i.ArgoApp, "?"), "file", fileOf(i)) +} + +// fileOf is the name of the file an installation's problem is about (its compose file, its +// .env), for the texts that name it. +// +// Args: +// +// i: the installation. +// +// Returns: +// +// The file's name, docker-compose.yml when the problem names none. +func fileOf(i detect.Install) string { + if i.ProblemFile == "" { + return "docker-compose.yml" + } + return filepath.Base(i.ProblemFile) +} + +// current tells whether an installation found already runs the version the installer +// installs: taking it over then changes nothing, its next upgrade comes later. A Runner in +// database mode is never current, its move to a socket mode is an upgrade of its own. +// +// Args: +// +// i: the installation. +// latest: the version the installer installs (zero when unknown). +// +// Returns: +// +// True when it runs that version or a newer one. +func current(i detect.Install, latest releases.Version) bool { + if latest == (releases.Version{}) || i.Database != nil { + return false + } + v, err := releases.ParseVersion(i.Version) + return err == nil && v.Compare(latest) >= 0 } // foundAdvice is what to do about the installations that cannot be taken over: the advice @@ -190,11 +234,27 @@ func offerFound(ctx context.Context, f Flags, found []detect.Install, alongside } return adopt(ctx, adoptable[0], false) } + // the version the installer installs : one found that runs it already is taken over + // without an upgrade, and said so (zero when it cannot be known : an upgrade then) + latest, _ := targetVersion(ctx, f) + allCurrent := len(adoptable) > 0 + for _, i := range adoptable { + if !current(i, latest) { + allCurrent = false + } + } for { - help := i18n.T("detect.found") + "\n\n" + foundList(found) + "\n\n" - if len(adoptable) > 0 { + title := i18n.T("detect.found") + if len(found) == 1 && current(found[0], latest) { + title = i18n.T("detect.foundCurrent", "version", found[0].Version) + } + help := title + "\n\n" + foundList(found) + "\n\n" + switch { + case allCurrent: + help += i18n.T("detect.foundHelpCurrent") + case len(adoptable) > 0: help += i18n.T("detect.foundHelp") - } else { + default: help += i18n.T("detect.foundHelpNone") } var opts []ui.Option @@ -207,7 +267,11 @@ func offerFound(ctx context.Context, f Flags, found []detect.Install, alongside if def == "stop" { def = "adopt:" + strconv.Itoa(n) } - opts = append(opts, ui.Option{Label: i18n.T("detect.adopt", "what", describe(i)), Value: "adopt:" + strconv.Itoa(n), Hint: i18n.T("detect.adoptHint")}) + label, hint := i18n.T("detect.adopt", "what", describe(i)), i18n.T("detect.adoptHint") + if current(i, latest) { + label, hint = i18n.T("detect.adoptCurrent", "what", describe(i)), i18n.T("detect.adoptCurrentHint") + } + opts = append(opts, ui.Option{Label: label, Value: "adopt:" + strconv.Itoa(n), Hint: hint}) } if alongside { opts = append(opts, ui.Option{Label: i18n.T("detect.alongside"), Value: "alongside", Hint: i18n.T("detect.alongsideHint")}) diff --git a/internal/cli/adopt_test.go b/internal/cli/adopt_test.go index ccc0ca5..ce64a89 100644 --- a/internal/cli/adopt_test.go +++ b/internal/cli/adopt_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "os" + "strings" "testing" "github.com/ansibleforms/installer/internal/detect" + "github.com/ansibleforms/installer/internal/releases" "github.com/ansibleforms/installer/internal/state" "github.com/ansibleforms/installer/internal/ui" ) @@ -72,3 +74,41 @@ func TestOfferFoundNothingAdoptable(t *testing.T) { t.Errorf("no reason given") } } + +// one found at the version the installer installs is taken over without an upgrade, and said +// so; an older one, or a Runner in database mode, is upgraded +func TestCurrent(t *testing.T) { + latest := releases.Version{Major: 7, Minor: 3} + at := handMade + at.Version = "7.3.0" + if !current(at, latest) { + t.Errorf("7.3.0 is not current at 7.3.0") + } + if current(handMade, latest) { + t.Errorf("7.2.0 is current at 7.3.0") + } + if current(at, releases.Version{}) { + t.Errorf("current when the latest is unknown") + } + at.Database = &detect.DatabaseRunner{} + if current(at, latest) { + t.Errorf("a Runner in database mode is current") + } +} + +// the compose file's problems name the file, and root is not told to run as root +func TestReasonNamesTheFile(t *testing.T) { + i := handMade + i.Problem, i.ProblemFile = detect.MissingComposeFile, "/opt/docker/.env" + if r := reason(i); !strings.Contains(r, ".env") || !strings.Contains(r, "/opt/docker") { + t.Errorf("reason: %s", r) + } + i.Problem, i.ProblemFile = detect.NoComposeFile, "/opt/docker/docker-compose.yaml" + r := reason(i) + if !strings.Contains(r, "docker-compose.yaml") { + t.Errorf("reason: %s", r) + } + if os.Geteuid() == 0 && strings.Contains(r, "as root or") { + t.Errorf("root told to run as root: %s", r) + } +} diff --git a/internal/compose/ops.go b/internal/compose/ops.go index ca3bf28..f444f76 100644 --- a/internal/compose/ops.go +++ b/internal/compose/ops.go @@ -59,6 +59,9 @@ func Backup(ctx context.Context, st *state.State) (string, error) { if st.Role == RoleRunner { return "", errors.New(i18n.T("compose.runnerNoBackup")) } + if err := repoComposeName(st.Dir); err != nil { + return "", err + } dir := backupDir(st) if err := os.MkdirAll(dir, 0o700); err != nil { return "", err @@ -94,6 +97,46 @@ func restore(ctx context.Context, st *state.State, dump string) error { return system.FromFile(ctx, st.Dir, dump, name, args...) } +// repoComposeName makes the folder's compose file docker-compose.yml, the name the docker +// repository uses and the upgrade writes. A project taken over that was started from +// compose.yaml, compose.yml or docker-compose.yaml has that file renamed, and the other +// compose files beside it, which compose would not read, moved aside (.