Skip to content

Commit d165162

Browse files
committed
Introduce automatic root detection
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent b2e3d16 commit d165162

File tree

8 files changed

+716
-32
lines changed

8 files changed

+716
-32
lines changed

main.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,51 @@ func init() {
7777
for _, cmd := range commands.Commands {
7878
rootCmd.AddCommand(cmd)
7979
}
80+
81+
// Add PersistentPreRunE to handle root detection and config loading
82+
originalPersistentPreRunE := rootCmd.PersistentPreRunE
83+
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
84+
// Detect and set project root using fallback strategy
85+
if err := commands.DetectAndSetRoot(cmd, args); err != nil {
86+
return err
87+
}
88+
89+
// Load config after root detection (skip for init and completion commands)
90+
cmdName := cmd.Use
91+
if !strings.HasPrefix(cmdName, "init") && !strings.HasPrefix(cmdName, "completion") {
92+
configFile := filepath.Join(commands.Config.RootDir, "Chart.yaml")
93+
if err := loadConfig(configFile); err != nil {
94+
return fmt.Errorf("error loading configuration: %w", err)
95+
}
96+
}
97+
98+
// Ensure talosconfig path is set to project root if not explicitly set via flag
99+
// This is needed for all commands that use talosctl client (template, apply, etc.)
100+
if !cmd.PersistentFlags().Changed("talosconfig") {
101+
var talosconfigPath string
102+
if commands.GlobalArgs.Talosconfig != "" {
103+
// Use existing path from Chart.yaml or default
104+
talosconfigPath = commands.GlobalArgs.Talosconfig
105+
} else {
106+
// Use talosconfig from project root
107+
talosconfigPath = commands.Config.GlobalOptions.Talosconfig
108+
if talosconfigPath == "" {
109+
talosconfigPath = "talosconfig"
110+
}
111+
}
112+
// Make it absolute path relative to project root if it's relative
113+
if !filepath.IsAbs(talosconfigPath) {
114+
commands.GlobalArgs.Talosconfig = filepath.Join(commands.Config.RootDir, talosconfigPath)
115+
} else {
116+
commands.GlobalArgs.Talosconfig = talosconfigPath
117+
}
118+
}
119+
120+
if originalPersistentPreRunE != nil {
121+
return originalPersistentPreRunE(cmd, args)
122+
}
123+
return nil
124+
}
80125
}
81126

