From cf84679133f60f33fc9e2e5670fe78b361c2dc9c Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 31 May 2026 16:37:15 -0400 Subject: [PATCH] fix: stop truncating existing files in common.TouchFile `common.TouchFile` opened files with `O_TRUNC`, so the call from `common.SshTask.Execute` against `~/.ssh/known_hosts` emptied the dokku user's previously trusted host keys before `goph.DefaultKnownHosts()` read them. Running `scheduler-k3s:cluster:add` without `--insecure-allow-unknown-hosts` then failed with `knownhosts: key is unknown` and left a zero-byte `known_hosts`. The function now matches `touch(1)` semantics: create the file if missing, otherwise leave its contents intact. --- plugins/common/io.go | 6 +++-- plugins/common/io_test.go | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/plugins/common/io.go b/plugins/common/io.go index 0f298b785..3bed590e4 100644 --- a/plugins/common/io.go +++ b/plugins/common/io.go @@ -212,10 +212,12 @@ func TouchDir(filename string) error { return os.MkdirAll(filename, mode) } -// TouchFile creates an empty file at the specified path +// TouchFile ensures a file exists at the specified path without truncating +// its contents. It matches the semantics of unix touch(1): create the file if +// it does not exist, otherwise leave the existing contents intact. func TouchFile(filename string) error { mode := os.FileMode(0600) - file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, mode) if err != nil { return fmt.Errorf("Error opening file %v for creation: %v", filename, err) } diff --git a/plugins/common/io_test.go b/plugins/common/io_test.go index e03ad046a..de559e2ba 100644 --- a/plugins/common/io_test.go +++ b/plugins/common/io_test.go @@ -1,6 +1,9 @@ package common import ( + "os" + "os/user" + "path/filepath" "testing" . "github.com/onsi/gomega" @@ -31,3 +34,47 @@ func TestCommonReadFirstLine(t *testing.T) { Expect(line).To(Equal(testEnvLine)) teardownTestApp() } + +// setupTouchFileTest pins DOKKU_SYSTEM_USER/GROUP to the running test user so +// SetPermissions' chown succeeds for non-root test runs. +func setupTouchFileTest(t *testing.T) { + t.Helper() + u, err := user.Current() + Expect(err).NotTo(HaveOccurred()) + g, err := user.LookupGroupId(u.Gid) + Expect(err).NotTo(HaveOccurred()) + t.Setenv("DOKKU_SYSTEM_USER", u.Username) + t.Setenv("DOKKU_SYSTEM_GROUP", g.Name) +} + +func TestCommonTouchFileCreatesMissingFile(t *testing.T) { + RegisterTestingT(t) + setupTouchFileTest(t) + + path := filepath.Join(t.TempDir(), "new-file") + Expect(FileExists(path)).To(BeFalse()) + Expect(TouchFile(path)).To(Succeed()) + + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Size()).To(BeNumerically("==", 0)) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) +} + +// TestCommonTouchFilePreservesExistingContents is a regression test for #8722: +// scheduler-k3s:cluster:add used to zero out the dokku user's ~/.ssh/known_hosts +// because TouchFile opened the file with O_TRUNC. +func TestCommonTouchFilePreservesExistingContents(t *testing.T) { + RegisterTestingT(t) + setupTouchFileTest(t) + + path := filepath.Join(t.TempDir(), "existing-file") + contents := []byte("host ssh-rsa AAAA...\n") + Expect(os.WriteFile(path, contents, 0600)).To(Succeed()) + + Expect(TouchFile(path)).To(Succeed()) + + got, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(contents)) +}