Skip to content

Commit c89a2d5

Browse files
committed
windows fixes
1 parent 1477eab commit c89a2d5

File tree

7 files changed

+163
-144
lines changed

7 files changed

+163
-144
lines changed

docs/fixes/windows-atmos-d-and-toolchain-issues.md

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44

55
This document describes Windows-specific issues reported by users and the fixes applied.
66

7-
| Issue | Status | Description |
8-
|------------------------------------|--------------------|--------------------------------------------------|
9-
| `.atmos.d` auto-import | ✅ Verified Working | Configuration loading works correctly on Windows |
10-
| Toolchain binary `.exe` extension | ✅ Fixed | Binaries now installed with `.exe` extension |
11-
| Archive extraction `.exe` handling | ✅ Fixed | Files extracted correctly from archives |
12-
| Asset download URL `.exe` fallback | ✅ Fixed | Downloads now try `.exe` suffix on Windows |
13-
| PowerShell hint message | ✅ Fixed | Shows correct `Invoke-Expression` syntax |
7+
| Issue | Status | Description |
8+
|------------------------------------|--------------------|----------------------------------------------------------|
9+
| `.atmos.d` auto-import | ✅ Verified Working | Configuration loading works correctly on Windows |
10+
| Toolchain binary `.exe` extension | ✅ Fixed | Centralized function ensures `.exe` extension on Windows |
11+
| Archive extraction `.exe` handling | ✅ Fixed | Files extracted correctly from archives |
12+
| PowerShell hint message | ✅ Fixed | Shows correct `Invoke-Expression` syntax |
1413

1514
---
1615

@@ -24,28 +23,53 @@ After testing on Windows, the `.atmos.d` auto-import functionality works correct
2423

2524
Enhanced debug logging in `pkg/config/load.go`:
2625

27-
- Logs at **Debug** level when directories are found
28-
- Users can diagnose issues with `ATMOS_LOGS_LEVEL=Debug`
26+
- Logs at **Debug** level when directories are found.
27+
- Users can diagnose issues with `ATMOS_LOGS_LEVEL=Debug`.
2928

3029
---
3130

3231
## Issue #2: Toolchain Installation Failures
3332

3433
### Reported Problems
3534

36-
1. Binary installed without `.exe` extension - causes `terraform --version` to hang
37-
2. Archive extraction fails for tools like helm - looking for `windows-amd64/helm` instead of `windows-amd64/helm.exe`
38-
3. Asset download fails for tools like jq - URL missing `.exe` suffix
39-
4. Hint message shows Unix `eval` syntax instead of PowerShell `Invoke-Expression`
35+
1. Binary installed without `.exe` extension - causes `terraform --version` to hang.
36+
2. Archive extraction fails for tools like helm - looking for `windows-amd64/helm` instead of `windows-amd64/helm.exe`.
37+
3. Hint message shows Unix `eval` syntax instead of PowerShell `Invoke-Expression`.
38+
39+
### Architecture: Centralized Windows Extension Handling
40+
41+
Following [Aqua's Windows support approach](https://aquaproj.github.io/docs/reference/windows-support/), Windows executables need the `.exe` extension to be found by `os/exec.LookPath`. Rather than scattering `.exe` handling across multiple files, we use a single centralized function.
42+
43+
**Key design decisions:**
44+
45+
- **No download URL heuristics**: If a tool's download URL needs `.exe`, the registry should specify it correctly in the asset template. We don't append `.exe` to URLs as a fallback.
46+
- **Single utility function**: `EnsureWindowsExeExtension()` handles all Windows extension logic in one place.
47+
- **Registry-driven configuration**: Asset URLs and file names come from the registry definition, not runtime heuristics.
4048

4149
### Fixes Applied
4250

43-
| File | Fix |
44-
|------------------------------------|----------------------------------------------------|
45-
| `toolchain/installer/installer.go` | Append `.exe` to binary name on Windows |
46-
| `toolchain/installer/extract.go` | Try `.exe` extension when extracting from archives |
47-
| `toolchain/installer/download.go` | Try `.exe` suffix in download URL on Windows |
48-
| `toolchain/install_helpers.go` | Platform-aware hint message |
51+
| File | Fix |
52+
|------------------------------------|--------------------------------------------------------------|
53+
| `toolchain/installer/installer.go` | Added `EnsureWindowsExeExtension()` centralized function |
54+
| `toolchain/installer/installer.go` | Uses centralized function for binary naming |
55+
| `toolchain/installer/extract.go` | Uses centralized function when extracting from archives |
56+
| `toolchain/install_helpers.go` | Platform-aware hint message for PowerShell |
57+
58+
### Centralized Function
59+
60+
```go
61+
// EnsureWindowsExeExtension appends .exe to the binary name on Windows if not present.
62+
// This follows Aqua's behavior where executables need the .exe extension on Windows
63+
// to be found by os/exec.LookPath.
64+
func EnsureWindowsExeExtension(binaryName string) string {
65+
defer perf.Track(nil, "installer.EnsureWindowsExeExtension")()
66+
67+
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(binaryName), ".exe") {
68+
return binaryName + ".exe"
69+
}
70+
return binaryName
71+
}
72+
```
4973

5074
---
5175

@@ -63,6 +87,8 @@ go test ./toolchain/installer/... -v
6387

6488
Test file: `tests/toolchain_custom_commands_test.go`
6589

90+
Uses `testhelpers.NewAtmosRunner` for building and running atmos binary (shared infrastructure).
91+
6692
| Test | Description |
6793
|-------------------------------------------------------|----------------------------------------------------------------|
6894
| `TestToolchainCustomCommands_InstallAllTools` | Installs gum, k9s, helm, jq, tofu and verifies `.exe` binaries |

tests/toolchain_custom_commands_test.go

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"github.com/stretchr/testify/require"
1313

1414
"github.com/cloudposse/atmos/pkg/perf"
15+
"github.com/cloudposse/atmos/tests/testhelpers"
16+
"github.com/cloudposse/atmos/toolchain/installer"
1517
)
1618

