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
23 changes: 22 additions & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,28 @@ jobs:
# Temporary allowlist: oras-go v2.6.1 is pulled in by Helm v4.2.1 and is
# currently the latest tagged v2 release. Remove after Helm/ORAS ships a
# fixed version for GHSA-fxhp-mv3v-67qp.
allow-ghsas: GHSA-fxhp-mv3v-67qp
#
# Temporary allowlist: brace-expansion's advisory (GHSA-mh99-v99m-4gvg /
# CVE-2026-14257) lists "<=5.0.7" as vulnerable and "5.0.8" as the first
# patched version, but that range predates the maintainer's later
# backport of the same EXPANSION_MAX_LENGTH fix into the 1.x/2.x lines
# (verified by unpacking the exact 1.1.18/2.1.4 tarballs pinned -- no
# `^` range -- in website/package.json's pnpm overrides: the fix is
# present, and the `module.exports = expandTop` callable export is
# unchanged). Jumping to 5.0.8+ is not an option: its CommonJS build
# switched to a named `exports.expand` export, which breaks the
# `require('brace-expansion')` usage in minimatch@3.1.5/9.0.9.
#
# NOTE: allow-ghsas suppresses this GHSA for every resolved
# brace-expansion version, not just 1.1.18/2.1.4 -- the action has no
# package/version-scoped vulnerability exception (only
# allow-dependencies-licenses is purl-scoped, and that's license-only).
# The exact (non-`^`) pnpm override pins are what keep this exception
# scoped in practice: revisit this entry if those pins ever change.
# Remove once GitHub's advisory data reflects the backport, or once
# minimatch ships a version compatible with brace-expansion 5.x's
# export shape.
allow-ghsas: GHSA-fxhp-mv3v-67qp, GHSA-mh99-v99m-4gvg
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Comment on PR with details
comment-summary-in-pr: always
Expand Down
17 changes: 17 additions & 0 deletions pkg/cache/file_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,23 @@ func (c *FileCache) Set(key string, content []byte) error {
})
}

// Delete removes a cached entry. A missing entry is treated as already deleted.
// Like Get and Set, deletion is serialized across Atmos processes.
func (c *FileCache) Delete(key string) error {
defer perf.Track(nil, "cache.FileCache.Delete")()

path := filepath.Join(c.baseDir, keyToFilename(key))
return c.lock.WithLock(func() error {
if err := c.fs.Remove(path); err != nil && !os.IsNotExist(err) {
return errUtils.Build(errUtils.ErrCacheWrite).
WithCause(err).
WithContext("key", key).
Err()
}
return nil
})
}

// GetPath returns the filesystem path for a cached key.
// Returns (path, true) if the key exists in cache, (path, false) otherwise.
// This is useful when callers need the file path rather than content.
Expand Down
39 changes: 39 additions & 0 deletions pkg/cache/file_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ func TestFileCache_Get_NotFound(t *testing.T) {
assert.Nil(t, got)
}

func TestFileCache_Delete(t *testing.T) {
cache := newTestCache(t)
require.NoError(t, cache.Set("test-key", []byte("cached content")))

require.NoError(t, cache.Delete("test-key"))
_, exists, err := cache.Get("test-key")
require.NoError(t, err)
assert.False(t, exists)

// Deleting an absent entry is idempotent.
require.NoError(t, cache.Delete("test-key"))
}

