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
52 changes: 52 additions & 0 deletions docs/fixes/2026-08-10-scaffold-remote-source-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Fix: remote scaffold sources no longer record a dangling temp-dir path in `spec.source`

**Date:** 2026-08-10

## Summary

`atmos scaffold generate`/`atmos init` recorded a dangling, already-deleted temp-directory path in
`spec.source` of `.atmos/scaffold.yaml` whenever the template source was remote (`git::...` or a bare
`https://...` URL). `resolveRemote()` in `pkg/generator/source/resolver.go` now restores
`Configuration.Source` to the original source string the caller passed in after loading the template
config from the temporary download directory.

## Context

- For a remote scaffold source, `resolveRemote()` downloads the template into
`os.MkdirTemp("", "atmos-scaffold-")`, then loads the config with that temp dir passed in as the
"source." `Configuration.Source` — and therefore the persisted `spec.source` — ended up holding
something like `/var/folders/xx/.../atmos-scaffold-1234567890`. `cleanup()` removes that directory
immediately after the command finishes, so the recorded provenance was a dangling reference to nothing
as soon as generation completed, contradicting `SaveProjectRecord`'s own doc comment: "spec.source and
spec.baseRef record provenance for future updates."
- Local sources (a relative/absolute path, or `file://...`) were already correct, but only by accident of
`resolveLocal` having no temp-dir indirection step, not because anything special-cased provenance for
them.
- Reproduced directly: `atmos scaffold generate "git::https://.../scaffold-template.git" ./out --defaults`,
then `cat ./out/.atmos/scaffold.yaml` showed a `/var/folders/...`/`/tmp/...` path for `spec.source` that
no longer existed on disk.
- No upstream GitHub issue; PR https://github.com/cloudposse/atmos/pull/2869 was opened directly.

## Changes

- `pkg/generator/source/resolver.go`: in `resolveRemote()`, set `conf.Source = src` (the original source
string the caller passed in) after loading the template configuration from the temporary download
directory, instead of leaving `Configuration.Source` as whatever `LoadConfigurationFromDir` was given
(the temp dir itself). No change to `resolveLocal`, which already recorded the correct value.
- `pkg/generator/source/resolver_test.go`: added assertions that `Configuration.Source` holds the original
source for both local (`TestResolve_LocalPath`, `TestHydrate_LocalStub`) and remote paths, plus a
dedicated regression test, `TestResolve_RemoteRecordsOriginalSource`, that fails on the pre-fix code and
passes after. The remote case is exercised by serving an in-memory ZIP archive from an `httptest.Server`
rather than a git fixture — go-getter's HTTP getter unpacks the `.zip` extension on its own, so the test
never shells out to git or any other external binary and stays hermetic/cross-platform.

## Validation

- `go test ./pkg/generator/source/...` — passes, including `TestResolve_RemoteRecordsOriginalSource`.
- Manual repro: `atmos scaffold generate "git::https://.../scaffold-template.git" ./out --defaults` followed
by `cat ./out/.atmos/scaffold.yaml` now shows the original `git::...` source string in `spec.source`
instead of the removed temp directory.

## Follow-ups

None.
4 changes: 4 additions & 0 deletions pkg/generator/source/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ func resolveRemote(atmosConfig *schema.AtmosConfiguration, name, src string, tim
cleanup()
return nil, noop, err
}
// tempDir only exists to read files off disk and is removed by cleanup()
// once generation finishes; the recorded provenance must be the original
// source the caller passed in, not that ephemeral fetch destination.
conf.Source = src
return conf, cleanup, nil
}

Expand Down
61 changes: 61 additions & 0 deletions pkg/generator/source/resolver_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package source

import (
"archive/zip"
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
Expand Down Expand Up @@ -71,6 +75,7 @@ func TestResolve_LocalPath(t *testing.T) {
defer cleanup()
require.NotNil(t, cfg)
assert.True(t, hasSampleFile(cfg.Files), "local template files must be loaded")
assert.Equal(t, dir, cfg.Source, "local sources must record the original path")
}

func TestResolve_LocalPathDefaultTimeout(t *testing.T) {
Expand Down Expand Up @@ -152,6 +157,7 @@ func TestHydrate_LocalStub(t *testing.T) {
require.NoError(t, err)
defer cleanup()
assert.True(t, hasSampleFile(stub.Files), "local stub must be hydrated from its source")
assert.Equal(t, dir, stub.Source, "hydrate's *stub = *resolved copy must preserve the original source")
}

func TestHydrate_LocalStubError(t *testing.T) {
Expand Down Expand Up @@ -233,6 +239,61 @@ func TestResolve_RemoteGitSubdirSuccess(t *testing.T) {
assert.True(t, hasSampleFile(cfg.Files), "remote git subdir template files must be loaded")
}

// zipArchive builds an in-memory ZIP archive containing the given files.
// go-getter's HTTP getter unpacks a recognized archive extension (.zip
// included) directly, so serving one over httptest.Server exercises a real
// remote (ClientModeDir) fetch through resolveRemote without needing git or
// any other external binary.
func zipArchive(t *testing.T, files map[string]string) []byte {
t.Helper()

var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range files {
w, err := zw.Create(name)
require.NoError(t, err)
_, err = w.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}

// TestResolve_RemoteRecordsOriginalSource pins the bug where a remote
// (git::/https://) scaffold source ended up with Configuration.Source (and
// therefore the persisted spec.source in .atmos/scaffold.yaml) set to the
// ephemeral os.MkdirTemp download directory instead of the original source
// string. That tempdir is removed by cleanup() as soon as the command
// finishes, leaving spec.source pointing at nothing.
//
// Serves a ZIP archive from a local httptest.Server rather than using a git
// fixture: the ".zip" extension is enough for go-getter's HTTP getter to
// unpack it into the temp dir on its own, so this test never shells out to
// git (or any other external binary) and stays hermetic/cross-platform.
func TestResolve_RemoteRecordsOriginalSource(t *testing.T) {
archive := zipArchive(t, map[string]string{
"scaffold.yaml": sampleScaffold,
"file.txt": "hello",
})

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(archive)
}))
defer server.Close()

src := server.URL + "/template.zip"

cfg, cleanup, err := Resolve(&schema.AtmosConfiguration{}, "sample", src, time.Minute)
require.NoError(t, err)
require.NotNil(t, cleanup)
defer cleanup()
require.NotNil(t, cfg)

assert.True(t, hasSampleFile(cfg.Files), "remote archive template files must be loaded")
assert.Equal(t, src, cfg.Source, "remote sources must record the original source string, not the ephemeral fetch tempdir")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// TestResolve_RemoteGitSubdirMissing pins the exact failure mode reported for
// `atmos init aws/app`: a valid git remote whose requested //subdir does not exist.
func TestResolve_RemoteGitSubdirMissing(t *testing.T) {
Expand Down
Loading