1719
// TestToolchainCustomCommands_InstallAllTools verifies that all tools defined in
@@ -338,38 +340,36 @@ func TestToolchainCustomCommands_ExecuteWithDependencies(t *testing.T) {
338340
}
339341
}
340342

341-
// buildAtmosBinary builds the atmos binary and returns its path.
342-
// It builds to a temporary directory to avoid polluting the source tree.
343+
// getAtmosRunner returns a shared AtmosRunner for tests.
344+
// It uses a package-level runner to avoid rebuilding atmos for each test.
345+
var sharedRunner *testhelpers.AtmosRunner
346+
347+
// buildAtmosBinary builds the atmos binary using the shared AtmosRunner and returns its path.
343348
func buildAtmosBinary(t *testing.T) string {
344349
t.Helper()
345350

346-
// Get the repo root (4 levels up from fixtures/scenarios/toolchain-custom-commands).
347-
repoRoot, err := filepath.Abs("../../../..")
348-
require.NoError(t, err, "Failed to get repo root")
349-
350-
// Build to temp directory.
351-
tmpDir := t.TempDir()
352-
binaryName := "atmos"
353-
if runtime.GOOS == "windows" {
354-
binaryName = "atmos.exe"
351+
if sharedRunner == nil {
352+
sharedRunner = testhelpers.NewAtmosRunner("")
355353
}
356-
binaryPath := filepath.Join(tmpDir, binaryName)
357354

358-
// Build atmos.
359-
cmd := exec.Command("go", "build", "-o", binaryPath, ".")
360-
cmd.Dir = repoRoot
361-
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
362-
output, err := cmd.CombinedOutput()
363-
require.NoError(t, err, "Failed to build atmos: %s", string(output))
355+
err := sharedRunner.Build()
356+
require.NoError(t, err, "Failed to build atmos")
364357

365-
return binaryPath
358+
// Register cleanup only once.
359+
t.Cleanup(func() {
360+
if sharedRunner != nil {
361+
sharedRunner.Cleanup()
362+
sharedRunner = nil
363+
}
364+
})
365+
366+
return sharedRunner.BinaryPath()
366367
}
367368

368369
// getBinaryPath constructs the expected binary path for an installed tool.
369370
func getBinaryPath(toolsDir, owner, repo, version, binaryName string) string {
370-
if runtime.GOOS == "windows" && !strings.HasSuffix(binaryName, ".exe") {
371-
binaryName += ".exe"
372-
}
371+
// Use the centralized function for Windows .exe extension handling.
372+
binaryName = installer.EnsureWindowsExeExtension(binaryName)
373373
return filepath.Join(toolsDir, "bin", owner, repo, version, binaryName)
374374
}
375375

toolchain/exec_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/cloudposse/atmos/pkg/schema"
9+
"github.com/cloudposse/atmos/toolchain/installer"
910
)
1011

1112
// --- Fake Installer for testing ---.
@@ -88,7 +89,8 @@ func TestRunExecCommand_Success(t *testing.T) {
8889
t.Fatalf("expected no error, got %v", err)
8990
}
9091