func TestFileCache_GetPath(t *testing.T) {
cache := newTestCache(t)

Expand Down Expand Up @@ -485,6 +498,32 @@ func TestFileCache_Set_WriteError(t *testing.T) {
assert.ErrorIs(t, err, errUtils.ErrCacheWrite)
}

func TestFileCache_Delete_RemoveError(t *testing.T) {
// Test that Delete returns a wrapped error when Remove fails with something
// other than "not exist" (e.g. a permissions problem or I/O error).
tempDir := t.TempDir()
key := "test-key"
expectedPath := filepath.Join(tempDir, keyToFilename(key))
removeErr := fmt.Errorf("remove failed")

ctrl := gomock.NewController(t)
mockFS := filesystem.NewMockFileSystem(ctrl)
mockFS.EXPECT().
Remove(expectedPath).
Return(removeErr)

cache := &FileCache{
baseDir: tempDir,
lockFilePath: filepath.Join(tempDir, "cache.lock"),
lock: &mockFileLock{},
fs: mockFS,
}

err := cache.Delete(key)
require.Error(t, err)
assert.ErrorIs(t, err, errUtils.ErrCacheWrite)
}

func TestFileCache_Clear_RemoveError(t *testing.T) {
// Test Clear when a cached file cannot be removed.
tempDir := t.TempDir()
Expand Down
6 changes: 3 additions & 3 deletions pkg/component/container/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ func prepare(info *schema.ConfigAndStacksInfo) (*resolved, error) {
// runtime detects the container runtime and forwards the resolved environment
// (so registry auth and app credentials reach the docker/podman subprocess).
func (r *resolved) runtime(ctx context.Context) (ctr.Runtime, error) {
runtime, err := detectRuntime(ctx, r.runtimePref, r.autoStart)
resolution, err := resolveRuntimeForContainerCommand(ctx, r.runtimePref, r.autoStart)
if err != nil {
return nil, err
}
if setter, ok := runtime.(ctr.EnvSetter); ok {
if setter, ok := resolution.runtime.(ctr.EnvSetter); ok {
setter.SetEnv(r.envList)
}
return runtime, nil
return resolution.runtime, nil
}

// mounts returns runtime mounts with bind sources made absolute against the
Expand Down
41 changes: 41 additions & 0 deletions pkg/component/container/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,47 @@ func TestEnvListToMap(t *testing.T) {
assert.Equal(t, "3", env["C"])
}

// TestResolvedRuntime_PropagatesDetectionError verifies (*resolved).runtime
// returns the detection error unchanged instead of forwarding a partially
// resolved runtime.
func TestResolvedRuntime_PropagatesDetectionError(t *testing.T) {
orig := detectRuntime
t.Cleanup(func() { detectRuntime = orig })
detectRuntime = func(_ context.Context, _ string, _ bool) (ctr.Runtime, error) {
return nil, assert.AnError
}

// An explicit runtime preference bypasses the durable-cache path entirely,
// so detectRuntime is called directly and deterministically.
r := &resolved{runtimePref: "docker"}
rt, err := r.runtime(context.Background())
require.ErrorIs(t, err, assert.AnError)
assert.Nil(t, rt)
}

// TestResolvedRuntime_ForwardsEnvToEnvSetterRuntime verifies (*resolved).runtime
// forwards the resolved component env to the detected runtime when it
// implements ctr.EnvSetter (e.g. so registry auth reaches the docker/podman
// CLI subprocess), and returns the runtime unchanged otherwise.
func TestResolvedRuntime_ForwardsEnvToEnvSetterRuntime(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
underlying := &envSetterMockRuntime{MockRuntime: NewMockRuntime(ctrl)}

orig := detectRuntime
t.Cleanup(func() { detectRuntime = orig })
detectRuntime = func(_ context.Context, _ string, _ bool) (ctr.Runtime, error) {
return underlying, nil
}

r := &resolved{runtimePref: "docker", envList: []string{"FOO=bar"}}
rt, err := r.runtime(context.Background())
require.NoError(t, err)
assert.Same(t, underlying, rt)
require.Len(t, underlying.setEnvCalls, 1)
assert.Equal(t, []string{"FOO=bar"}, underlying.setEnvCalls[0])
}

func TestDefaultStopTimeoutValue(t *testing.T) {
assert.Equal(t, 10*time.Second, defaultStopTimeout)
}
52 changes: 41 additions & 11 deletions pkg/component/container/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,21 @@ func ExecuteList(ctx context.Context, info *schema.ConfigAndStacksInfo) error {
}

// Detect the runtime once (non-fatal) and annotate each row's running state.
runtime, rtErr := detectRuntime(ctx, atmosConfig.Container.Runtime.Provider, atmosConfig.Container.Runtime.AutoStart)
// A cached runtime that no longer responds is invalidated, re-discovered, and
// retried once because listing is read-only.
resolution, rtErr := resolveRuntimeForContainerCommand(ctx, atmosConfig.Container.Runtime.Provider, atmosConfig.Container.Runtime.AutoStart)
if rtErr == nil {
listErr := annotateRunningState(ctx, resolution.runtime, rows)
if listErr != nil && resolution.cached {
resolution, rtErr = rediscoverRuntime(ctx, resolution)
if rtErr == nil {
_ = annotateRunningState(ctx, resolution.runtime, rows)
}
}
}
if rtErr != nil {
runtime = nil
annotateUnknown(rows)
}
annotateRunningState(ctx, runtime, rows)

return renderInstanceTable(rows)
}
Expand Down Expand Up @@ -173,16 +183,27 @@ func imageFromComponent(compData any) string {

// annotateRunningState fills each row's running state via label discovery. When
// no runtime is available, rows are marked "unknown".
func annotateRunningState(ctx context.Context, runtime ctr.Runtime, rows []instanceRow) {
for i := range rows {
if runtime == nil {
rows[i].status = statusUnknown
continue
func annotateRunningState(ctx context.Context, runtime ctr.Runtime, rows []instanceRow) error {
if runtime == nil {
annotateUnknown(rows)
return nil
}

containers, err := runtime.List(ctx, ctr.ComponentTypeFilter(cfg.ContainerComponentType))
if err != nil {
annotateUnknown(rows)
return err
}
instances := make(map[string]ctr.Info, len(containers))
for i := range containers {
in := &containers[i]
if instance := in.Labels[ctr.LabelInstance]; instance != "" {
instances[instance] = *in
}
in, found, err := ctr.FindInstance(ctx, runtime, rows[i].stack, cfg.ContainerComponentType, rows[i].component)
}
for i := range rows {
in, found := instances[ctr.InstanceAddress(rows[i].stack, cfg.ContainerComponentType, rows[i].component)]
switch {
case err != nil:
rows[i].status = statusUnknown
case found && ctr.IsContainerRunning(in.Status):
rows[i].status = statusRunning
rows[i].running = true
Expand All @@ -194,6 +215,15 @@ func annotateRunningState(ctx context.Context, runtime ctr.Runtime, rows []insta
rows[i].status = statusStopped
}
}
return nil
}

func annotateUnknown(rows []instanceRow) {
for i := range rows {
rows[i].status = statusUnknown
rows[i].running = false
rows[i].health = ""
}
}

// renderInstanceTable prints the container instances as an aligned table to the
Expand Down
26 changes: 23 additions & 3 deletions pkg/component/container/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func initListTestIO(t *testing.T) {
// describeStacks returns the provided map/error, and detectRuntime returns rt/err.
func withListStubs(t *testing.T, stacksMap map[string]any, describeErr error, rt ctr.Runtime, detectErr error) {
t.Helper()
t.Setenv("ATMOS_XDG_CACHE_HOME", t.TempDir())
t.Setenv("ATMOS_CONTAINER_RUNTIME", "")

origInit, origDescribe, origDetect := initCliConfig, describeStacks, detectRuntime
t.Cleanup(func() {
Expand Down Expand Up @@ -172,7 +174,7 @@ func TestAnnotateRunningState_Running(t *testing.T) {
defer ctrl.Finish()
rt := NewMockRuntime(ctrl)

rt.EXPECT().List(gomock.Any(), ctr.DiscoveryFilter("dev", "container", "api")).
rt.EXPECT().List(gomock.Any(), ctr.ComponentTypeFilter("container")).
Return([]ctr.Info{{ID: "cid", Status: "running", Labels: ctr.InstanceLabels("dev", "container", "api")}}, nil)

rows := []instanceRow{{stack: "dev", component: "api"}}
Expand All @@ -186,7 +188,7 @@ func TestAnnotateRunningState_Stopped(t *testing.T) {
defer ctrl.Finish()
rt := NewMockRuntime(ctrl)

rt.EXPECT().List(gomock.Any(), ctr.DiscoveryFilter("dev", "container", "api")).
rt.EXPECT().List(gomock.Any(), ctr.ComponentTypeFilter("container")).
Return([]ctr.Info{{ID: "cid", Status: "exited", Labels: ctr.InstanceLabels("dev", "container", "api")}}, nil)

rows := []instanceRow{{stack: "dev", component: "api"}}
Expand All @@ -207,6 +209,24 @@ func TestAnnotateRunningState_ListError(t *testing.T) {
assert.Equal(t, statusUnknown, rows[0].status)
}

func TestAnnotateRunningState_UsesOneBulkQuery(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
rt := NewMockRuntime(ctrl)

rt.EXPECT().List(gomock.Any(), ctr.ComponentTypeFilter("container")).Return([]ctr.Info{
{Status: "running", Labels: ctr.InstanceLabels("dev", "container", "api")},
{Status: "exited", Labels: ctr.InstanceLabels("prod", "container", "worker")},
{Status: "running", Labels: ctr.InstanceLabels("other", "container", "ignored")},
}, nil).Times(1)

rows := []instanceRow{{stack: "dev", component: "api"}, {stack: "prod", component: "worker"}, {stack: "dev", component: "missing"}}
require.NoError(t, annotateRunningState(context.Background(), rt, rows))
assert.Equal(t, statusRunning, rows[0].status)
assert.Equal(t, statusStopped, rows[1].status)
assert.Equal(t, statusStopped, rows[2].status)
}

func TestExecuteList_NoComponents(t *testing.T) {
withListStubs(t, map[string]any{}, nil, nil, nil)
require.NoError(t, ExecuteList(context.Background(), &schema.ConfigAndStacksInfo{}))
Expand Down Expand Up @@ -237,7 +257,7 @@ func TestExecuteList_RendersWithRuntime(t *testing.T) {
stacksMap := map[string]any{"dev": containerStack(map[string]string{"api": "api:dev"})}
withListStubs(t, stacksMap, nil, rt, nil)

rt.EXPECT().List(gomock.Any(), ctr.DiscoveryFilter("dev", "container", "api")).
rt.EXPECT().List(gomock.Any(), ctr.ComponentTypeFilter("container")).
Return([]ctr.Info{{ID: "cid", Status: "running", Labels: ctr.InstanceLabels("dev", "container", "api")}}, nil)

require.NoError(t, ExecuteList(context.Background(), &schema.ConfigAndStacksInfo{}))
Expand Down
Loading
Loading