Skip to content

Commit a0d6a38

Browse files
authored
fix: support subpaths (#33)
Fix subpaths
1 parent 81ed630 commit a0d6a38

12 files changed

Lines changed: 590 additions & 41 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ venv
3131

3232
# Node
3333
node_modules/
34+
35+
# Git Crypt
36+
git-crypt-key

README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,40 @@ See [Bootstrap Reports documentation](docs/cli/bootstrap.md#bootstrap-reports) f
9191

9292
#### Repo content in a subdirectory
9393

94-
If your Kubernetes manifests live in a subdirectory (e.g. `k8s/`):
94+
If your Kubernetes manifests live in a subdirectory (e.g. `k8s/`), you need to configure both the CLI and values file:
9595

96+
1. **Update `apps/values.yaml`** to set the base path:
97+
```yaml
98+
repo:
99+
basePath: "k8s" # Set to your subdirectory name
100+
```
101+
102+
2. **Run bootstrap** using either method:
103+
104+
From repository root:
96105
```bash
97-
./cli/cluster-bootstrap --base-dir ./k8s bootstrap dev --app-path k8s/apps
106+
./k8s/cli/cluster-bootstrap --base-dir ./k8s bootstrap dev \
107+
--app-path k8s/apps \
108+
--wait-for-health -v
109+
```
110+
111+
Or from inside the subdirectory (both work):
112+
```bash
113+
cd k8s
114+
115+
# Relative path
116+
./cli/cluster-bootstrap bootstrap dev --app-path apps --wait-for-health -v
117+
118+
# Or full path
119+
./cli/cluster-bootstrap bootstrap dev --app-path k8s/apps --wait-for-health -v
98120
```
99121

100-
`--base-dir` resolves local file paths (Chart.yaml, values, secrets). `--app-path` sets the `spec.source.path` in the ArgoCD Application CR.
122+
**Key points:**
123+
- The CLI **automatically detects** if you're in a Git subdirectory
124+
- Works with both relative (`apps`) and full paths (`k8s/apps`)
125+
- Strips prefixes intelligently for local validation
126+
- `repo.basePath: "k8s"` in values.yaml ensures component paths include the subdirectory prefix
127+
- Choose whichever feels most natural to you!
101128

102129
### 4. Access ArgoCD UI
103130

apps/templates/application.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ spec:
1515
source:
1616
repoURL: {{ $.Values.repo.url }}
1717
targetRevision: {{ $.Values.repo.targetRevision }}
18-
path: components/{{ $name }}
18+
path: {{ if $.Values.repo.basePath }}{{ $.Values.repo.basePath }}/{{ end }}components/{{ $name }}
1919
{{- if or (not (hasKey $config "hasValues")) $config.hasValues }}
2020
helm:
2121
valueFiles:

apps/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ environment: dev
33
repo:
44
url: git@github.com:user-cube/cluster-bootstrap.git
55
targetRevision: main
6+
# basePath: Optional base path when the project is in a subfolder (e.g., "k8s")
7+
# Leave empty or omit if the project is at the repository root
8+
basePath: ""
69

710
components:
811
argocd:

cli/cmd/bootstrap.go

Lines changed: 148 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func init() {
5555
bootstrapCmd.Flags().StringVar(&bootstrapAgeKey, "age-key-file", "", "path to age private key file for SOPS decryption")
5656
bootstrapCmd.Flags().StringVar(&encryption, "encryption", "sops", "encryption backend (sops|git-crypt)")
5757
bootstrapCmd.Flags().StringVar(&gitcryptKeyFile, "gitcrypt-key-file", "", "path to git-crypt symmetric key file (creates K8s secret)")
58-
bootstrapCmd.Flags().StringVar(&appPath, "app-path", "apps", "path inside the Git repo for the App of Apps source")
58+
bootstrapCmd.Flags().StringVar(&appPath, "app-path", "apps", "path to App of Apps (relative to current dir when in subfolder, or full repo path with --base-dir)")
5959
bootstrapCmd.Flags().BoolVar(&waitForHealth, "wait-for-health", false, "wait for cluster components to be ready after bootstrap")
6060
bootstrapCmd.Flags().IntVar(&healthTimeout, "health-timeout", 180, "timeout in seconds for health checks (default 180)")
6161
bootstrapCmd.Flags().StringVar(&reportFormat, "report-format", "summary", "report format: summary, json, none")
@@ -74,11 +74,49 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
7474

7575
logger := NewLogger(verbose)
7676

77+
// Detect if we're running from a subdirectory and adjust paths accordingly
78+
var argoCDAppPath string
79+
var subfolderPath string
80+
81+
if baseDir == "." {
82+
// Check if we're in a subdirectory of a Git repository
83+
detected, relPath := detectGitSubdirectory()
84+
if detected && relPath != "" {
85+
subfolderPath = relPath
86+
87+
// Handle different appPath scenarios:
88+
// 1. appPath="apps" -> convert to "k8s/apps"
89+
// 2. appPath="k8s/apps" (user specified full path) -> strip to "apps" for local validation, keep "k8s/apps" for ArgoCD
90+
if strings.HasPrefix(appPath, relPath+"/") {
91+
// User provided full path (e.g., "k8s/apps" while in k8s/)
92+
// This is valid, keep it for ArgoCD
93+
argoCDAppPath = appPath
94+
if verbose {
95+
fmt.Printf(" 📁 Detected running from subdirectory: %s\n", relPath)
96+
fmt.Printf(" 📍 Using full path for ArgoCD: %s\n", argoCDAppPath)
97+
}
98+
} else {
99+
// User provided relative path (e.g., "apps")
100+
// Convert to full path for ArgoCD
101+
argoCDAppPath = relPath + "/" + appPath
102+
if verbose {
103+
fmt.Printf(" 📁 Detected running from subdirectory: %s\n", relPath)
104+
fmt.Printf(" 📍 Local path: %s -> ArgoCD path: %s\n", appPath, argoCDAppPath)
105+
}
106+
}
107+
} else {
108+
argoCDAppPath = appPath
109+
}
110+
} else {
111+
// baseDir is explicitly set, use the original logic
112+
argoCDAppPath = appPath
113+
}
114+
77115
// Initialize bootstrap report
78116
report := NewBootstrapReport(env)
79117
report.Configuration = ConfigReport{
80118
BaseDir: baseDir,
81-
AppPath: appPath,
119+
AppPath: argoCDAppPath,
82120
Encryption: encryption,
83121
SecretsFile: secretsFile,
84122
Kubeconfig: kubeconfig,
@@ -132,7 +170,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
132170

133171
// Validation
134172
validationTimer := startStage("Validation")
135-
if err := validateBootstrapInputs(env); err != nil {
173+
localAppPath, err := validateBootstrapInputs(env, argoCDAppPath)
174+
if err != nil {
136175
bootstrapErr = fmt.Errorf("validation failed: %w", err)
137176
report.AddStage(validationTimer.complete(false, err))
138177
return bootstrapErr
@@ -143,7 +182,13 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
143182
configStage := logger.Stage("Configuration")
144183
configStage.Detail("Environment: %s", env)
145184
configStage.Detail("Base directory: %s", baseDir)
146-
configStage.Detail("App path: %s", appPath)
185+
if subfolderPath != "" {
186+
configStage.Detail("Subfolder context: %s", subfolderPath)
187+
}
188+
configStage.Detail("App path (ArgoCD): %s", argoCDAppPath)
189+
if localAppPath != argoCDAppPath {
190+
configStage.Detail("App path (local): %s", localAppPath)
191+
}
147192
configStage.Detail("Encryption: %s", encryption)
148193
if kubeconfig != "" {
149194
configStage.Detail("Kubeconfig: %s", kubeconfig)
@@ -163,7 +208,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
163208
secretsTimer := startStage("Loading Secrets")
164209
secretsStage := logger.Stage("Loading Secrets")
165210
var envSecrets *config.EnvironmentSecrets
166-
var err error
167211

168212
var secretsPath string
169213
switch encryption {
@@ -227,7 +271,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
227271
}
228272

229273
if dryRun {
230-
bootstrapErr = printDryRun(envSecrets, env, appPath)
274+
bootstrapErr = printDryRun(envSecrets, env, argoCDAppPath)
231275
return bootstrapErr
232276
}
233277

@@ -348,7 +392,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
348392
appTimer := startStage("Deploying App of Apps")
349393
appStage := logger.Stage("Deploying App of Apps")
350394
stepf("Applying App of Apps for environment: %s", env)
351-
_, appCreated, err := client.ApplyAppOfApps(ctx, envSecrets.Repo.URL, envSecrets.Repo.TargetRevision, env, appPath, false)
395+
_, appCreated, err := client.ApplyAppOfApps(ctx, envSecrets.Repo.URL, envSecrets.Repo.TargetRevision, env, argoCDAppPath, false)
352396
if err != nil {
353397
bootstrapErr = err
354398
report.AddStage(appTimer.complete(false, err))
@@ -410,7 +454,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
410454
fmt.Println()
411455
successf("Done! ArgoCD is installed and the app-of-apps root Application has been created.")
412456
logger.PrintStageSummary()
413-
printBootstrapSummary(env, secretsPath)
457+
printBootstrapSummary(env, secretsPath, argoCDAppPath)
414458
fmt.Println(" Access the ArgoCD UI:")
415459
fmt.Println(" kubectl port-forward svc/argocd-server -n argocd 8080:443")
416460
fmt.Println(" Get the initial admin password:")
@@ -516,35 +560,70 @@ func buildDryRunObjects(envSecrets *config.EnvironmentSecrets, env, appPath stri
516560
return repoSecret, appOfApps
517561
}
518562

519-
func validateBootstrapInputs(env string) error {
563+
func validateBootstrapInputs(env string, argoCDAppPath string) (localPath string, err error) {
520564
if env == "" {
521-
return fmt.Errorf("environment is required")
565+
return "", fmt.Errorf("environment is required")
522566
}
523567

524-
baseInfo, err := os.Stat(baseDir)
525-
if err != nil {
526-
return fmt.Errorf("base-dir %s is not accessible: %w", baseDir, err)
568+
baseInfo, statErr := os.Stat(baseDir)
569+
if statErr != nil {
570+
return "", fmt.Errorf("base-dir %s is not accessible: %w", baseDir, statErr)
527571
}
528572
if !baseInfo.IsDir() {
529-
return fmt.Errorf("base-dir %s is not a directory", baseDir)
573+
return "", fmt.Errorf("base-dir %s is not a directory", baseDir)
530574
}
531575

532-
if filepath.IsAbs(appPath) {
533-
return fmt.Errorf("app-path must be relative to base-dir")
576+
if filepath.IsAbs(argoCDAppPath) {
577+
return "", fmt.Errorf("app-path must be relative")
578+
}
579+
580+
// Determine the local path to validate
581+
// The argoCDAppPath is the full path from repository root (e.g., "k8s/apps")
582+
// We need to determine what part to validate locally based on baseDir or current directory
583+
localAppPath := argoCDAppPath
584+
585+
if baseDir == "." {
586+
// Check if we're in a Git subdirectory
587+
detected, relPath := detectGitSubdirectory()
588+
if detected && relPath != "" && strings.HasPrefix(argoCDAppPath, relPath+"/") {
589+
// We're in a subdirectory and argoCDAppPath includes that prefix
590+
// Strip it for local validation
591+
// Example: In k8s/, argoCDAppPath="k8s/apps" -> localAppPath="apps"
592+
localAppPath = strings.TrimPrefix(argoCDAppPath, relPath+"/")
593+
}
594+
} else if baseDir != "." {
595+
// When baseDir is set (e.g., "./k8s"), we need to strip the matching prefix from argoCDAppPath
596+
// Example: baseDir="./k8s", argoCDAppPath="k8s/apps" -> localAppPath="apps"
597+
cleanBase := filepath.Clean(baseDir)
598+
baseComponents := strings.Split(cleanBase, string(filepath.Separator))
599+
pathComponents := strings.Split(argoCDAppPath, "/")
600+
601+
// Find the last component of baseDir (e.g., "k8s" from "./k8s")
602+
baseLastComponent := baseComponents[len(baseComponents)-1]
603+
604+
// If argoCDAppPath starts with the same component, strip it
605+
if len(pathComponents) > 0 && pathComponents[0] == baseLastComponent {
606+
// Strip the first component for local validation
607+
localAppPath = strings.Join(pathComponents[1:], "/")
608+
if localAppPath == "" {
609+
localAppPath = "."
610+
}
611+
}
534612
}
535-
appFullPath := filepath.Join(baseDir, appPath)
536-
if _, err := os.Stat(appFullPath); err != nil {
537-
if appPath == "apps" {
613+
614+
appFullPath := filepath.Join(baseDir, localAppPath)
615+
if _, statErr := os.Stat(appFullPath); statErr != nil {
616+
if argoCDAppPath == "apps" {
538617
detected, detectErr := autoDetectAppPath(baseDir)
539618
if detectErr != nil {
540-
return fmt.Errorf("app-path %s does not exist under base-dir: %w", appPath, err)
619+
return "", fmt.Errorf("app-path %s does not exist: %w\n hint: use --app-path to specify the full path from repository root (e.g., 'k8s/apps')", argoCDAppPath, statErr)
541620
}
542-
appPath = detected
621+
localAppPath = detected
543622
if verbose {
544-
fmt.Printf(" App path auto-detected: %s\n", appPath)
623+
fmt.Printf(" App path auto-detected: %s\n", localAppPath)
545624
}
546625
} else {
547-
return fmt.Errorf("app-path %s does not exist under base-dir: %w", appPath, err)
626+
return "", fmt.Errorf("app-path %s does not exist: %w\n hint: verify the path exists and try using --base-dir if working with subfolders", argoCDAppPath, statErr)
548627
}
549628
}
550629

@@ -554,16 +633,16 @@ func validateBootstrapInputs(env string) error {
554633
switch encryption {
555634
case "sops":
556635
if !isEnc {
557-
return fmt.Errorf("secrets-file must end with .enc.yaml when encryption is sops")
636+
return "", fmt.Errorf("secrets-file must end with .enc.yaml when encryption is sops")
558637
}
559638
case "git-crypt":
560639
if !isYaml || isEnc {
561-
return fmt.Errorf("secrets-file must end with .yaml (not .enc.yaml) when encryption is git-crypt")
640+
return "", fmt.Errorf("secrets-file must end with .yaml (not .enc.yaml) when encryption is git-crypt")
562641
}
563642
}
564643
}
565644

566-
return nil
645+
return localAppPath, nil
567646
}
568647

569648
func autoDetectAppPath(base string) (string, error) {
@@ -604,13 +683,13 @@ func autoDetectAppPath(base string) (string, error) {
604683
return candidates[0], nil
605684
}
606685

607-
func printBootstrapSummary(env, secretsPath string) {
686+
func printBootstrapSummary(env, secretsPath, displayAppPath string) {
608687
fmt.Println("\nSummary:")
609688
fmt.Printf(" Environment: %s\n", env)
610689
if secretsPath != "" {
611690
fmt.Printf(" Secrets file: %s\n", secretsPath)
612691
}
613-
fmt.Printf(" App path: %s\n", appPath)
692+
fmt.Printf(" App path: %s\n", displayAppPath)
614693
fmt.Printf(" Encryption: %s\n", encryption)
615694
if skipArgoCDInstall {
616695
fmt.Println(" ArgoCD install: skipped")
@@ -631,3 +710,44 @@ func validateSecretsFileExists(path string) error {
631710
}
632711
return nil
633712
}
713+
714+
// detectGitSubdirectory checks if we're running from a subdirectory of a Git repository
715+
// Returns (detected bool, relative path from repo root)
716+
func detectGitSubdirectory() (bool, string) {
717+
cwd, err := os.Getwd()
718+
if err != nil {
719+
return false, ""
720+
}
721+
722+
// Walk up the directory tree looking for .git
723+
dir := cwd
724+
for {
725+
gitPath := filepath.Join(dir, ".git")
726+
if _, err := os.Stat(gitPath); err == nil {
727+
// Found .git directory - this is the repo root
728+
if dir == cwd {
729+
// We're at the repo root
730+
return false, ""
731+
}
732+
733+
// Calculate relative path from repo root to current directory
734+
relPath, err := filepath.Rel(dir, cwd)
735+
if err != nil {
736+
return false, ""
737+
}
738+
739+
// Normalize path separators to forward slashes (for consistency with Git paths)
740+
relPath = filepath.ToSlash(relPath)
741+
742+
return true, relPath
743+
}
744+
745+
// Move up one directory
746+
parent := filepath.Dir(dir)
747+
if parent == dir {
748+
// Reached filesystem root without finding .git
749+
return false, ""
750+
}
751+
dir = parent
752+
}
753+
}

cli/cmd/bootstrap_test.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,20 @@ func TestValidateBootstrapInputs(t *testing.T) {
7373
encryption = "sops"
7474
secretsFile = filepath.Join(tmpDir, "secrets.dev.enc.yaml")
7575

76-
require.NoError(t, validateBootstrapInputs("dev"))
76+
_, err := validateBootstrapInputs("dev", "apps")
77+
require.NoError(t, err)
7778

7879
secretsFile = filepath.Join(tmpDir, "secrets.dev.yaml")
79-
assert.ErrorContains(t, validateBootstrapInputs("dev"), "must end with .enc.yaml")
80+
_, err = validateBootstrapInputs("dev", "apps")
81+
assert.ErrorContains(t, err, "must end with .enc.yaml")
8082

8183
encryption = "git-crypt"
8284
secretsFile = filepath.Join(tmpDir, "secrets.dev.enc.yaml")
83-
assert.ErrorContains(t, validateBootstrapInputs("dev"), "not .enc.yaml")
85+
_, err = validateBootstrapInputs("dev", "apps")
86+
assert.ErrorContains(t, err, "not .enc.yaml")
8487

85-
appPath = "/abs/path"
86-
assert.ErrorContains(t, validateBootstrapInputs("dev"), "app-path must be relative")
88+
_, err = validateBootstrapInputs("dev", "/abs/path")
89+
assert.ErrorContains(t, err, "app-path must be relative")
8790

8891
appPath = "apps"
8992
encryption = "sops"
@@ -92,6 +95,10 @@ func TestValidateBootstrapInputs(t *testing.T) {
9295
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "k8s", "apps", "templates"), 0755))
9396
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "k8s", "apps", "Chart.yaml"), []byte("apiVersion: v2\n"), 0644))
9497
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "k8s", "apps", "templates", "application.yaml"), []byte("kind: Application\n"), 0644))
95-
require.NoError(t, validateBootstrapInputs("dev"))
96-
assert.Equal(t, filepath.Join("k8s", "apps"), appPath)
98+
99+
// Test with baseDir pointing to k8s subfolder (simulating --base-dir ./k8s)
100+
baseDir = filepath.Join(tmpDir, "k8s")
101+
localPath, err := validateBootstrapInputs("dev", "k8s/apps")
102+
require.NoError(t, err)
103+
assert.Equal(t, "apps", localPath)
97104
}

0 commit comments

Comments
 (0)