Skip to content

Commit f17d531

Browse files
create XDG directories prior to use (#486)
Signed-off-by: Adrian Cole <[email protected]>
1 parent 2f26b21 commit f17d531

File tree

7 files changed

+265
-16
lines changed

7 files changed

+265
-16
lines changed

internal/cmd/use.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ $ func-e use %s`, currentVersionWorkingDirFile, currentVersionConfigFile, versio
4444
return err
4545
},
4646
Action: func(c *cli.Context) (err error) {
47+
// Create base XDG directories before any file operations
48+
if err = o.Mkdirs(); err != nil {
49+
return err
50+
}
4751
// The argument could be a MinorVersion (ex. 1.19) or a PatchVersion (ex. 1.19.3)
4852
// We need to download and install a patch version
4953
if o.EnvoyVersion, err = runtime.EnsurePatchVersion(c.Context, o, v); err != nil {

internal/cmd/which.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ func NewWhichCmd(o *globals.GlobalOpts) *cli.Command {
2525
return runtime.EnsureEnvoyVersion(c.Context, o)
2626
},
2727
Action: func(c *cli.Context) error {
28+
// Create base XDG directories before any file operations
29+
if err := o.Mkdirs(); err != nil {
30+
return err
31+
}
2832
ev, err := envoy.InstallIfNeeded(c.Context, o)
2933
if err != nil {
3034
return err

internal/envoy/version.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ const (
2323
// if the former is present.
2424
func WriteCurrentVersion(v version.Version, configHome, versionFilePath string) error {
2525
if _, err := os.Stat(".envoy-version"); os.IsNotExist(err) {
26-
if e := os.MkdirAll(configHome, 0o750); e != nil {
27-
return e
28-
}
2926
return os.WriteFile(versionFilePath, []byte(v.String()), 0o600)
3027
} else if err != nil {
3128
return err

internal/globals/globals.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package globals
66
import (
77
"fmt"
88
"io"
9+
"os"
910
"runtime"
1011
"strings"
1112

@@ -83,6 +84,45 @@ func (o *GlobalOpts) Logf(format string, a ...interface{}) {
8384
fmt.Fprintf(o.Out, format, a...) //nolint:errcheck
8485
}
8586

87+
// Mkdirs creates XDG Base Directory directories needed by func-e.
88+
// Only creates directories that will actually be used:
89+
// - ConfigHome and DataHome are always created (for version files and binaries)
90+
// - Per-run directories are only created when RunDir is set (intermediate dirs created automatically)
91+
// Permissions follow XDG spec: RuntimeDir uses 0700, others use 0750.
92+
func (o *GlobalOpts) Mkdirs() error {
93+
// Base directories always needed (for version files and binary installation)
94+
dirs := []struct {
95+
path string
96+
perm os.FileMode
97+
}{
98+
{o.ConfigHome, 0o750},
99+
{o.DataHome, 0o750},
100+
{o.EnvoyVersionsDir(), 0o750},
101+
}
102+
103+
// Per-run directories (only when actually running Envoy)
104+
// os.MkdirAll creates intermediate directories automatically
105+
if o.RunDir != "" {
106+
dirs = append(dirs,
107+
struct {
108+
path string
109+
perm os.FileMode
110+
}{o.RunDir, 0o750},
111+
struct {
112+
path string
113+
perm os.FileMode
114+
}{o.RunOpts.RuntimeDir, 0o700}, // Use embedded RunOpts.RuntimeDir, not base RuntimeDir
115+
)
116+
}
117+
118+
for _, d := range dirs {
119+
if err := os.MkdirAll(d.path, d.perm); err != nil {
120+
return fmt.Errorf("unable to create directory %q: %w", d.path, err)
121+
}
122+
}
123+
return nil
124+
}
125+
86126
const (
87127
// DefaultEnvoyVersionsURL is the default value for GlobalOpts.EnvoyVersionsURL
88128
DefaultEnvoyVersionsURL = "https://archive.tetratelabs.io/envoy/envoy-versions.json"

internal/globals/globals_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright 2025 Tetrate
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package globals
5+
6+
import (
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestGlobalOpts_Mkdirs(t *testing.T) {
16+
testCases := []struct {
17+
name string
18+
initialDirs map[string]fs.FileMode // directories to create before calling Mkdirs (empty means create none)
19+
runID string // empty means no per-run directories
20+
expectedError string
21+
expectedDirs map[string]fs.FileMode // directories that should exist with correct perms after Mkdirs
22+
}{
23+
{
24+
name: "creates only base directories when no runID",
25+
initialDirs: map[string]fs.FileMode{}, // nothing exists
26+
runID: "", // no per-run directories
27+
expectedError: "",
28+
expectedDirs: map[string]fs.FileMode{
29+
"config": 0o750,
30+
"data": 0o750,
31+
"data/envoy-versions": 0o750,
32+
// StateHome, RuntimeDir NOT created when no runID
33+
},
34+
},
35+
{
36+
name: "creates missing directories when some exist",
37+
initialDirs: map[string]fs.FileMode{
38+
"config": 0o755,
39+
"data": 0o755,
40+
},
41+
runID: "",
42+
expectedError: "",
43+
expectedDirs: map[string]fs.FileMode{
44+
"data/envoy-versions": 0o750,
45+
},
46+
},
47+
{
48+
name: "idempotent when all directories exist",
49+
initialDirs: map[string]fs.FileMode{
50+
"config": 0o755,
51+
"data": 0o755,
52+
"data/envoy-versions": 0o755,
53+
},
54+
runID: "",
55+
expectedError: "",
56+
expectedDirs: map[string]fs.FileMode{}, // All pre-exist, just verify no errors
57+
},
58+
{
59+
name: "creates per-run directories when runID is set",
60+
initialDirs: map[string]fs.FileMode{},
61+
runID: "20250413_123045_001",
62+
expectedError: "",
63+
expectedDirs: map[string]fs.FileMode{
64+
"config": 0o750,
65+
"data": 0o750,
66+
"data/envoy-versions": 0o750,
67+
"state/envoy-runs/20250413_123045_001": 0o750, // Leaf per-run dir
68+
"runtime/20250413_123045_001": 0o700, // Leaf per-run dir
69+
},
70+
},
71+
{
72+
name: "creates per-run directories when some base directories exist",
73+
initialDirs: map[string]fs.FileMode{
74+
"config": 0o755,
75+
},
76+
runID: "20250413_123045_002",
77+
expectedError: "",
78+
expectedDirs: map[string]fs.FileMode{
79+
"data": 0o750,
80+
"data/envoy-versions": 0o750,
81+
"state/envoy-runs/20250413_123045_002": 0o750,
82+
"runtime/20250413_123045_002": 0o700,
83+
},
84+
},
85+
}
86+
87+
for _, tc := range testCases {
88+
t.Run(tc.name, func(t *testing.T) {
89+
// Each test gets its own isolated temp directory
90+
baseDir := t.TempDir()
91+
92+
configHome := filepath.Join(baseDir, "config")
93+
dataHome := filepath.Join(baseDir, "data")
94+
stateHome := filepath.Join(baseDir, "state")
95+
runtimeDir := filepath.Join(baseDir, "runtime")
96+
97+
// Pre-create directories specified in test case
98+
for dir, perm := range tc.initialDirs {
99+
fullPath := filepath.Join(baseDir, dir)
100+
require.NoError(t, os.MkdirAll(fullPath, perm))
101+
}
102+
103+
// Set up GlobalOpts
104+
o := &GlobalOpts{
105+
ConfigHome: configHome,
106+
DataHome: dataHome,
107+
StateHome: stateHome,
108+
RuntimeDir: runtimeDir,
109+
}
110+
111+
// Set up per-run directories if runID is specified
112+
if tc.runID != "" {
113+
o.RunID = tc.runID
114+
o.RunDir = o.EnvoyRunDir(tc.runID)
115+
o.RuntimeDir = o.EnvoyRuntimeDir(tc.runID)
116+
}
117+
118+
// Call Mkdirs
119+
err := o.Mkdirs()
120+
121+
// Check error expectation
122+
if tc.expectedError != "" {
123+
require.Error(t, err)
124+
require.EqualError(t, err, tc.expectedError)
125+
return
126+
}
127+
require.NoError(t, err)
128+
129+
// Verify expected directories exist with correct permissions
130+
for dir, expectedPerm := range tc.expectedDirs {
131+
fullPath := filepath.Join(baseDir, dir)
132+
require.DirExists(t, fullPath, "directory %q should exist", dir)
133+
134+
info, err := os.Stat(fullPath)
135+
require.NoError(t, err, "should be able to stat directory %q", dir)
136+
actualPerm := info.Mode().Perm()
137+
require.Equal(t, expectedPerm, actualPerm, "directory %q should have permissions %o, got %o", dir, expectedPerm, actualPerm)
138+
}
139+
140+
// Verify idempotency - calling Mkdirs again should not error
141+
err = o.Mkdirs()
142+
require.NoError(t, err, "Mkdirs should be idempotent")
143+
})
144+
}
145+
}
146+
147+
func TestGlobalOpts_Mkdirs_RuntimeDirPermissions(t *testing.T) {
148+
// Specific test to verify XDG spec compliance: per-run RuntimeDir must be 0700
149+
baseDir := t.TempDir()
150+
151+
o := &GlobalOpts{
152+
ConfigHome: filepath.Join(baseDir, "config"),
153+
DataHome: filepath.Join(baseDir, "data"),
154+
StateHome: filepath.Join(baseDir, "state"),
155+
RuntimeDir: filepath.Join(baseDir, "runtime"),
156+
}
157+
158+
// Without runID set, RuntimeDir is NOT created
159+
require.NoError(t, o.Mkdirs())
160+
_, err := os.Stat(o.RuntimeDir)
161+
require.True(t, os.IsNotExist(err), "RuntimeDir should not exist when no runID set")
162+
163+
// Verify base directories have 0750 permissions
164+
for _, dir := range []string{o.ConfigHome, o.DataHome} {
165+
info, err := os.Stat(dir)
166+
require.NoError(t, err)
167+
require.Equal(t, os.FileMode(0o750), info.Mode().Perm(), "directory %q should have 0750 permissions", dir)
168+
}
169+
}
170+
171+
func TestGlobalOpts_Mkdirs_PerRunRuntimeDirPermissions(t *testing.T) {
172+
// Verify per-run RuntimeDir has 0700 permissions per XDG spec
173+
baseDir := t.TempDir()
174+
175+
o := &GlobalOpts{
176+
ConfigHome: filepath.Join(baseDir, "config"),
177+
DataHome: filepath.Join(baseDir, "data"),
178+
StateHome: filepath.Join(baseDir, "state"),
179+
RuntimeDir: filepath.Join(baseDir, "runtime"),
180+
}
181+
182+
runID := "20250413_123045_999"
183+
o.RunID = runID
184+
o.RunID = runID
185+
o.RunDir = o.EnvoyRunDir(runID)
186+
o.RuntimeDir = o.EnvoyRuntimeDir(runID)
187+
188+
require.NoError(t, o.Mkdirs())
189+
190+
// Verify per-run RuntimeDir has 0700 permissions (XDG spec requirement)
191+
info, err := os.Stat(o.RuntimeDir)
192+
require.NoError(t, err)
193+
require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), "per-run RuntimeDir must have 0700 permissions per XDG spec")
194+
195+
// Verify per-run RunDir has 0750 permissions
196+
info, err = os.Stat(o.RunDir)
197+
require.NoError(t, err)
198+
require.Equal(t, os.FileMode(0o750), info.Mode().Perm(), "per-run RunDir should have 0750 permissions")
199+
}

internal/runtime/opts.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ func InitializeGlobalOpts(o *globals.GlobalOpts, envoyVersionsURL, homeDir, conf
7979
if o.GetEnvoyVersions == nil { // not overridden for tests
8080
o.GetEnvoyVersions = envoy.NewGetVersions(o.EnvoyVersionsURL, o.Platform, o.Version)
8181
}
82-
return nil
82+
83+
// Create base XDG directories now that all paths are configured
84+
return o.Mkdirs()
8385
}
8486

8587
func getPlatform(platform string) version.Platform {

internal/runtime/run.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,28 +107,31 @@ func setEnvoyVersion(ctx context.Context, o *globals.GlobalOpts) (err error) {
107107
// initializeRunOpts initializes the api options
108108
func initializeRunOpts(ctx context.Context, o *globals.GlobalOpts) error {
109109
runOpts := &o.RunOpts
110-
if o.EnvoyPath == "" { // not overridden for tests
111-
envoyPath, err := envoy.InstallIfNeeded(ctx, o)
112-
if err != nil {
113-
return err
114-
}
115-
o.EnvoyPath = envoyPath
116-
}
117110

118111
// Set up directories using pre-generated runID
119112
if runOpts.RunDir == "" { // not overridden for tests
120113
runOpts.RunDir = o.EnvoyRunDir(o.RunID)
114+
}
115+
if runOpts.RuntimeDir == "" { // not overridden for tests
121116
runOpts.RuntimeDir = o.EnvoyRuntimeDir(o.RunID)
117+
}
118+
if runOpts.RunID == "" { // not overridden for tests
122119
runOpts.RunID = o.RunID
123120
}
124121

125-
// Eagerly create the run and runtime dirs so that errors raise early
126-
if err := os.MkdirAll(runOpts.RunDir, 0o750); err != nil {
127-
return fmt.Errorf("validation error: unable to create run directory %q, so we cannot run envoy", runOpts.RunDir)
122+
// Create all XDG directories now that runID is finalized
123+
if err := o.Mkdirs(); err != nil {
124+
return err
128125
}
129-
if err := os.MkdirAll(runOpts.RuntimeDir, 0o750); err != nil {
130-
return fmt.Errorf("validation error: unable to create runtime directory %q, so we cannot run envoy", runOpts.RuntimeDir)
126+
127+
if o.EnvoyPath == "" { // not overridden for tests
128+
envoyPath, err := envoy.InstallIfNeeded(ctx, o)
129+
if err != nil {
130+
return err
131+
}
132+
o.EnvoyPath = envoyPath
131133
}
134+
132135
return nil
133136
}
134137

0 commit comments

Comments
 (0)