82127
func initConfig() {
@@ -88,20 +133,13 @@ func initConfig() {
88133
if cmd.HasParent() && cmd.Parent() != rootCmd {
89134
cmd = cmd.Parent()
90135
}
136+
91137
if strings.HasPrefix(cmd.Use, "init") {
92138
if strings.HasPrefix(Version, "v") {
93139
commands.Config.InitOptions.Version = strings.TrimPrefix(Version, `v`)
94140
} else {
95141
commands.Config.InitOptions.Version = "0.1.0"
96142
}
97-
} else {
98-
if !strings.HasPrefix(cmd.Use, "completion") {
99-
configFile := filepath.Join(commands.Config.RootDir, "Chart.yaml")
100-
if err := loadConfig(configFile); err != nil {
101-
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
102-
os.Exit(1)
103-
}
104-
}
105143
}
106144
}
107145

pkg/commands/apply.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"errors"
2121
"fmt"
2222
"os"
23+
"path/filepath"
2324
"time"
2425

2526
"github.com/cozystack/talm/pkg/engine"
@@ -93,14 +94,55 @@ var applyCmd = &cobra.Command{
9394

9495
func apply(args []string) func(ctx context.Context, c *client.Client) error {
9596
return func(ctx context.Context, c *client.Client) error {
97+
// Detect root from files if specified, otherwise fallback to cwd
98+
if len(applyCmdFlags.configFiles) > 0 {
99+
detectedRoot, err := ValidateAndDetectRootsForFiles(applyCmdFlags.configFiles)
100+
if err != nil {
101+
return err
102+
}
103+
if detectedRoot != "" {
104+
absConfigRoot, _ := filepath.Abs(Config.RootDir)
105+
absDetectedRoot, _ := filepath.Abs(detectedRoot)
106+
// Root from files has priority
107+
if absConfigRoot != absDetectedRoot {
108+
// If --root was explicitly set and differs from files root, error
109+
if Config.RootDirExplicit {
110+
return fmt.Errorf("conflicting project roots: global --root=%s, but files belong to root=%s", absConfigRoot, absDetectedRoot)
111+
}
112+
}
113+
// Use root from files (has priority)
114+
Config.RootDir = detectedRoot
115+
}
116+
} else {
117+
// Fallback: detect root from current working directory if not explicitly set
118+
if !Config.RootDirExplicit {
119+
currentDir, err := os.Getwd()
120+
if err == nil {
121+
detectedRoot, err := DetectProjectRoot(currentDir)
122+
if err == nil && detectedRoot != "" {
123+
Config.RootDir = detectedRoot
124+
}
125+
}
126+
}
127+
}
128+
96129
for _, configFile := range applyCmdFlags.configFiles {
97130
if err := processModelineAndUpdateGlobals(configFile, applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs, true); err != nil {
98131
return err
99132
}
100133

134+
// Resolve secrets.yaml path relative to project root if not absolute
135+
withSecretsPath := applyCmdFlags.withSecrets
136+
if withSecretsPath == "" {
137+
withSecretsPath = "secrets.yaml"
138+
}
139+
if !filepath.IsAbs(withSecretsPath) {
140+
withSecretsPath = filepath.Join(Config.RootDir, withSecretsPath)
141+
}
142+
101143
opts := engine.Options{
102144
TalosVersion: applyCmdFlags.talosVersion,
103-
WithSecrets: applyCmdFlags.withSecrets,
145+
WithSecrets: withSecretsPath,
104146
KubernetesVersion: applyCmdFlags.kubernetesVersion,
105147
Debug: applyCmdFlags.debug,
106148
}

pkg/commands/init.go

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ var initCmd = &cobra.Command{
304304
talosconfigFileExists := fileExists(talosconfigFile)
305305
encryptedTalosconfigFileExists := fileExists(encryptedTalosconfigFile)
306306

307-
// If encrypted file exists, decrypt it
307+
// If encrypted file exists, decrypt it (don't require key - will generate if needed)
308308
if encryptedTalosconfigFileExists && !talosconfigFileExists {
309-
if err := age.DecryptYAMLFile(Config.RootDir, "talosconfig.encrypted", "talosconfig"); err != nil {
310-
return fmt.Errorf("failed to decrypt talosconfig: %w", err)
309+
_, err := handleTalosconfigEncryption(false)
310+
if err != nil {
311+
// If decryption fails (e.g., no key), continue to generate
311312
}
312-
talosconfigFileExists = true
313+
talosconfigFileExists = fileExists(talosconfigFile)
313314
}
314315

315316
// Generate talosconfig only if it doesn't exist
@@ -331,22 +332,13 @@ var initCmd = &cobra.Command{
331332
talosconfigFileExists = true
332333
}
333334

334-
// If talosconfig exists but encrypted file doesn't, encrypt it
335-
if talosconfigFileExists && !encryptedTalosconfigFileExists {
336-
// Ensure key exists
337-
if !keyFileExists {
338-
_, keyCreated, err := age.GenerateKey(Config.RootDir)
339-
if err != nil {
340-
return fmt.Errorf("failed to generate key: %w", err)
341-
}
342-
keyFileExists = true // Update flag after creation
343-
keyWasCreated = keyCreated
344-
}
345-
346-
// Encrypt talosconfig
347-
if err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted"); err != nil {
348-
return fmt.Errorf("failed to encrypt talosconfig: %w", err)
349-
}
335+
// Encrypt talosconfig if needed
336+
talosKeyCreated, err := handleTalosconfigEncryption(false)
337+
if err != nil {
338+
return err
339+
}
340+
if talosKeyCreated {
341+
keyWasCreated = true
350342
}
351343

352344
// Handle kubeconfig encryption logic (check if kubeconfig exists from Chart.yaml)
@@ -627,6 +619,60 @@ func printSecretsWarning() {
627619
fmt.Fprintf(os.Stderr, "\n")
628620
}
629621

622+
// handleTalosconfigEncryption handles encryption/decryption logic for talosconfig file.
623+
// It decrypts if encrypted file exists, encrypts if plain file exists.
624+
// requireKeyForDecrypt: if true, returns error if key is missing when trying to decrypt.
625+
// Returns true if key was created during this call, false otherwise.
626+
func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) {
627+
talosconfigFile := filepath.Join(Config.RootDir, "talosconfig")
628+
encryptedTalosconfigFile := filepath.Join(Config.RootDir, "talosconfig.encrypted")
629+
talosconfigFileExists := fileExists(talosconfigFile)
630+
encryptedTalosconfigFileExists := fileExists(encryptedTalosconfigFile)
631+
keyFile := filepath.Join(Config.RootDir, "talm.key")
632+
keyFileExists := fileExists(keyFile)
633+
keyWasCreated := false
634+
635+
// If encrypted file exists, decrypt it
636+
if encryptedTalosconfigFileExists && !talosconfigFileExists {
637+
if !keyFileExists {
638+
if requireKeyForDecrypt {
639+
return false, fmt.Errorf("talosconfig.encrypted exists but talm.key is missing. Cannot decrypt without key")
640+
}
641+
// If key is not required, just return (don't decrypt)
642+
return false, nil
643+
}
644+
fmt.Fprintf(os.Stderr, "Decrypting talosconfig.encrypted -> talosconfig\n")
645+
if err := age.DecryptYAMLFile(Config.RootDir, "talosconfig.encrypted", "talosconfig"); err != nil {
646+
return false, fmt.Errorf("failed to decrypt talosconfig: %w", err)
647+
}
648+
talosconfigFileExists = true
649+
}
650+
651+
// If talosconfig exists but encrypted file doesn't, encrypt it
652+
if talosconfigFileExists && !encryptedTalosconfigFileExists {
653+
// Ensure key exists
654+
if !keyFileExists {
655+
_, keyCreated, err := age.GenerateKey(Config.RootDir)
656+
if err != nil {
657+
return false, fmt.Errorf("failed to generate key: %w", err)
658+
}
659+
keyWasCreated = keyCreated
660+
if keyCreated {
661+
fmt.Fprintf(os.Stderr, "Generated new encryption key: talm.key\n")
662+
}
663+
keyFileExists = true
664+
}
665+
666+
// Encrypt talosconfig
667+
fmt.Fprintf(os.Stderr, "Encrypting talosconfig -> talosconfig.encrypted\n")
668+
if err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted"); err != nil {
669+
return false, fmt.Errorf("failed to encrypt talosconfig: %w", err)
670+
}
671+
}
672+
673+
return keyWasCreated, nil
674+
}
675+
630676
func writeToDestination(data []byte, destination string, permissions os.FileMode) error {
631677
if err := validateFileExists(destination); err != nil {
632678
return err

pkg/commands/root.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"os"
22+
"path/filepath"
2123
"time"
2224

2325
"github.com/cozystack/talm/pkg/modeline"
@@ -35,6 +37,7 @@ var GlobalArgs global.Args
3537

3638
var Config struct {
3739
RootDir string
40+
RootDirExplicit bool // true if --root was explicitly set
3841
GlobalOptions struct {
3942
Talosconfig string `yaml:"talosconfig"`
4043
Kubeconfig string `yaml:"kubeconfig"`
@@ -112,6 +115,94 @@ func addCommand(cmd *cobra.Command) {
112115
Commands = append(Commands, cmd)
113116
}
114117

118+
// DetectProjectRoot automatically detects the project root directory by looking
119+
// for Chart.yaml and secrets.yaml files in the current directory and parent directories.
120+
// Returns the absolute path to the project root, or empty string if not found.
121+
func DetectProjectRoot(startDir string) (string, error) {
122+
absStartDir, err := filepath.Abs(startDir)
123+
if err != nil {
124+
return "", fmt.Errorf("failed to get absolute path: %w", err)
125+
}
126+
127+
currentDir := absStartDir
128+
for {
129+
chartYaml := filepath.Join(currentDir, "Chart.yaml")
130+
secretsYaml := filepath.Join(currentDir, "secrets.yaml")
131+
132+
chartExists := false
133+
secretsExists := false
134+
135+
if _, err := os.Stat(chartYaml); err == nil {
136+
chartExists = true
137+
}
138+
if _, err := os.Stat(secretsYaml); err == nil {
139+
secretsExists = true
140+
}
141+
142+
if chartExists && secretsExists {
143+
return currentDir, nil
144+
}
145+
146+
parentDir := filepath.Dir(currentDir)
147+
if parentDir == currentDir {
148+
// Reached filesystem root
149+
break
150+
}
151+
currentDir = parentDir
152+
}
153+
154+
return "", nil
155+
}
156+
157+
// DetectProjectRootForFile detects the project root for a given file path.
158+
// It finds the directory containing the file, then searches up for Chart.yaml and secrets.yaml.
159+
func DetectProjectRootForFile(filePath string) (string, error) {
160+
absFilePath, err := filepath.Abs(filePath)
161+
if err != nil {
162+
return "", fmt.Errorf("failed to get absolute path: %w", err)
163+
}
164+
165+
// Get directory containing the file
166+
fileDir := filepath.Dir(absFilePath)
167+
return DetectProjectRoot(fileDir)
168+
}
169+
170+
// ValidateAndDetectRootsForFiles validates that all files belong to the same project root.
171+
// Returns the common root directory and an error if files belong to different roots.
172+
func ValidateAndDetectRootsForFiles(filePaths []string) (string, error) {
173+
if len(filePaths) == 0 {
174+
return "", nil
175+
}
176+
177+
var commonRoot string
178+
roots := make(map[string]bool)
179+
180+
for _, filePath := range filePaths {
181+
fileRoot, err := DetectProjectRootForFile(filePath)
182+
if err != nil {
183+
return "", fmt.Errorf("failed to detect root for file %s: %w", filePath, err)
184+
}
185+
if fileRoot == "" {
186+
return "", fmt.Errorf("failed to detect project root for file %s (Chart.yaml and secrets.yaml not found)", filePath)
187+
}
188+
189+
roots[fileRoot] = true
190+
if commonRoot == "" {
191+
commonRoot = fileRoot
192+
} else if commonRoot != fileRoot {
193+
return "", fmt.Errorf("files belong to different project roots: %s and %s", commonRoot, fileRoot)
194+
}
195+
}
196+
197+
return commonRoot, nil
198+
}
199+
200+
// DetectRootForTemplate detects the project root for a template file path.
201+
// Similar to ValidateAndDetectRootsForFiles but for a single template file.
202+
func DetectRootForTemplate(templatePath string) (string, error) {
203+
return DetectProjectRootForFile(templatePath)
204+
}
205+
115206
func processModelineAndUpdateGlobals(configFile string, nodesFromArgs bool, endpointsFromArgs bool, owerwrite bool) error {
116207
modelineConfig, err := modeline.ReadAndParseModeline(configFile)
117208
if err != nil {

0 commit comments

Comments
 (0)