Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/prd/import-adapter-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func isRemoteImport(importPath string) bool {
3. **Maintain backward compatibility** - Existing local and HTTP/HTTPS imports must work unchanged
4. **Support testing** - Mock adapter for unit tests without external dependencies
5. **Preserve recursive imports** - Nested imports continue to work across all schemes
6. **Make nested import resolution explicit** - Remote stack imports must support both consumer-local nested imports and self-contained remote import trees

## Functional Requirements

Expand Down Expand Up @@ -115,6 +116,25 @@ func isRemoteImport(importPath string) bool {
| FR-6.3 | Nested/recursive imports SHALL continue to work across all schemes |
| FR-6.4 | The `ResolvedPaths` struct SHALL support a new `ADAPTER` import type |

### FR-7: Nested Remote Import Resolution

| ID | Requirement |
|----|-------------|
| FR-7.1 | Map-form imports SHALL support `nested_imports` with values `local` and `remote` |
| FR-7.2 | `nested_imports: local` SHALL preserve current behavior by resolving imports inside the imported file from the consumer repository's stack base path |
| FR-7.3 | `nested_imports: remote` SHALL resolve imports inside a git/go-getter remote file from the remote source's stack base path |
| FR-7.4 | Plain string imports and map imports without `nested_imports` SHALL default to `local` for backward compatibility |
| FR-7.5 | Nested imports SHALL inherit the parent import's `nested_imports` mode unless a nested map import overrides it |
| FR-7.6 | Atmos SHALL return an actionable error when `nested_imports: remote` is requested for a remote source where the remote stack base path cannot be inferred |

Example:

```yaml
import:
- path: git::https://github.com/org/hub.git//stacks/orgs/l360/_defaults.yaml?ref=v1
nested_imports: remote
```

## Solution: Two-Layer Architecture

### Design Philosophy
Expand Down
13 changes: 13 additions & 0 deletions examples/remote-stack-imports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ import:
- github.com/acme/infrastructure//stacks/catalog/rds?ref=main
```

### Nested Imports from the Remote Source

Remote files can contain their own `import:` section. By default, those nested imports resolve from your local `stacks.base_path`. Use `nested_imports: remote` when the remote file and its imports should be resolved together from the same remote source:

```yaml
import:
- path: git::https://github.com/acme/infrastructure.git//stacks/orgs/acme/_defaults.yaml?ref=v1.2.0
nested_imports: remote
```

With this setting, a nested import like `catalog/_defaults` resolves to `stacks/catalog/_defaults.yaml` in the remote repository.

### S3 Bucket

```yaml
Expand All @@ -66,6 +78,7 @@ import:
2. **Cache Considerations** - Remote imports are cached locally
3. **Authentication** - Configure credentials for private repositories via environment variables
4. **Fallback to Local** - Consider vendoring critical imports for offline access
5. **Choose Nested Import Resolution** - Use the default `nested_imports: local` for remote files that expect local extension points, and `nested_imports: remote` for self-contained remote stack libraries

## Learn More

Expand Down
83 changes: 65 additions & 18 deletions internal/exec/stack_processor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,8 @@ func ProcessYAMLConfigFileWithContext(
atmosConfig,
basePath,
filePath,
basePath,
schema.StackImportNestedImportsLocal,
importsConfig,
context,
ignoreMissingFiles,
Expand Down Expand Up @@ -820,6 +822,8 @@ func processYAMLConfigFileWithContextInternal(
atmosConfig *schema.AtmosConfiguration,
basePath string,
filePath string,
localBasePath string,
inheritedNestedImports string,
importsConfig map[string]map[string]any,
context map[string]any,
ignoreMissingFiles bool,
Expand All @@ -844,6 +848,10 @@ func processYAMLConfigFileWithContextInternal(
error,
) {
var stackConfigs []map[string]any
inheritedNestedImports = normalizeNestedImports(inheritedNestedImports)
if localBasePath == "" {
localBasePath = basePath
}
relativeFilePath := u.TrimBasePathFromPath(basePath+"/", filePath)

log.Trace("Processing YAML config file", "file", relativeFilePath)
Expand Down Expand Up @@ -1156,25 +1164,29 @@ func processYAMLConfigFileWithContextInternal(
log.Trace("Processing import structs", "count", len(importStructs), "file", relativeFilePath, "track_provenance", atmosConfig != nil && atmosConfig.TrackProvenance)
for _, importStruct := range importStructs {
imp := importStruct.Path
nestedImports := importStruct.NestedImports
if nestedImports == "" {
nestedImports = inheritedNestedImports
}
nestedImports = normalizeNestedImports(nestedImports)

if imp == "" {
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w in the manifest '%s'", errUtils.ErrInvalidImport, relativeFilePath)
}

var importMatches []string
var importMatches []stackimports.RemoteImportMatch

// Capture remote status and original import key before resolution.
// For remote imports, the original URI must be preserved as the import key
// so that downstream lookups and imports output use the user-specified URI
// rather than the local cache path.
isRemote := stackimports.IsRemote(imp)
importKey := imp

// Check if the import is a remote URL.
if isRemote {
// Download the remote import.
log.Debug("Downloading remote stack import", "uri", imp, "file", relativeFilePath)
localPath, err := stackimports.DownloadRemoteImport(atmosConfig, imp)
log.Debug("Downloading remote stack import", "uri", imp, "file", relativeFilePath, "nested_imports", nestedImports)
remoteMatches, err := stackimports.ResolveRemoteImportNested(atmosConfig, imp, nestedImports)
if err != nil {
if importStruct.SkipIfMissing {
log.Debug("Skipping missing remote import", "uri", imp)
Expand All @@ -1183,8 +1195,7 @@ func processYAMLConfigFileWithContextInternal(
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w '%s' in file '%s': %w",
errUtils.ErrDownloadRemoteImport, imp, relativeFilePath, err)
}
// Remote imports return a single file path.
importMatches = []string{localPath}
importMatches = remoteMatches
} else {
// Local import - handle extension resolution and glob matching.
impWithExt := imp
Expand Down Expand Up @@ -1228,13 +1239,13 @@ func processYAMLConfigFileWithContextInternal(

// Find all import matches in the glob.
var err error
importMatches, err = u.GetGlobMatches(impWithExtPath)
if err != nil || len(importMatches) == 0 {
localMatches, err := u.GetGlobMatches(impWithExtPath)
if err != nil || len(localMatches) == 0 {
// Retry (b/c we are using `doublestar` library and it sometimes has issues reading many files in a Docker container).
// TODO: review `doublestar` library.

importMatches, err = u.GetGlobMatches(impWithExtPath)
if err != nil || len(importMatches) == 0 {
localMatches, err = u.GetGlobMatches(impWithExtPath)
if err != nil || len(localMatches) == 0 {
// The import was not found -> check if the import is a Go template; if not, return the error.
isGolangTemplate, err2 := IsGolangTemplate(atmosConfig, imp)
if err2 != nil {
Expand All @@ -1244,14 +1255,14 @@ func processYAMLConfigFileWithContextInternal(
// If the import is not a Go template and SkipIfMissing is false, return the error.
// The wrapped `err` from GetGlobMatches already carries ErrFailedToFindImport, so
// callers using errors.Is (see describe_affected_utils.go) keep matching. When
// err is nil but importMatches is empty (defensive guard for unexpected empty/nil
// err is nil but localMatches is empty (defensive guard for unexpected empty/nil
// results from u.GetGlobMatches), we wrap ErrFailedToFindImport explicitly.
if !isGolangTemplate && !importStruct.SkipIfMissing {
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("%w: import '%s' in file '%s': %w",
errUtils.ErrStackImportNotFound, imp, relativeFilePath, err)
}
if len(importMatches) == 0 {
if len(localMatches) == 0 {
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf(
"%w: import '%s' in file '%s': %w",
errUtils.ErrStackImportNotFound,
Expand All @@ -1263,6 +1274,10 @@ func processYAMLConfigFileWithContextInternal(
}
}
}
importMatches = make([]stackimports.RemoteImportMatch, len(localMatches))
for i, match := range localMatches {
importMatches[i] = stackimports.RemoteImportMatch{Path: match}
}
}

// Process `context` in hierarchical imports.
Expand All @@ -1288,10 +1303,18 @@ func processYAMLConfigFileWithContextInternal(
results := make([]importFileResult, len(importMatches))
var wg sync.WaitGroup

for i, importFile := range importMatches {
for i, importMatch := range importMatches {
wg.Add(1)
go func(index int, file string, isRemote bool, importKey string) {
go func(index int, match stackimports.RemoteImportMatch) {
defer wg.Done()
file := match.Path
childBasePath := basePath
if nestedImports == schema.StackImportNestedImportsLocal {
childBasePath = localBasePath
}
if nestedImports == schema.StackImportNestedImportsRemote && match.BasePath != "" {
childBasePath = match.BasePath
}

// Process the import file (expensive I/O + parsing + recursive imports).
yamlConfig,
Expand All @@ -1304,8 +1327,10 @@ func processYAMLConfigFileWithContextInternal(
importMergeContext,
processErr := processYAMLConfigFileWithContextInternal(
atmosConfig,
basePath,
childBasePath,
file,
localBasePath,
nestedImports,
importsConfig,
mergedContext,
ignoreMissingFiles,
Expand Down Expand Up @@ -1334,8 +1359,8 @@ func processYAMLConfigFileWithContextInternal(

// For remote imports, use the original URI as the import key
// instead of the cache-derived path.
if isRemote {
importRelativePathWithoutExt = importKey
if match.Key != "" {
importRelativePathWithoutExt = match.Key
}

// Store result with all necessary data for sequential merging.
Expand All @@ -1352,7 +1377,7 @@ func processYAMLConfigFileWithContextInternal(
mergeContext: importMergeContext,
err: nil,
}
}(i, importFile, isRemote, importKey)
}(i, importMatch)
}

// Wait for all parallel processing to complete.
Expand Down Expand Up @@ -1727,6 +1752,12 @@ func ProcessImportSection(stackMap map[string]any, filePath string) ([]schema.St
var importObj schema.StackImport
err := mapstructure.Decode(imp, &importObj)
if err == nil {
if importObj.NestedImports != "" {
importObj.NestedImports = normalizeNestedImports(importObj.NestedImports)
if err := validateNestedImports(importObj.NestedImports); err != nil {
return nil, fmt.Errorf("%w in the file '%s'", err, filePath)
}
}
importObj.Path = u.ResolveRelativePath(importObj.Path, filePath)
result = append(result, importObj)
continue
Expand All @@ -1748,6 +1779,22 @@ func ProcessImportSection(stackMap map[string]any, filePath string) ([]schema.St
return result, nil
}

func normalizeNestedImports(value string) string {
if value == "" {
return schema.StackImportNestedImportsLocal
}
return strings.ToLower(strings.TrimSpace(value))
}

func validateNestedImports(value string) error {
switch normalizeNestedImports(value) {
case schema.StackImportNestedImportsLocal, schema.StackImportNestedImportsRemote:
return nil
default:
return fmt.Errorf("%w: nested_imports must be either 'local' or 'remote'", errUtils.ErrInvalidImport)
}
}

// sectionContainsAnyNotEmptySections checks if a section contains any of the provided low-level sections, and it's not empty.
func sectionContainsAnyNotEmptySections(section map[string]any, sectionsToCheck []string) bool {
for _, s := range sectionsToCheck {
Expand Down
Loading
Loading