Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ type Model struct {
// first.
dirty bool

// composeDir is the directory of the compose file this session is
// bound to: set when a file is imported or successfully saved.
// .env, .gitignore and secrets/ are written there, because that is
// where Compose resolves them (not the shell's working directory).
// Empty until the session is bound to a file; callers then fall
// back to the working directory.
composeDir string

quitting bool
}

Expand Down Expand Up @@ -172,6 +180,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case saveResult:
m.lastSave = msg
if msg.err == nil {
m.composeDir = dirOf(msg.filename)
m.dirty = false
m.currentMode = modeSaved
m.syncViewportHeight()
Expand Down Expand Up @@ -955,6 +964,7 @@ func (m *Model) importFile(path string) error {
return err
}
m.config = imported
m.composeDir = dirOf(path)
// The working copy now differs from whatever's on disk at the
// default save path until it's explicitly saved, same as any other
// change — this isn't itself the file at "docker-compose.yml".
Expand Down
23 changes: 19 additions & 4 deletions internal/tui/secrets_dialog.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ import (
// ConvertSecretToEnvFile/ConvertSecretToComposeSecret only mutate the
// in-memory config and hand back what needs writing.
func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd {
// Captured now, not inside the closure: the command runs later on
// another goroutine, and the model may have been copied by then.
dir := m.composeDir
return func() tea.Msg {
switch strategy {
case 0: // .env
line, err := m.config.ConvertSecretToEnvFile(service, key)
if err != nil {
return secretActionResult{err: err}
}
if err := appendEnvFileLine(".env", line); err != nil {
if err := appendEnvFileLine(filepath.Join(dir, ".env"), line); err != nil {
return secretActionResult{err: fmt.Errorf("updating .env: %w", err)}
}
msg := fmt.Sprintf("Moved %s to .env.", key)
if err := ensureGitignoreEntry(".gitignore", ".env"); err != nil {
if err := ensureGitignoreEntry(filepath.Join(dir, ".gitignore"), ".env"); err != nil {
msg += " Couldn't update .gitignore automatically — make sure .env is ignored yourself."
} else {
msg += " Added .env to .gitignore."
Expand All @@ -41,14 +44,17 @@ func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd {
if err != nil {
return secretActionResult{err: err}
}
if err := writeSecretFile(path, value); err != nil {
// path is "./secrets/<name>.txt", relative to the compose
// file (that is how the top-level secrets: entry refers to
// it), so it is written relative to the compose file too.
if err := writeSecretFile(filepath.Join(dir, path), value); err != nil {
return secretActionResult{err: fmt.Errorf("writing %s: %w", path, err)}
}
msg := fmt.Sprintf(
"Moved %s to a Compose secret ('%s', %s) — the service now reads it "+
"from /run/secrets/%s instead of the environment.",
key, secretName, path, secretName)
if err := ensureGitignoreEntry(".gitignore", "secrets/"); err != nil {
if err := ensureGitignoreEntry(filepath.Join(dir, ".gitignore"), "secrets/"); err != nil {
msg += " Couldn't update .gitignore automatically — make sure secrets/ is ignored yourself."
} else {
msg += " Added secrets/ to .gitignore."
Expand All @@ -59,6 +65,15 @@ func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd {
}
}

// dirOf returns the absolute directory of a compose file path, or the
// plain directory part if the working directory can't be resolved.
func dirOf(path string) string {
if abs, err := filepath.Abs(path); err == nil {
return filepath.Dir(abs)
}
return filepath.Dir(path)
}

// ensureGitignoreEntry appends entry to a .gitignore file as its own
// line, creating the file if it doesn't exist. If entry (or the same
// path with/without a trailing slash — ".env" and ".env/" are treated
Expand Down
105 changes: 105 additions & 0 deletions internal/tui/secrets_dialog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"runtime"
"testing"

"github.com/casablanque-code/komposer/pkg/composer"
)

func TestEnsureGitignoreEntry_CreatesFileIfMissing(t *testing.T) {
Expand Down Expand Up @@ -135,3 +137,106 @@ func TestWriteSecretFile_CreatesParentDirAndWritesValue(t *testing.T) {
}
}
}

// newSecretTestModel returns a model bound to dir with one service
// holding a hardcoded POSTGRES_PASSWORD.
func newSecretTestModel(t *testing.T, dir string) *Model {
t.Helper()
cfg := composer.NewComposeConfig()
svc := cfg.AddService("db")
svc.Image = "postgres:16"
svc.Environment = []string{"POSTGRES_PASSWORD=hunter2"}
return &Model{config: cfg, composeDir: dir}
}

func TestConvertSecretCmd_EnvFileGoesNextToComposeFile(t *testing.T) {
dir := t.TempDir()
cwd := t.TempDir()
t.Chdir(cwd) // the shell's working directory must stay untouched

m := newSecretTestModel(t, dir)
res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 0)().(secretActionResult)
if res.err != nil {
t.Fatalf("unexpected error: %v", res.err)
}

env, err := os.ReadFile(filepath.Join(dir, ".env"))
if err != nil {
t.Fatalf(".env not written next to compose file: %v", err)
}
if string(env) != "POSTGRES_PASSWORD=hunter2\n" {
t.Fatalf(".env = %q", env)
}
if _, err := os.Stat(filepath.Join(dir, ".gitignore")); err != nil {
t.Fatalf(".gitignore not written next to compose file: %v", err)
}
for _, name := range []string{".env", ".gitignore"} {
if _, err := os.Stat(filepath.Join(cwd, name)); err == nil {
t.Fatalf("%s leaked into the working directory", name)
}
}
}

func TestConvertSecretCmd_ComposeSecretGoesNextToComposeFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode bits are not meaningful on Windows")
}
dir := t.TempDir()
cwd := t.TempDir()
t.Chdir(cwd)

m := newSecretTestModel(t, dir)
res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 1)().(secretActionResult)
if res.err != nil {
t.Fatalf("unexpected error: %v", res.err)
}

got, err := os.ReadFile(filepath.Join(dir, "secrets", "db_postgres_password.txt"))
if err != nil {
t.Fatalf("secret file not written next to compose file: %v", err)
}
if string(got) != "hunter2" {
t.Fatalf("secret content = %q", got)
}
if _, err := os.Stat(filepath.Join(cwd, "secrets")); err == nil {
t.Fatal("secrets/ leaked into the working directory")
}
}

func TestConvertSecretCmd_UnboundModelFallsBackToWorkingDir(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)

m := newSecretTestModel(t, "")
res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 0)().(secretActionResult)
if res.err != nil {
t.Fatalf("unexpected error: %v", res.err)
}
if _, err := os.Stat(filepath.Join(cwd, ".env")); err != nil {
t.Fatalf(".env not written to working directory: %v", err)
}
}

func TestImportFile_BindsComposeDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compose.yaml")
if err := os.WriteFile(path, []byte("services:\n web:\n image: nginx:1.27\n"), 0644); err != nil {
t.Fatalf("setup: %v", err)
}
m := &Model{config: composer.NewComposeConfig()}
if err := m.importFile(path); err != nil {
t.Fatalf("import: %v", err)
}
if m.composeDir != dir {
t.Fatalf("composeDir = %q, want %q", m.composeDir, dir)
}
}

func TestSaveResult_BindsComposeDir(t *testing.T) {
dir := t.TempDir()
m := Model{config: composer.NewComposeConfig(), dirty: true}
next, _ := m.Update(saveResult{filename: filepath.Join(dir, "compose.yaml")})
if got := next.(Model).composeDir; got != dir {
t.Fatalf("composeDir = %q, want %q", got, dir)
}
}
Loading