91-
expected := filepath.Join(tempDir, ".tools", "bin", "hashicorp", "terraform", "1.13.1", "terraform")
92+
binaryName := installer.EnsureWindowsExeExtension("terraform")
93+
expected := filepath.Join(tempDir, ".tools", "bin", "hashicorp", "terraform", "1.13.1", binaryName)
9294
if calledExecPath != expected {
9395
t.Errorf("expected exec path %q, got %q", expected, calledExecPath)
9496
}

toolchain/installer/download.go

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"os"
1010
"path/filepath"
11-
"runtime"
1211
"strings"
1312

1413
log "github.com/charmbracelet/log"
@@ -177,41 +176,9 @@ func (i *Installer) tryFallbackVersion(tool *registry.Tool, version, assetURL st
177176
return assetPath, nil
178177
}
179178

180-
// On Windows, try appending .exe to the asset URL as a last resort.
181-
// Some tools (like jq) have Windows binaries with .exe suffix in the download URL.
182-
if runtime.GOOS == "windows" {
183-
return i.tryWindowsExeFallback(assetURL, fallbackURL)
184-
}
185-
186179
return "", fmt.Errorf("%w: tried %s and %s: %w", ErrHTTPRequest, assetURL, fallbackURL, err)
187180
}
188181

189-
// tryWindowsExeFallback attempts to download with .exe extension appended to the URL.
190-
// Some tools have Windows binaries named with .exe suffix (e.g., jq-windows-amd64.exe).
191-
func (i *Installer) tryWindowsExeFallback(assetURL, fallbackURL string) (string, error) {
192-
defer perf.Track(nil, "Installer.tryWindowsExeFallback")()
193-
194-
// Try original URL with .exe.
195-
exeURL := assetURL + ".exe"
196-
log.Debug("Trying Windows .exe fallback", "url", exeURL)
197-
assetPath, err := i.downloadAsset(exeURL)
198-
if err == nil {
199-
return assetPath, nil
200-
}
201-
202-
// Try fallback URL with .exe.
203-
if fallbackURL != "" && fallbackURL != assetURL {
204-
fallbackExeURL := fallbackURL + ".exe"
205-
log.Debug("Trying Windows .exe fallback for version fallback", "url", fallbackExeURL)
206-
assetPath, err = i.downloadAsset(fallbackExeURL)
207-
if err == nil {
208-
return assetPath, nil
209-
}
210-
}
211-
212-
return "", fmt.Errorf("%w: tried %s and %s: %w", ErrHTTPRequest, assetURL, fallbackURL, ErrHTTP404)
213-
}
214-
215182
// isHTTP404 returns true if the error is a 404 from downloadAsset.
216183
func isHTTP404(err error) bool {
217184
return errors.Is(err, ErrHTTP404)

toolchain/installer/extract.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,7 @@ func (i *Installer) extractFilesFromDir(tempDir, binaryPath string, tool *regist
247247
}
248248
src = srcWithExe
249249
// Also update dst to include .exe if it doesn't already.
250-
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(dst), ".exe") {
251-
dst += ".exe"
252-
}
250+
dst = EnsureWindowsExeExtension(dst)
253251
}
254252

255253
if err := MoveFile(src, dst); err != nil {

toolchain/installer/installer.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,23 @@ const (
3838
logFieldOwner = "owner"
3939
logFieldRepo = "repo"
4040
logFieldVersion = "version"
41+
42+
// Windows constants.
43+
windowsExeExt = ".exe"
4144
)
4245

46+
// EnsureWindowsExeExtension appends .exe to the binary name on Windows if not already present.
47+
// This follows Aqua's behavior where executables need the .exe extension on Windows
48+
// to be found by os/exec.LookPath.
49+
func EnsureWindowsExeExtension(binaryName string) string {
50+
defer perf.Track(nil, "installer.EnsureWindowsExeExtension")()
51+
52+
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(binaryName), windowsExeExt) {
53+
return binaryName + windowsExeExt
54+
}
55+
return binaryName
56+
}
57+
4358
// ToolResolver defines an interface for resolving tool names to owner/repo pairs
4459
// This allows for mocking in tests and flexible resolution in production.
4560
type ToolResolver interface {
@@ -447,10 +462,8 @@ func (i *Installer) extractAndInstall(tool *registry.Tool, assetPath, version st
447462
// Determine the binary name using shared resolution logic.
448463
binaryName := resolveBinaryName(tool)
449464

450-
// On Windows, executables need the .exe extension to be found by the shell.
451-
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(binaryName), ".exe") {
452-
binaryName += ".exe"
453-
}
465+
// Ensure Windows executables have .exe extension.
466+
binaryName = EnsureWindowsExeExtension(binaryName)
454467

455468
binaryPath := filepath.Join(versionDir, binaryName)
456469

0 commit comments

Comments
 (0)