-
-
Notifications
You must be signed in to change notification settings - Fork 153
Expand file tree
/
Copy pathload.go
More file actions
1423 lines (1218 loc) · 48.6 KB
/
load.go
File metadata and controls
1423 lines (1218 loc) · 48.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package config
import (
"bytes"
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/auth/provisioning"
"github.com/cloudposse/atmos/pkg/config/casemap"
"github.com/cloudposse/atmos/pkg/env"
"github.com/cloudposse/atmos/pkg/filesystem"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui"
u "github.com/cloudposse/atmos/pkg/utils"
"github.com/cloudposse/atmos/pkg/version"
"github.com/cloudposse/atmos/pkg/xdg"
)
//go:embed atmos.yaml
var embeddedConfigData []byte
const (
// MaximumImportLvL defines the maximum import level allowed.
MaximumImportLvL = 10
// CommandsKey is the configuration key for commands.
commandsKey = "commands"
// YamlType is the configuration file type.
yamlType = "yaml"
)
var defaultHomeDirProvider = filesystem.NewOSHomeDirProvider()
// mergedConfigFiles tracks all config files merged during a LoadConfig call.
// This is used to extract case-sensitive map keys from all sources, not just the main config.
// The slice is reset at the start of each LoadConfig call.
//
// NOTE: This package-level state assumes sequential (non-concurrent) calls to LoadConfig.
// LoadConfig is NOT safe for concurrent use. If concurrent config loading becomes necessary,
// this should be refactored to pass state through a context or options struct.
var mergedConfigFiles []string
// resetMergedConfigFiles clears the tracked config files. Call at start of LoadConfig.
func resetMergedConfigFiles() {
mergedConfigFiles = nil
}
// trackMergedConfigFile records a config file path for case-sensitive key extraction.
func trackMergedConfigFile(path string) {
if path != "" {
mergedConfigFiles = append(mergedConfigFiles, path)
}
}
const (
profileKey = "profile"
profileDelimiter = ","
// AtmosCliConfigPathEnvVar is the environment variable name for CLI config path.
AtmosCliConfigPathEnvVar = "ATMOS_CLI_CONFIG_PATH"
)
// parseProfilesFromOsArgs parses --profile flags from os.Args using pflag.
// This is a fallback for commands with DisableFlagParsing=true (terraform, helmfile, packer).
// Uses pflag's StringSlice parser to handle all syntax variations correctly.
func parseProfilesFromOsArgs(args []string) []string {
// Create temporary FlagSet just for parsing --profile.
fs := pflag.NewFlagSet("profile-parser", pflag.ContinueOnError)
fs.ParseErrorsAllowlist.UnknownFlags = true // Ignore other flags.
// Register profile flag using pflag's StringSlice (handles comma-separated values).
profiles := fs.StringSlice(profileKey, []string{}, "Configuration profiles")
// Parse args - pflag handles both --profile=value and --profile value syntax.
_ = fs.Parse(args) // Ignore errors from unknown flags.
if profiles == nil || len(*profiles) == 0 {
return nil
}
// Post-process: trim whitespace and filter empty values (maintains compatibility with manual parsing).
result := make([]string, 0, len(*profiles))
for _, profile := range *profiles {
trimmed := strings.TrimSpace(profile)
if trimmed != "" {
result = append(result, trimmed)
}
}
if len(result) == 0 {
return nil
}
return result
}
// parseViperProfilesFromEnv handles Viper's quirky environment variable parsing for StringSlice.
// Viper does NOT parse comma-separated environment variables correctly:
// - "dev,staging,prod" → []string{"dev,staging,prod"} (single element, NOT split)
// - "dev staging prod" → []string{"dev", "staging", "prod"} (splits on whitespace)
// - " dev , staging " → []string{"dev", ",", "staging"} (splits on whitespace, keeps commas!)
func parseViperProfilesFromEnv(profiles []string) []string {
var parsed []string
for _, p := range profiles {
trimmed := strings.TrimSpace(p)
// Skip empty strings and standalone commas (from Viper's whitespace split).
if trimmed == "" || trimmed == "," {
continue
}
// If this element contains commas, split it further.
if strings.Contains(trimmed, ",") {
for _, part := range strings.Split(trimmed, ",") {
if partTrimmed := strings.TrimSpace(part); partTrimmed != "" {
parsed = append(parsed, partTrimmed)
}
}
} else {
// No commas, use as-is.
parsed = append(parsed, trimmed)
}
}
return parsed
}
// parseProfilesFromEnvString parses comma-separated profiles from an environment variable value.
// Trims whitespace and filters empty entries.
func parseProfilesFromEnvString(envValue string) []string {
var result []string
for _, v := range strings.Split(envValue, profileDelimiter) {
if trimmed := strings.TrimSpace(v); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// getProfilesFromFallbacks handles fallback profile loading when Viper doesn't have profiles set.
// Returns profiles and source ("flag" or "env") for logging.
func getProfilesFromFallbacks() ([]string, string) {
// Fallback: For commands with DisableFlagParsing=true, Cobra never parses flags,
// so Viper won't have flag values. Manually parse os.Args as fallback.
profiles := parseProfilesFromOsArgs(os.Args)
if len(profiles) > 0 {
return profiles, "flag"
}
// Check environment variable directly as final fallback.
if envProfiles := os.Getenv("ATMOS_PROFILE"); envProfiles != "" { //nolint:forbidigo
result := parseProfilesFromEnvString(envProfiles)
if len(result) > 0 {
return result, "env"
}
}
return nil, ""
}
// getProfilesFromFlagsOrEnv retrieves profiles from --profile flag or ATMOS_PROFILE env var.
// This is a helper function to reduce nesting complexity in LoadConfig.
// Returns profiles and source ("env" or "flag") for logging.
//
// NOTE: This function reads from Viper's global singleton, which has flag values synced
// by syncGlobalFlagsToViper() in cmd/root.go PersistentPreRun before InitCliConfig is called.
//
// IMPORTANT: For commands with DisableFlagParsing=true (terraform, helmfile, packer),
// Cobra never parses flags, so we fall back to parseProfilesFromOsArgs() to manually
// parse the --profile flag from os.Args. This ensures profiles work for all commands.
func getProfilesFromFlagsOrEnv() ([]string, string) {
globalViper := viper.GetViper()
// Check if profile is set in Viper (from either flag or env var).
if !globalViper.IsSet(profileKey) {
return getProfilesFromFallbacks()
}
profiles := globalViper.GetStringSlice(profileKey)
_, envSet := os.LookupEnv("ATMOS_PROFILE")
// Environment variable path - needs special parsing for Viper quirks.
if envSet && len(profiles) > 0 {
parsed := parseViperProfilesFromEnv(profiles)
if len(parsed) > 0 {
return parsed, "env"
}
return nil, ""
}
// CLI flag path - already parsed correctly by pflag/Cobra.
if len(profiles) > 0 {
return profiles, "flag"
}
return nil, ""
}
// LoadConfig loads the Atmos configuration from multiple sources in order of precedence:
// * Embedded atmos.yaml (`atmos/pkg/config/atmos.yaml`)
// * System dir (`/usr/local/etc/atmos` on Linux, `%LOCALAPPDATA%/atmos` on Windows).
// * Home directory (~/.atmos).
// * Current working directory.
// * ENV vars.
// * Command-line arguments.
//
// NOTE: Global flags (like --profile) must be synced to Viper before calling this function.
// This is done by syncGlobalFlagsToViper() in cmd/root.go PersistentPreRun.
func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosConfiguration, error) {
// Reset merged config file tracker at start of each LoadConfig call.
resetMergedConfigFiles()
v := viper.New()
var atmosConfig schema.AtmosConfiguration
v.SetConfigType("yaml")
v.SetTypeByDefaultValue(true)
setDefaultConfiguration(v)
// Load embed atmos.yaml
if err := loadEmbeddedConfig(v); err != nil {
return atmosConfig, err
}
if len(configAndStacksInfo.AtmosConfigFilesFromArg) > 0 || len(configAndStacksInfo.AtmosConfigDirsFromArg) > 0 {
err := loadConfigFromCLIArgs(v, configAndStacksInfo, &atmosConfig)
if err != nil {
return atmosConfig, err
}
return atmosConfig, nil
}
// Load configuration from different sources.
if err := loadConfigSources(v, configAndStacksInfo); err != nil {
return atmosConfig, err
}
// If no config file is used, fall back to the default CLI config.
if v.ConfigFileUsed() == "" {
log.Debug("'atmos.yaml' CLI config was not found", "paths", "system dir, home dir, current dir, parent dirs, ENV vars")
log.Debug("Refer to https://atmos.tools/cli/configuration for details on how to configure 'atmos.yaml'")
log.Debug("Using the default CLI config")
if err := mergeDefaultConfig(v); err != nil {
return atmosConfig, err
}
// Also search git root for .atmos.d even with default config.
// This enables custom commands defined in .atmos.d at the repo root
// to work when running from any subdirectory.
gitRoot, err := u.ProcessTagGitRoot("!repo-root .")
if err == nil && gitRoot != "" && gitRoot != "." {
log.Debug("Loading .atmos.d from git root", "path", gitRoot)
if err := mergeDefaultImports(gitRoot, v); err != nil {
log.Trace("Failed to load .atmos.d from git root", "path", gitRoot, "error", err)
// Non-fatal: continue with default config.
}
}
}
if v.ConfigFileUsed() != "" {
// get dir of atmosConfigFilePath
atmosConfigDir := filepath.Dir(v.ConfigFileUsed())
atmosConfig.CliConfigPath = atmosConfigDir
// Set the CLI config path in the atmosConfig struct
if !filepath.IsAbs(atmosConfig.CliConfigPath) {
absPath, err := filepath.Abs(atmosConfig.CliConfigPath)
if err != nil {
return atmosConfig, err
}
atmosConfig.CliConfigPath = absPath
}
}
setEnv(v)
// Early .env file loading: MUST happen before profile detection.
// This allows ATMOS_PROFILE and other ATMOS_* vars in .env files to influence Atmos behavior.
// The base path may not be fully resolved yet, so we use what's available from config.
loadEnvFilesEarly(v, v.GetString("base_path"))
// Load profiles if specified via --profile flag or ATMOS_PROFILE env var.
// Profiles are loaded after base config but before final unmarshaling.
// This allows profiles to override base config settings.
// If profiles weren't passed via ConfigAndStacksInfo, check if they were
// specified via --profile flag or ATMOS_PROFILE env var.
// Note: Global flags are bound to viper.GetViper() (global singleton), not the local viper instance.
if len(configAndStacksInfo.ProfilesFromArg) == 0 {
profiles, source := getProfilesFromFlagsOrEnv()
if len(profiles) > 0 {
configAndStacksInfo.ProfilesFromArg = profiles
log.Debug("Profiles loaded from CLI "+source, "profiles", profiles)
}
}
if len(configAndStacksInfo.ProfilesFromArg) > 0 {
// First, do a temporary unmarshal to get CliConfigPath and Profiles config.
// We need these to discover and load profile directories.
var tempConfig schema.AtmosConfiguration
if err := v.Unmarshal(&tempConfig, atmosDecodeHook()); err != nil {
return atmosConfig, err
}
// Copy the already-computed CLI config directory into tempConfig.
// This ensures relative profile paths resolve against the actual CLI config directory
// rather than the current working directory.
tempConfig.CliConfigPath = atmosConfig.CliConfigPath
// Load each profile in order (left-to-right precedence).
if err := loadProfiles(v, configAndStacksInfo.ProfilesFromArg, &tempConfig); err != nil {
return atmosConfig, err
}
log.Debug("Profiles loaded successfully",
"profiles", configAndStacksInfo.ProfilesFromArg,
"count", len(configAndStacksInfo.ProfilesFromArg))
}
// https://gist.github.com/chazcheadle/45bf85b793dea2b71bd05ebaa3c28644
// https://sagikazarmark.hu/blog/decoding-custom-formats-with-viper/
err := v.Unmarshal(&atmosConfig, atmosDecodeHook())
if err != nil {
return atmosConfig, err
}
// Manually extract top-level env fields to avoid mapstructure tag collision.
// Both AtmosConfiguration.Env and Command.Env use "env" but with different types
// (map[string]string vs []CommandEnv), causing mapstructure to silently drop Commands.
// Using mapstructure:"-" on the Env fields and extracting manually here fixes this.
//
// The env section supports two forms:
// 1. Structured: env.vars (map) + env.files (EnvFilesConfig)
// 2. Flat (legacy): env as direct key-value map
parseEnvConfig(v, &atmosConfig)
if envMap := v.GetStringMapString("templates.settings.env"); len(envMap) > 0 {
atmosConfig.Templates.Settings.Env = envMap
}
// Post-process to preserve case-sensitive map keys.
// Viper lowercases all YAML map keys, but we need to preserve original case
// for identity names and environment variables.
preserveCaseSensitiveMaps(v, &atmosConfig)
// Apply git root discovery for default base path.
// This enables running Atmos from any subdirectory, similar to Git.
if err := applyGitRootBasePath(&atmosConfig); err != nil {
log.Debug("Failed to apply git root base path", "error", err)
// Don't fail config loading if this step fails, just log it.
}
return atmosConfig, nil
}
func setEnv(v *viper.Viper) {
// Base path configuration.
bindEnv(v, "base_path", "ATMOS_BASE_PATH")
// Terraform plugin cache configuration.
bindEnv(v, "components.terraform.plugin_cache", "ATMOS_COMPONENTS_TERRAFORM_PLUGIN_CACHE")
bindEnv(v, "components.terraform.plugin_cache_dir", "ATMOS_COMPONENTS_TERRAFORM_PLUGIN_CACHE_DIR")
bindEnv(v, "settings.github_token", "GITHUB_TOKEN")
bindEnv(v, "settings.inject_github_token", "ATMOS_INJECT_GITHUB_TOKEN")
bindEnv(v, "settings.atmos_github_token", "ATMOS_GITHUB_TOKEN")
bindEnv(v, "settings.github_username", "ATMOS_GITHUB_USERNAME", "GITHUB_ACTOR", "GITHUB_USERNAME")
bindEnv(v, "settings.bitbucket_token", "BITBUCKET_TOKEN")
bindEnv(v, "settings.atmos_bitbucket_token", "ATMOS_BITBUCKET_TOKEN")
bindEnv(v, "settings.inject_bitbucket_token", "ATMOS_INJECT_BITBUCKET_TOKEN")
bindEnv(v, "settings.bitbucket_username", "BITBUCKET_USERNAME")
bindEnv(v, "settings.gitlab_token", "GITLAB_TOKEN")
bindEnv(v, "settings.inject_gitlab_token", "ATMOS_INJECT_GITLAB_TOKEN")
bindEnv(v, "settings.atmos_gitlab_token", "ATMOS_GITLAB_TOKEN")
bindEnv(v, "settings.terminal.pager", "ATMOS_PAGER", "PAGER")
bindEnv(v, "settings.terminal.color", "ATMOS_COLOR", "COLOR")
bindEnv(v, "settings.terminal.no_color", "ATMOS_NO_COLOR", "NO_COLOR")
bindEnv(v, "settings.terminal.force_color", "ATMOS_FORCE_COLOR")
bindEnv(v, "settings.terminal.theme", "ATMOS_THEME", "THEME")
// Atmos Pro settings
bindEnv(v, "settings.pro.base_url", AtmosProBaseUrlEnvVarName)
bindEnv(v, "settings.pro.endpoint", AtmosProEndpointEnvVarName)
bindEnv(v, "settings.pro.token", AtmosProTokenEnvVarName)
bindEnv(v, "settings.pro.workspace_id", AtmosProWorkspaceIDEnvVarName)
bindEnv(v, "settings.pro.github_run_id", "GITHUB_RUN_ID")
bindEnv(v, "settings.pro.atmos_pro_run_id", AtmosProRunIDEnvVarName)
// GitHub OIDC for Atmos Pro
bindEnv(v, "settings.pro.github_oidc.request_url", "ACTIONS_ID_TOKEN_REQUEST_URL")
bindEnv(v, "settings.pro.github_oidc.request_token", "ACTIONS_ID_TOKEN_REQUEST_TOKEN")
// Telemetry settings
bindEnv(v, "settings.telemetry.enabled", "ATMOS_TELEMETRY_ENABLED")
bindEnv(v, "settings.telemetry.token", "ATMOS_TELEMETRY_TOKEN")
bindEnv(v, "settings.telemetry.endpoint", "ATMOS_TELEMETRY_ENDPOINT")
bindEnv(v, "settings.telemetry.logging", "ATMOS_TELEMETRY_LOGGING")
// Profiler settings
bindEnv(v, "profiler.enabled", "ATMOS_PROFILER_ENABLED")
bindEnv(v, "profiler.host", "ATMOS_PROFILER_HOST")
bindEnv(v, "profiler.port", "ATMOS_PROFILER_PORT")
bindEnv(v, "profiler.file", "ATMOS_PROFILE_FILE")
bindEnv(v, "profiler.profile_type", "ATMOS_PROFILE_TYPE")
}
func bindEnv(v *viper.Viper, key ...string) {
if err := v.BindEnv(key...); err != nil {
errUtils.CheckErrorPrintAndExit(err, "", "")
}
}
// setDefaultConfiguration set default configuration for the viper instance.
func setDefaultConfiguration(v *viper.Viper) {
v.SetDefault("components.helmfile.use_eks", true)
v.SetDefault("components.terraform.append_user_agent",
fmt.Sprintf("Atmos/%s (Cloud Posse; +https://atmos.tools)", version.Version))
// Plugin cache enabled by default for zero-config performance.
v.SetDefault("components.terraform.plugin_cache", true)
// Token injection defaults for all supported Git hosting providers.
v.SetDefault("settings.inject_github_token", true)
v.SetDefault("settings.inject_bitbucket_token", true)
v.SetDefault("settings.inject_gitlab_token", true)
v.SetDefault("logs.file", "/dev/stderr")
v.SetDefault("logs.level", "Warning")
v.SetDefault("settings.terminal.color", true)
v.SetDefault("settings.terminal.no_color", false)
v.SetDefault("settings.terminal.pager", "false") // String value to match the field type
// Note: force_color is ENV-only (ATMOS_FORCE_COLOR), no config default
v.SetDefault("docs.generate.readme.output", "./README.md")
// Atmos Pro defaults
v.SetDefault("settings.pro.base_url", AtmosProDefaultBaseUrl)
v.SetDefault("settings.pro.endpoint", AtmosProDefaultEndpoint)
}
// loadConfigSources loads configuration from multiple sources in priority order,
// delegating reading configs from each source and returning early if any step fails.
//
// Config loading order (lowest to highest priority, later wins):
// 1. System dir (/usr/local/etc/atmos) - lowest priority
// 2. Home dir (~/.atmos)
// 3. Parent directory search (fallback for unusual structures)
// 4. Git root (repo-root/atmos.yaml)
// 5. CWD only (./atmos.yaml, NO parent search)
// 6. Env var (ATMOS_CLI_CONFIG_PATH) - overrides discovery
// 7. CLI arg (--config-path) - highest priority
//
// Note: Viper merges configs, so later sources override earlier ones.
func loadConfigSources(v *viper.Viper, configAndStacksInfo *schema.ConfigAndStacksInfo) error {
// Load in order from lowest to highest priority (Viper merges, later wins).
// 1. System dir (lowest priority).
if err := readSystemConfig(v); err != nil {
return err
}
// 2. Home dir.
if err := readHomeConfig(v); err != nil {
return err
}
// 3. Parent directory search (fallback).
if err := readParentDirConfig(v); err != nil {
return err
}
// 4. Git root.
if err := readGitRootConfig(v); err != nil {
return err
}
// 5. CWD only.
if err := readWorkDirConfigOnly(v); err != nil {
return err
}
// 6. Env var (ATMOS_CLI_CONFIG_PATH) - overrides discovery.
if err := readEnvAmosConfigPath(v); err != nil {
return err
}
// 7. CLI arg (highest priority).
return readAtmosConfigCli(v, configAndStacksInfo.AtmosCliConfigPath)
}
// readSystemConfig load config from system dir.
func readSystemConfig(v *viper.Viper) error {
configFilePath := ""
if runtime.GOOS == "windows" {
appDataDir := os.Getenv(WindowsAppDataEnvVar)
if len(appDataDir) > 0 {
configFilePath = appDataDir
}
} else {
configFilePath = SystemDirConfigFilePath
}
if len(configFilePath) > 0 {
log.Trace("Checking for atmos.yaml in system config", "path", configFilePath)
err := mergeConfig(v, configFilePath, CliConfigFileName, false)
switch err.(type) {
case viper.ConfigFileNotFoundError:
return nil
default:
return err
}
}
return nil
}
// readHomeConfig load config from user's HOME dir.
func readHomeConfig(v *viper.Viper) error {
return readHomeConfigWithProvider(v, defaultHomeDirProvider)
}
// readHomeConfigWithProvider loads config from user's HOME dir using a HomeDirProvider.
func readHomeConfigWithProvider(v *viper.Viper, homeProvider filesystem.HomeDirProvider) error {
home, err := homeProvider.Dir()
if err != nil {
return err
}
configFilePath := filepath.Join(home, ".atmos")
log.Trace("Checking for atmos.yaml in home directory", "path", configFilePath)
err = mergeConfig(v, configFilePath, CliConfigFileName, true)
if err != nil {
switch err.(type) {
case viper.ConfigFileNotFoundError:
return nil
default:
return err
}
}
return nil
}
// readWorkDirConfigOnly tries to load atmos.yaml from CWD only (no parent search).
func readWorkDirConfigOnly(v *viper.Viper) error {
wd, err := os.Getwd()
if err != nil {
return err
}
// First try the current directory.
log.Trace("Checking for atmos.yaml in working directory", "path", wd)
err = mergeConfig(v, wd, CliConfigFileName, true)
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
return nil
}
return err
}
log.Trace("Found atmos.yaml in current directory", "path", wd)
return nil
}
// readGitRootConfig tries to load atmos.yaml from the git repository root.
func readGitRootConfig(v *viper.Viper) error {
// Check if git root discovery is disabled.
//nolint:forbidigo // ATMOS_GIT_ROOT_BASEPATH is bootstrap config, not application configuration.
if os.Getenv("ATMOS_GIT_ROOT_BASEPATH") == "false" {
return nil
}
// If ATMOS_CLI_CONFIG_PATH is set, skip git root discovery.
// The env var is an explicit override.
//nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself.
if os.Getenv(AtmosCliConfigPathEnvVar) != "" {
return nil
}
gitRoot, err := u.ProcessTagGitRoot("!repo-root")
if err != nil {
log.Trace("Git root detection failed", "error", err)
return nil
}
// Skip if git root is empty or same as CWD (already handled by readWorkDirConfigOnly).
if gitRoot == "" || gitRoot == "." {
return nil
}
// Convert relative path to absolute.
if !filepath.IsAbs(gitRoot) {
cwd, err := os.Getwd()
if err != nil {
return err
}
gitRoot = filepath.Join(cwd, gitRoot)
}
err = mergeConfig(v, gitRoot, CliConfigFileName, true)
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
return nil
}
return err
}
log.Trace("Found atmos.yaml at git root", "path", gitRoot)
return nil
}
// readParentDirConfig searches parent directories for atmos.yaml (fallback).
func readParentDirConfig(v *viper.Viper) error {
// If ATMOS_CLI_CONFIG_PATH is set, don't search parent directories.
// This allows tests and users to explicitly control config discovery.
//nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself.
if os.Getenv(AtmosCliConfigPathEnvVar) != "" {
return nil
}
wd, err := os.Getwd()
if err != nil {
return err
}
// Search parent directories for atmos.yaml.
configDir := findAtmosConfigInParentDirs(wd)
if configDir == "" {
// No config found in any parent directory.
return nil
}
// Found config in a parent directory, merge it.
err = mergeConfig(v, configDir, CliConfigFileName, true)
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
return nil
}
return err
}
log.Trace("Found atmos.yaml in parent directory", "path", configDir)
return nil
}
// Deprecated: readWorkDirConfig is kept for backward compatibility.
// Use readWorkDirConfigOnly, readGitRootConfig, and readParentDirConfig instead.
func readWorkDirConfig(v *viper.Viper) error {
if err := readWorkDirConfigOnly(v); err != nil {
return err
}
if err := readGitRootConfig(v); err != nil {
return err
}
return readParentDirConfig(v)
}
// findAtmosConfigInParentDirs searches for atmos.yaml in parent directories.
// It walks up the directory tree from the given starting directory until
// it finds an atmos.yaml file or reaches the filesystem root.
// Returns the directory containing atmos.yaml, or empty string if not found.
func findAtmosConfigInParentDirs(startDir string) string {
dir := startDir
for {
// Move to parent directory.
parent := filepath.Dir(dir)
// Check if we've reached the root.
if parent == dir {
return ""
}
dir = parent
log.Trace("Checking for atmos.yaml in parent directory", "path", dir)
// Check for atmos.yaml or .atmos.yaml in this directory.
for _, configName := range []string{AtmosConfigFileName, DotAtmosConfigFileName} {
configPath := filepath.Join(dir, configName)
if _, err := os.Stat(configPath); err == nil {
return dir
}
}
}
}
func readEnvAmosConfigPath(v *viper.Viper) error {
//nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself.
atmosPath := os.Getenv(AtmosCliConfigPathEnvVar)
if atmosPath == "" {
return nil
}
log.Trace("Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH", "path", atmosPath)
err := mergeConfig(v, atmosPath, CliConfigFileName, true)
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
log.Debug("config not found ENV var "+AtmosCliConfigPathEnvVar, "file", atmosPath)
return nil
}
return err
}
log.Trace("Found config ENV", AtmosCliConfigPathEnvVar, atmosPath)
return nil
}
func readAtmosConfigCli(v *viper.Viper, atmosCliConfigPath string) error {
if len(atmosCliConfigPath) == 0 {
return nil
}
err := mergeConfig(v, atmosCliConfigPath, CliConfigFileName, true)
switch err.(type) {
case viper.ConfigFileNotFoundError:
log.Debug("config not found", "file", atmosCliConfigPath)
default:
return err
}
return nil
}
// loadConfigFile reads a configuration file and returns a temporary Viper instance with its contents.
func loadConfigFile(path string, fileName string) (*viper.Viper, error) {
tempViper := viper.New()
tempViper.AddConfigPath(path)
tempViper.SetConfigName(fileName)
tempViper.SetConfigType(yamlType)
if err := tempViper.ReadInConfig(); err != nil {
// Return sentinel error unwrapped for type checking
var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) {
return nil, err
}
// Wrap error with context using proper chaining.
// This preserves the full error chain for debugging while adding our sentinel error.
return nil, fmt.Errorf("%w: %s/%s: %w", errUtils.ErrReadConfig, path, fileName, err)
}
return tempViper, nil
}
// readConfigFileContent reads the content of a configuration file.
func readConfigFileContent(configFilePath string) ([]byte, error) {
content, err := os.ReadFile(configFilePath)
if err != nil {
return nil, fmt.Errorf("%w: %s: %w", errUtils.ErrReadConfig, configFilePath, err)
}
return content, nil
}
// processConfigImportsAndReapply processes imports and re-applies the original config for proper precedence.
func processConfigImportsAndReapply(path string, tempViper *viper.Viper, content []byte) error {
// Parse the main config to get its commands separately.
mainViper := viper.New()
mainViper.SetConfigType(yamlType)
if err := mainViper.ReadConfig(bytes.NewReader(content)); err != nil {
return fmt.Errorf("%w: parse main config: %w", errUtils.ErrMergeConfiguration, err)
}
mainCommands := mainViper.Get(commandsKey)
// Process default imports (e.g., .atmos.d) first.
// These don't need the main config to be loaded.
if err := mergeDefaultImports(path, tempViper); err != nil {
log.Debug("error process default imports", "path", path, "error", err)
}
defaultCommands := tempViper.Get(commandsKey)
// Now load the main config temporarily to process explicit imports.
// We need this because the import paths are defined in the main config.
if err := tempViper.MergeConfig(bytes.NewReader(content)); err != nil {
return fmt.Errorf("%w: merge main config: %w", errUtils.ErrMergeConfiguration, err)
}
// Clear commands before processing imports to collect only imported commands.
tempViper.Set(commandsKey, nil)
// Process explicit imports.
// This will read the import paths from the config and process them.
if err := mergeImports(tempViper); err != nil {
log.Debug("error process explicit imports", "file", tempViper.ConfigFileUsed(), "error", err)
}
// Get imported commands (without main commands).
importedCommands := tempViper.Get(commandsKey)
// Re-apply this config file's content after processing its imports.
// This ensures proper precedence: each config file's own settings override
// the settings from any files it imports (directly or transitively).
if err := tempViper.MergeConfig(bytes.NewReader(content)); err != nil {
return fmt.Errorf("%w: re-applying main config after processing imports: %w", errUtils.ErrMergeConfiguration, err)
}
// Now merge commands in the correct order with proper override behavior:
// 1. Default imports (.atmos.d)
// 2. Explicit imports
// 3. Main config (overrides imports on duplicates)
var finalCommands interface{}
// Start with defaults
if defaultCommands != nil {
finalCommands = defaultCommands
}
// Add imported, with imported overriding defaults on duplicates
if importedCommands != nil {
finalCommands = mergeCommandArrays(finalCommands, importedCommands)
}
// Add main, with main overriding all others on duplicates
if mainCommands != nil {
finalCommands = mergeCommandArrays(finalCommands, mainCommands)
}
tempViper.Set(commandsKey, finalCommands)
return nil
}
// marshalViperToYAML marshals a Viper instance's settings to YAML.
func marshalViperToYAML(tempViper *viper.Viper) ([]byte, error) {
allSettings := tempViper.AllSettings()
yamlBytes, err := yaml.Marshal(allSettings)
if err != nil {
return nil, errors.Join(errUtils.ErrFailedMarshalConfigToYaml, err)
}
return yamlBytes, nil
}
// mergeYAMLIntoViper merges YAML content into a Viper instance.
func mergeYAMLIntoViper(v *viper.Viper, configFilePath string, yamlContent []byte) error {
v.SetConfigFile(configFilePath)
if err := v.MergeConfig(strings.NewReader(string(yamlContent))); err != nil {
return errors.Join(errUtils.ErrMerge, err)
}
return nil
}
// mergeConfig merges a config file and its imports with proper precedence.
// Each config file's settings override the settings from files it imports.
// This creates a hierarchy where the importing file always takes precedence over imported files.
func mergeConfig(v *viper.Viper, path string, fileName string, processImports bool) error {
// Load the configuration file
tempViper, err := loadConfigFile(path, fileName)
if err != nil {
return err
}
configFilePath := tempViper.ConfigFileUsed()
// Read the config file's content
content, err := readConfigFileContent(configFilePath)
if err != nil {
return err
}
// Process imports if requested
if processImports {
if err := processConfigImportsAndReapply(path, tempViper, content); err != nil {
return err
}
}
// Process YAML functions
if err := preprocessAtmosYamlFunc(content, tempViper); err != nil {
return errors.Join(errUtils.ErrPreprocessYAMLFunctions, err)
}
// Marshal to YAML
yamlBytes, err := marshalViperToYAML(tempViper)
if err != nil {
return err
}
// Merge into the main Viper instance
return mergeYAMLIntoViper(v, configFilePath, yamlBytes)
}
// shouldExcludePathForTesting checks if a directory path should be excluded from .atmos.d loading during testing.
// It compares the given directory path against a list of excluded paths from the TEST_EXCLUDE_ATMOS_D environment variable.
// Returns true if the path should be excluded, false otherwise.
func shouldExcludePathForTesting(dirPath string) bool {
//nolint:forbidigo // TEST_EXCLUDE_ATMOS_D is specifically for test isolation, not application configuration.
excludePaths := os.Getenv("TEST_EXCLUDE_ATMOS_D")
if excludePaths == "" {
return false
}
// Canonicalize the directory path we're checking.
absDirPath, err := filepath.Abs(filepath.Clean(dirPath))
if err != nil {
absDirPath = dirPath
}
// Split paths using the OS-specific path list separator.
for _, excludePath := range strings.Split(excludePaths, string(os.PathListSeparator)) {
if excludePath == "" {
continue
}
// Canonicalize the exclude path.
absExcludePath, err := filepath.Abs(filepath.Clean(excludePath))
if err != nil {
continue
}
// Check if the current directory is within or equals the excluded path.
// We currently only check for exact matches, but this could be extended
// to check for containment using filepath.Rel if needed.
pathsMatch := false
if runtime.GOOS == "windows" {
// Case-insensitive comparison on Windows.
pathsMatch = strings.EqualFold(absDirPath, absExcludePath)
} else {
pathsMatch = absDirPath == absExcludePath
}
if pathsMatch {
return true
}
}
return false
}
// loadAtmosConfigsFromDirectory loads all YAML configuration files from a directory
// and merges them into the destination viper instance.
// This is used by both .atmos.d/ loading and profile loading.
//
// The directory can contain:
// - YAML files (.yaml, .yml)
// - Subdirectories with YAML files
// - Special files like atmos.yaml (loaded with priority)
//
// Files are loaded in order:
// 1. Priority files (atmos.yaml) first
// 2. Sorted by depth (shallower first)
// 3. Lexicographic order within same depth
//
// Parameters:
// - searchPattern: Glob pattern for finding files (e.g., "/path/to/dir/**/*")
// - dst: Destination viper instance to merge configs into
// - source: Description for error messages (e.g., ".atmos.d", "profile 'developer'")
//
// Returns error if files can't be read or YAML is invalid.
func loadAtmosConfigsFromDirectory(searchPattern string, dst *viper.Viper, source string) error {
// Find all config files using existing search infrastructure.
foundPaths, err := SearchAtmosConfig(searchPattern)
if err != nil {
return fmt.Errorf("%w: failed to search for configuration files in %s: %w", errUtils.ErrParseFile, source, err)
}
// No files found is not an error - just means directory is empty.
if len(foundPaths) == 0 {
log.Trace("No configuration files found", "source", source, "pattern", searchPattern)
return nil
}
// Load and merge each file.
for _, filePath := range foundPaths {
if err := mergeConfigFile(filePath, dst); err != nil {
return fmt.Errorf("%w: failed to load configuration file from %s: %s: %w", errUtils.ErrParseFile, source, filePath, err)
}
log.Trace("Loaded configuration file", "path", filePath, "source", source)
}
log.Debug("Loaded configuration directory",
"source", source,
"files", len(foundPaths),
"pattern", searchPattern)
return nil
}
// mergeDefaultImports merges default imports (`atmos.d/`,`.atmos.d/`)
// from a specified directory into the destination configuration.
// It also searches the git/worktree root for .atmos.d with lower priority.
func mergeDefaultImports(dirPath string, dst *viper.Viper) error {
isDir := false
if stat, err := os.Stat(dirPath); err == nil && stat.IsDir() {
isDir = true
}
if !isDir {
return errUtils.ErrAtmosDirConfigNotFound
}
// Check if we should exclude .atmos.d from this directory during testing.
if shouldExcludePathForTesting(dirPath) {
// Silently skip without logging to avoid test output pollution.
return nil
}
// Search git/worktree root FIRST (lower priority - gets overridden by config dir).
// This enables .atmos.d to be discovered at the repo root even when running from subdirectories.