From cfc459c2471652f4db1d5acbc69dcdb7e50f0e41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:13:23 +0000 Subject: [PATCH 1/7] Initial plan From 04d42bee6ca56e69ab3a817018268b02913a2ef2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:26:10 +0000 Subject: [PATCH 2/7] Add state caching infrastructure for azd show performance - Created StateCacheManager in pkg/state for managing cached Azure resource information - Cache stores resource IDs and ingress URLs per service to avoid repeated Azure queries - Implemented .state-change notification file that tools can watch for changes - Updated azd show command to use cache when available - Added comprehensive tests for cache functionality - All tests passing with proper formatting and spell checking Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/internal/cmd/show/show.go | 99 ++++++++++---- cli/azd/pkg/state/state_cache.go | 182 ++++++++++++++++++++++++++ cli/azd/pkg/state/state_cache_test.go | 161 +++++++++++++++++++++++ 3 files changed, 417 insertions(+), 25 deletions(-) create mode 100644 cli/azd/pkg/state/state_cache.go create mode 100644 cli/azd/pkg/state/state_cache_test.go diff --git a/cli/azd/internal/cmd/show/show.go b/cli/azd/internal/cmd/show/show.go index 1bf5d907ff7..9b3ebb32ac7 100644 --- a/cli/azd/internal/cmd/show/show.go +++ b/cli/azd/internal/cmd/show/show.go @@ -36,6 +36,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -92,6 +93,7 @@ type showAction struct { lazyServiceManager *lazy.Lazy[project.ServiceManager] lazyResourceManager *lazy.Lazy[project.ResourceManager] portalUrlBase string + stateCacheManager *state.StateCacheManager } func NewShowAction( @@ -114,6 +116,9 @@ func NewShowAction( lazyResourceManager *lazy.Lazy[project.ResourceManager], cloud *cloud.Cloud, ) actions.Action { + // Create state cache manager with the environment directory + stateCacheManager := state.NewStateCacheManager(azdCtx.EnvironmentDirectory()) + return &showAction{ projectConfig: projectConfig, importManager: importManager, @@ -133,6 +138,7 @@ func NewShowAction( lazyServiceManager: lazyServiceManager, lazyResourceManager: lazyResourceManager, portalUrlBase: cloud.PortalUrlBase, + stateCacheManager: stateCacheManager, } } @@ -196,11 +202,6 @@ func (s *showAction) Run(ctx context.Context) (*actions.ActionResult, error) { if subId = env.GetSubscriptionId(); subId == "" { log.Printf("provision has not been run, resource ids will not be available") } else { - resourceManager, err := s.lazyResourceManager.GetValue() - if err != nil { - return nil, err - } - envName := env.Name() if len(s.args) > 0 { @@ -213,32 +214,80 @@ func (s *showAction) Run(ctx context.Context) (*actions.ActionResult, error) { return nil, nil } - rgName, err = s.infraResourceManager.FindResourceGroupForEnvironment(ctx, subId, envName) - if err == nil { - for _, serviceConfig := range stableServices { - svcName := serviceConfig.Name - resources, err := resourceManager.GetServiceResources(ctx, subId, rgName, serviceConfig) - if err == nil { - resourceIds := make([]string, len(resources)) - for idx, res := range resources { - resourceIds[idx] = res.Id - } + // Try to load from cache first + cachedState, err := s.stateCacheManager.Load(ctx, envName) + if err != nil { + log.Printf("error loading cache: %v, will query Azure directly", err) + } - resSvc := res.Services[svcName] - resSvc.Target = &contracts.ShowTargetArm{ - ResourceIds: resourceIds, + if cachedState != nil && cachedState.SubscriptionId == subId { + // Use cached data + rgName = cachedState.ResourceGroupName + for svcName, cachedSvc := range cachedState.ServiceResources { + if resSvc, exists := res.Services[svcName]; exists { + if len(cachedSvc.ResourceIds) > 0 { + resSvc.Target = &contracts.ShowTargetArm{ + ResourceIds: cachedSvc.ResourceIds, + } } - resSvc.IngresUrl = s.serviceEndpoint(ctx, subId, serviceConfig, env) + resSvc.IngresUrl = cachedSvc.IngressUrl res.Services[svcName] = resSvc - } else { - log.Printf("ignoring error determining resource id for service %s: %v", svcName, err) } } } else { - log.Printf( - "ignoring error determining resource group for environment %s, resource ids will not be available: %v", - env.Name(), - err) + // Cache miss or invalid, query Azure and update cache + resourceManager, err := s.lazyResourceManager.GetValue() + if err != nil { + return nil, err + } + + rgName, err = s.infraResourceManager.FindResourceGroupForEnvironment(ctx, subId, envName) + if err == nil { + // Create cache for this query + newCache := &state.StateCache{ + SubscriptionId: subId, + ResourceGroupName: rgName, + ServiceResources: make(map[string]state.ServiceResourceCache), + } + + for _, serviceConfig := range stableServices { + svcName := serviceConfig.Name + resources, err := resourceManager.GetServiceResources(ctx, subId, rgName, serviceConfig) + if err == nil { + resourceIds := make([]string, len(resources)) + for idx, res := range resources { + resourceIds[idx] = res.Id + } + + ingressUrl := s.serviceEndpoint(ctx, subId, serviceConfig, env) + + resSvc := res.Services[svcName] + resSvc.Target = &contracts.ShowTargetArm{ + ResourceIds: resourceIds, + } + resSvc.IngresUrl = ingressUrl + res.Services[svcName] = resSvc + + // Add to cache + newCache.ServiceResources[svcName] = state.ServiceResourceCache{ + ResourceIds: resourceIds, + IngressUrl: ingressUrl, + } + } else { + log.Printf("ignoring error determining resource id for service %s: %v", svcName, err) + } + } + + // Save cache + if err := s.stateCacheManager.Save(ctx, envName, newCache); err != nil { + log.Printf("error saving cache: %v", err) + } + } else { + log.Printf( + "ignoring error determining resource group for environment %s, resource ids will not be available: %v", + env.Name(), + err) + } } } } diff --git a/cli/azd/pkg/state/state_cache.go b/cli/azd/pkg/state/state_cache.go new file mode 100644 index 00000000000..b064764f86c --- /dev/null +++ b/cli/azd/pkg/state/state_cache.go @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package state + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// StateCache represents cached Azure resource information for an environment +type StateCache struct { + // Version of the cache format + Version int `json:"version"` + // Timestamp when the cache was last updated + UpdatedAt time.Time `json:"updatedAt"` + // Subscription ID + SubscriptionId string `json:"subscriptionId,omitempty"` + // Resource group name + ResourceGroupName string `json:"resourceGroupName,omitempty"` + // Service resources mapped by service name + ServiceResources map[string]ServiceResourceCache `json:"serviceResources,omitempty"` +} + +// ServiceResourceCache represents cached resource information for a service +type ServiceResourceCache struct { + // Resource IDs associated with this service + ResourceIds []string `json:"resourceIds,omitempty"` + // Ingress URL for the service + IngressUrl string `json:"ingressUrl,omitempty"` +} + +const ( + StateCacheVersion = 1 + StateCacheFileName = ".state.json" + StateChangeFileName = ".state-change" + DefaultCacheTTLDuration = 24 * time.Hour +) + +// StateCacheManager manages the state cache for environments +type StateCacheManager struct { + rootPath string + ttl time.Duration +} + +// NewStateCacheManager creates a new state cache manager +func NewStateCacheManager(rootPath string) *StateCacheManager { + return &StateCacheManager{ + rootPath: rootPath, + ttl: DefaultCacheTTLDuration, + } +} + +// SetTTL sets the time-to-live for cached data +func (m *StateCacheManager) SetTTL(ttl time.Duration) { + m.ttl = ttl +} + +// GetCachePath returns the path to the cache file for an environment +func (m *StateCacheManager) GetCachePath(envName string) string { + return filepath.Join(m.rootPath, envName, StateCacheFileName) +} + +// GetStateChangePath returns the path to the state change notification file +func (m *StateCacheManager) GetStateChangePath() string { + return filepath.Join(m.rootPath, StateChangeFileName) +} + +// Load loads the state cache for an environment +func (m *StateCacheManager) Load(ctx context.Context, envName string) (*StateCache, error) { + cachePath := m.GetCachePath(envName) + + data, err := os.ReadFile(cachePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil // Cache doesn't exist, not an error + } + return nil, fmt.Errorf("reading cache file: %w", err) + } + + var cache StateCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("parsing cache file: %w", err) + } + + // Check if cache is expired + if m.ttl > 0 && time.Since(cache.UpdatedAt) > m.ttl { + return nil, nil // Cache is expired, treat as if it doesn't exist + } + + return &cache, nil +} + +// Save saves the state cache for an environment +func (m *StateCacheManager) Save(ctx context.Context, envName string, cache *StateCache) error { + cache.Version = StateCacheVersion + cache.UpdatedAt = time.Now() + + cachePath := m.GetCachePath(envName) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { + return fmt.Errorf("creating cache directory: %w", err) + } + + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("serializing cache: %w", err) + } + + if err := os.WriteFile(cachePath, data, 0600); err != nil { + return fmt.Errorf("writing cache file: %w", err) + } + + // Update the state change notification file + if err := m.TouchStateChange(); err != nil { + return fmt.Errorf("updating state change file: %w", err) + } + + return nil +} + +// Invalidate removes the cache for an environment +func (m *StateCacheManager) Invalidate(ctx context.Context, envName string) error { + cachePath := m.GetCachePath(envName) + + err := os.Remove(cachePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing cache file: %w", err) + } + + // Update the state change notification file + if err := m.TouchStateChange(); err != nil { + return fmt.Errorf("updating state change file: %w", err) + } + + return nil +} + +// TouchStateChange updates the state change notification file +// This file is watched by IDEs/tools to know when to refresh their state +func (m *StateCacheManager) TouchStateChange() error { + stateChangePath := m.GetStateChangePath() + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(stateChangePath), 0755); err != nil { + return fmt.Errorf("creating state change directory: %w", err) + } + + // Write current timestamp to the file + timestamp := time.Now().Format(time.RFC3339) + if err := os.WriteFile(stateChangePath, []byte(timestamp), 0600); err != nil { + return fmt.Errorf("writing state change file: %w", err) + } + + return nil +} + +// GetStateChangeTime returns the last time the state changed +func (m *StateCacheManager) GetStateChangeTime() (time.Time, error) { + stateChangePath := m.GetStateChangePath() + + data, err := os.ReadFile(stateChangePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return time.Time{}, nil + } + return time.Time{}, fmt.Errorf("reading state change file: %w", err) + } + + timestamp, err := time.Parse(time.RFC3339, string(data)) + if err != nil { + return time.Time{}, fmt.Errorf("parsing timestamp: %w", err) + } + + return timestamp, nil +} diff --git a/cli/azd/pkg/state/state_cache_test.go b/cli/azd/pkg/state/state_cache_test.go new file mode 100644 index 00000000000..33eb0b880e2 --- /dev/null +++ b/cli/azd/pkg/state/state_cache_test.go @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package state + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStateCacheManager_SaveAndLoad(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + ctx := context.Background() + + cache := &StateCache{ + SubscriptionId: "sub-123", + ResourceGroupName: "rg-test", + ServiceResources: map[string]ServiceResourceCache{ + "web": { + ResourceIds: []string{"/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.Web/sites/web"}, + IngressUrl: "https://web.azurewebsites.net", + }, + }, + } + + // Save cache + err := manager.Save(ctx, "test-env", cache) + require.NoError(t, err) + + // Load cache + loaded, err := manager.Load(ctx, "test-env") + require.NoError(t, err) + require.NotNil(t, loaded) + require.Equal(t, cache.SubscriptionId, loaded.SubscriptionId) + require.Equal(t, cache.ResourceGroupName, loaded.ResourceGroupName) + require.Equal(t, cache.ServiceResources["web"].ResourceIds, loaded.ServiceResources["web"].ResourceIds) + require.Equal(t, cache.ServiceResources["web"].IngressUrl, loaded.ServiceResources["web"].IngressUrl) +} + +func TestStateCacheManager_LoadNonExistent(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + ctx := context.Background() + + // Load non-existent cache + loaded, err := manager.Load(ctx, "non-existent") + require.NoError(t, err) + require.Nil(t, loaded) +} + +func TestStateCacheManager_Invalidate(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + ctx := context.Background() + + cache := &StateCache{ + SubscriptionId: "sub-123", + ResourceGroupName: "rg-test", + } + + // Save cache + err := manager.Save(ctx, "test-env", cache) + require.NoError(t, err) + + // Invalidate cache + err = manager.Invalidate(ctx, "test-env") + require.NoError(t, err) + + // Load cache should return nil + loaded, err := manager.Load(ctx, "test-env") + require.NoError(t, err) + require.Nil(t, loaded) +} + +func TestStateCacheManager_TTL(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + manager.SetTTL(100 * time.Millisecond) // Very short TTL for testing + ctx := context.Background() + + cache := &StateCache{ + SubscriptionId: "sub-123", + ResourceGroupName: "rg-test", + } + + // Save cache + err := manager.Save(ctx, "test-env", cache) + require.NoError(t, err) + + // Load immediately should work + loaded, err := manager.Load(ctx, "test-env") + require.NoError(t, err) + require.NotNil(t, loaded) + + // Wait for TTL to expire + time.Sleep(150 * time.Millisecond) + + // Load after TTL should return nil + loaded, err = manager.Load(ctx, "test-env") + require.NoError(t, err) + require.Nil(t, loaded) +} + +func TestStateCacheManager_StateChangeFile(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + ctx := context.Background() + + cache := &StateCache{ + SubscriptionId: "sub-123", + ResourceGroupName: "rg-test", + } + + // Save cache should create state change file + err := manager.Save(ctx, "test-env", cache) + require.NoError(t, err) + + stateChangePath := manager.GetStateChangePath() + _, err = os.Stat(stateChangePath) + require.NoError(t, err, "State change file should exist") + + // Get state change time + changeTime, err := manager.GetStateChangeTime() + require.NoError(t, err) + require.False(t, changeTime.IsZero()) + + // Wait a bit and invalidate to update the timestamp + time.Sleep(100 * time.Millisecond) + err = manager.Invalidate(ctx, "test-env") + require.NoError(t, err) + + // State change time should be updated + newChangeTime, err := manager.GetStateChangeTime() + require.NoError(t, err) + require.True(t, newChangeTime.After(changeTime) || newChangeTime.Equal(changeTime), + "Expected new time %v to be after or equal to %v", newChangeTime, changeTime) +} + +func TestStateCacheManager_GetCachePath(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + + cachePath := manager.GetCachePath("test-env") + expectedPath := filepath.Join(tempDir, "test-env", StateCacheFileName) + require.Equal(t, expectedPath, cachePath) +} + +func TestStateCacheManager_GetStateChangePath(t *testing.T) { + tempDir := t.TempDir() + manager := NewStateCacheManager(tempDir) + + stateChangePath := manager.GetStateChangePath() + expectedPath := filepath.Join(tempDir, StateChangeFileName) + require.Equal(t, expectedPath, stateChangePath) +} From 9e4e0a73e3affba45ab7fd822e784d899ef061a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:33:34 +0000 Subject: [PATCH 3/7] Update provision, deploy, and down commands to invalidate cache - Added cache invalidation to provision command after successful provisioning - Added cache invalidation to deploy command after successful deployment - Added cache invalidation to down command after successful resource deletion - All state-changing commands now touch .state-change file to notify tools - Cache files are already excluded via existing .azure gitignore entry Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/cmd/down.go | 12 ++++++++++++ cli/azd/internal/cmd/deploy.go | 7 +++++++ cli/azd/internal/cmd/provision.go | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 6f63832b4fb..0dda39d3d0d 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "slices" "time" @@ -15,12 +16,14 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" inf "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -71,6 +74,7 @@ type downAction struct { console input.Console projectConfig *project.ProjectConfig alphaFeatureManager *alpha.FeatureManager + azdCtx *azdcontext.AzdContext } func newDownAction( @@ -82,6 +86,7 @@ func newDownAction( console input.Console, alphaFeatureManager *alpha.FeatureManager, importManager *project.ImportManager, + azdCtx *azdcontext.AzdContext, ) actions.Action { return &downAction{ flags: flags, @@ -92,6 +97,7 @@ func newDownAction( importManager: importManager, alphaFeatureManager: alphaFeatureManager, args: args, + azdCtx: azdCtx, } } @@ -150,6 +156,12 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } + // Invalidate cache after successful down so azd show will refresh + stateCacheManager := state.NewStateCacheManager(a.azdCtx.EnvironmentDirectory()) + if err := stateCacheManager.Invalidate(ctx, a.env.Name()); err != nil { + log.Printf("warning: failed to invalidate state cache: %v", err) + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ Header: fmt.Sprintf("Your application was removed from Azure in %s.", ux.DurationAsText(since(startTime))), diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 9669efc9197..74f9c826ad3 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -28,6 +28,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -362,6 +363,12 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) } } + // Invalidate cache after successful deploy so azd show will refresh + stateCacheManager := state.NewStateCacheManager(da.azdCtx.EnvironmentDirectory()) + if err := stateCacheManager.Invalidate(ctx, da.env.Name()); err != nil { + log.Printf("warning: failed to invalidate state cache: %v", err) + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ Header: fmt.Sprintf("Your application was deployed to Azure in %s.", ux.DurationAsText(since(startTime))), diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index a4e75131c12..ecbc3dcc2d8 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -20,11 +20,13 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" "go.uber.org/multierr" @@ -117,6 +119,7 @@ type ProvisionAction struct { importManager *project.ImportManager alphaFeatureManager *alpha.FeatureManager portalUrlBase string + azdCtx *azdcontext.AzdContext } func NewProvisionAction( @@ -135,6 +138,7 @@ func NewProvisionAction( subManager *account.SubscriptionsManager, alphaFeatureManager *alpha.FeatureManager, cloud *cloud.Cloud, + azdCtx *azdcontext.AzdContext, ) actions.Action { return &ProvisionAction{ args: args, @@ -152,6 +156,7 @@ func NewProvisionAction( importManager: importManager, alphaFeatureManager: alphaFeatureManager, portalUrlBase: cloud.PortalUrlBase, + azdCtx: azdCtx, } } @@ -414,6 +419,12 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error ) } } + + // Invalidate cache after successful provisioning so next azd show will refresh + stateCacheManager := state.NewStateCacheManager(p.azdCtx.EnvironmentDirectory()) + if err := stateCacheManager.Invalidate(ctx, p.env.Name()); err != nil { + log.Printf("warning: failed to invalidate state cache: %v", err) + } } if allSkipped { From c34d7900cd600d1c2637c7ad73713344150d4046 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:39:51 +0000 Subject: [PATCH 4/7] Fix linting issue - line length in show.go - Split long log message to comply with 125 character line limit - All linting now passes (0 issues) - All tests still passing Co-authored-by: spboyer <7681382+spboyer@users.noreply.github.com> --- cli/azd/internal/cmd/show/show.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/internal/cmd/show/show.go b/cli/azd/internal/cmd/show/show.go index 9b3ebb32ac7..062af0100d4 100644 --- a/cli/azd/internal/cmd/show/show.go +++ b/cli/azd/internal/cmd/show/show.go @@ -284,7 +284,8 @@ func (s *showAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } else { log.Printf( - "ignoring error determining resource group for environment %s, resource ids will not be available: %v", + "ignoring error determining resource group for environment %s, "+ + "resource ids will not be available: %v", env.Name(), err) } From 37fc354055bd28c429010a05219099fb6dd34fce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:49:45 +0000 Subject: [PATCH 5/7] Refactor: Integrate StateCacheManager into EnvironmentManager via IoC - Moved StateCacheManager into environment.Manager following IoC pattern - Added InvalidateEnvCache() and GetStateCacheManager() methods to Manager interface - Updated show, provision, deploy, down actions to use envManager for cache operations - Added context cancellation checks in Load, Save, and Invalidate methods - Moved cache invalidation in provision outside the layers loop - Removed direct StateCacheManager instantiation from action constructors - All actions now properly use dependency injection via environment.Manager Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/cmd/down.go | 11 ++++------- cli/azd/internal/cmd/deploy.go | 7 ++++--- cli/azd/internal/cmd/provision.go | 16 +++++----------- cli/azd/internal/cmd/show/show.go | 5 +---- cli/azd/pkg/environment/manager.go | 30 +++++++++++++++++++++++++----- cli/azd/pkg/state/state_cache.go | 25 +++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 30 deletions(-) diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 0dda39d3d0d..27c854fca80 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -16,14 +16,12 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/environment" - "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" inf "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" - "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -71,10 +69,10 @@ type downAction struct { provisionManager *provisioning.Manager importManager *project.ImportManager env *environment.Environment + envManager environment.Manager console input.Console projectConfig *project.ProjectConfig alphaFeatureManager *alpha.FeatureManager - azdCtx *azdcontext.AzdContext } func newDownAction( @@ -82,22 +80,22 @@ func newDownAction( flags *downFlags, provisionManager *provisioning.Manager, env *environment.Environment, + envManager environment.Manager, projectConfig *project.ProjectConfig, console input.Console, alphaFeatureManager *alpha.FeatureManager, importManager *project.ImportManager, - azdCtx *azdcontext.AzdContext, ) actions.Action { return &downAction{ flags: flags, provisionManager: provisionManager, env: env, + envManager: envManager, console: console, projectConfig: projectConfig, importManager: importManager, alphaFeatureManager: alphaFeatureManager, args: args, - azdCtx: azdCtx, } } @@ -157,8 +155,7 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } // Invalidate cache after successful down so azd show will refresh - stateCacheManager := state.NewStateCacheManager(a.azdCtx.EnvironmentDirectory()) - if err := stateCacheManager.Invalidate(ctx, a.env.Name()); err != nil { + if err := a.envManager.InvalidateEnvCache(ctx, a.env.Name()); err != nil { log.Printf("warning: failed to invalidate state cache: %v", err) } diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 74f9c826ad3..8e09dd2953a 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -28,7 +28,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" - "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -114,6 +113,7 @@ type DeployAction struct { projectConfig *project.ProjectConfig azdCtx *azdcontext.AzdContext env *environment.Environment + envManager environment.Manager projectManager project.ProjectManager serviceManager project.ServiceManager resourceManager project.ResourceManager @@ -137,6 +137,7 @@ func NewDeployAction( resourceManager project.ResourceManager, azdCtx *azdcontext.AzdContext, environment *environment.Environment, + envManager environment.Manager, accountManager account.Manager, cloud *cloud.Cloud, azCli *azapi.AzureClient, @@ -153,6 +154,7 @@ func NewDeployAction( projectConfig: projectConfig, azdCtx: azdCtx, env: environment, + envManager: envManager, projectManager: projectManager, serviceManager: serviceManager, resourceManager: resourceManager, @@ -364,8 +366,7 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) } // Invalidate cache after successful deploy so azd show will refresh - stateCacheManager := state.NewStateCacheManager(da.azdCtx.EnvironmentDirectory()) - if err := stateCacheManager.Invalidate(ctx, da.env.Name()); err != nil { + if err := da.envManager.InvalidateEnvCache(ctx, da.env.Name()); err != nil { log.Printf("warning: failed to invalidate state cache: %v", err) } diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index ecbc3dcc2d8..f679d192abe 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -20,13 +20,11 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/environment" - "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" - "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/spf13/cobra" "github.com/spf13/pflag" "go.uber.org/multierr" @@ -119,7 +117,6 @@ type ProvisionAction struct { importManager *project.ImportManager alphaFeatureManager *alpha.FeatureManager portalUrlBase string - azdCtx *azdcontext.AzdContext } func NewProvisionAction( @@ -138,7 +135,6 @@ func NewProvisionAction( subManager *account.SubscriptionsManager, alphaFeatureManager *alpha.FeatureManager, cloud *cloud.Cloud, - azdCtx *azdcontext.AzdContext, ) actions.Action { return &ProvisionAction{ args: args, @@ -156,7 +152,6 @@ func NewProvisionAction( importManager: importManager, alphaFeatureManager: alphaFeatureManager, portalUrlBase: cloud.PortalUrlBase, - azdCtx: azdCtx, } } @@ -419,12 +414,6 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error ) } } - - // Invalidate cache after successful provisioning so next azd show will refresh - stateCacheManager := state.NewStateCacheManager(p.azdCtx.EnvironmentDirectory()) - if err := stateCacheManager.Invalidate(ctx, p.env.Name()); err != nil { - log.Printf("warning: failed to invalidate state cache: %v", err) - } } if allSkipped { @@ -435,6 +424,11 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error }, nil } + // Invalidate cache after successful provisioning so next azd show will refresh + if err := p.envManager.InvalidateEnvCache(ctx, p.env.Name()); err != nil { + log.Printf("warning: failed to invalidate state cache: %v", err) + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ Header: fmt.Sprintf( diff --git a/cli/azd/internal/cmd/show/show.go b/cli/azd/internal/cmd/show/show.go index 062af0100d4..eca759ca2ce 100644 --- a/cli/azd/internal/cmd/show/show.go +++ b/cli/azd/internal/cmd/show/show.go @@ -116,9 +116,6 @@ func NewShowAction( lazyResourceManager *lazy.Lazy[project.ResourceManager], cloud *cloud.Cloud, ) actions.Action { - // Create state cache manager with the environment directory - stateCacheManager := state.NewStateCacheManager(azdCtx.EnvironmentDirectory()) - return &showAction{ projectConfig: projectConfig, importManager: importManager, @@ -138,7 +135,7 @@ func NewShowAction( lazyServiceManager: lazyServiceManager, lazyResourceManager: lazyResourceManager, portalUrlBase: cloud.PortalUrlBase, - stateCacheManager: stateCacheManager, + stateCacheManager: envManager.GetStateCacheManager(), } } diff --git a/cli/azd/pkg/environment/manager.go b/cli/azd/pkg/environment/manager.go index 6a38e1c58ad..4a057b5a15b 100644 --- a/cli/azd/pkg/environment/manager.go +++ b/cli/azd/pkg/environment/manager.go @@ -82,6 +82,12 @@ type Manager interface { EnvPath(env *Environment) string ConfigPath(env *Environment) string + + // InvalidateEnvCache invalidates the state cache for the given environment + InvalidateEnvCache(ctx context.Context, envName string) error + + // GetStateCacheManager returns the state cache manager for accessing cached state + GetStateCacheManager() *state.StateCacheManager } type manager struct { @@ -94,6 +100,9 @@ type manager struct { // across different scopes, enabling shared state mutation (e.g., from extensions) cacheMu sync.RWMutex envCache map[string]*Environment + + // State cache manager for managing cached Azure resource information + stateCacheManager *state.StateCacheManager } // NewManager creates a new Manager instance @@ -125,11 +134,12 @@ func NewManager( } return &manager{ - azdContext: azdContext, - local: local, - remote: remote, - console: console, - envCache: make(map[string]*Environment), + azdContext: azdContext, + local: local, + remote: remote, + console: console, + envCache: make(map[string]*Environment), + stateCacheManager: state.NewStateCacheManager(azdContext.EnvironmentDirectory()), }, nil } @@ -564,6 +574,16 @@ func (m *manager) ensureValidEnvironmentName(ctx context.Context, spec *Spec) er return nil } +// InvalidateEnvCache invalidates the state cache for the given environment +func (m *manager) InvalidateEnvCache(ctx context.Context, envName string) error { + return m.stateCacheManager.Invalidate(ctx, envName) +} + +// GetStateCacheManager returns the state cache manager for accessing cached state +func (m *manager) GetStateCacheManager() *state.StateCacheManager { + return m.stateCacheManager +} + func invalidEnvironmentNameMsg(environmentName string) string { return fmt.Sprintf( "environment name '%s' is invalid (it should contain only alphanumeric characters and hyphens)\n", diff --git a/cli/azd/pkg/state/state_cache.go b/cli/azd/pkg/state/state_cache.go index b064764f86c..8ebb44deb7e 100644 --- a/cli/azd/pkg/state/state_cache.go +++ b/cli/azd/pkg/state/state_cache.go @@ -73,6 +73,11 @@ func (m *StateCacheManager) GetStateChangePath() string { // Load loads the state cache for an environment func (m *StateCacheManager) Load(ctx context.Context, envName string) (*StateCache, error) { + // Check for context cancellation + if err := ctx.Err(); err != nil { + return nil, err + } + cachePath := m.GetCachePath(envName) data, err := os.ReadFile(cachePath) @@ -98,6 +103,11 @@ func (m *StateCacheManager) Load(ctx context.Context, envName string) (*StateCac // Save saves the state cache for an environment func (m *StateCacheManager) Save(ctx context.Context, envName string, cache *StateCache) error { + // Check for context cancellation + if err := ctx.Err(); err != nil { + return err + } + cache.Version = StateCacheVersion cache.UpdatedAt = time.Now() @@ -117,6 +127,11 @@ func (m *StateCacheManager) Save(ctx context.Context, envName string, cache *Sta return fmt.Errorf("writing cache file: %w", err) } + // Check for context cancellation before updating state change file + if err := ctx.Err(); err != nil { + return err + } + // Update the state change notification file if err := m.TouchStateChange(); err != nil { return fmt.Errorf("updating state change file: %w", err) @@ -127,6 +142,11 @@ func (m *StateCacheManager) Save(ctx context.Context, envName string, cache *Sta // Invalidate removes the cache for an environment func (m *StateCacheManager) Invalidate(ctx context.Context, envName string) error { + // Check for context cancellation + if err := ctx.Err(); err != nil { + return err + } + cachePath := m.GetCachePath(envName) err := os.Remove(cachePath) @@ -134,6 +154,11 @@ func (m *StateCacheManager) Invalidate(ctx context.Context, envName string) erro return fmt.Errorf("removing cache file: %w", err) } + // Check for context cancellation before updating state change file + if err := ctx.Err(); err != nil { + return err + } + // Update the state change notification file if err := m.TouchStateChange(); err != nil { return fmt.Errorf("updating state change file: %w", err) From 70d14c89a9c1dc523b1ca02604d4685d6b83c7ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:46:03 +0000 Subject: [PATCH 6/7] Fix: Add missing methods to MockEnvManager for new Manager interface - Added InvalidateEnvCache() method to mock - Added GetStateCacheManager() method to mock - Fixes compilation errors in pipeline and project tests - All tests now passing Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- cli/azd/test/mocks/mockenv/mock_manager.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli/azd/test/mocks/mockenv/mock_manager.go b/cli/azd/test/mocks/mockenv/mock_manager.go index b3db301cfc7..0b2a9644e81 100644 --- a/cli/azd/test/mocks/mockenv/mock_manager.go +++ b/cli/azd/test/mocks/mockenv/mock_manager.go @@ -7,6 +7,7 @@ import ( "context" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/stretchr/testify/mock" ) @@ -67,3 +68,16 @@ func (m *MockEnvManager) Delete(ctx context.Context, name string) error { args := m.Called(name) return args.Error(0) } + +func (m *MockEnvManager) InvalidateEnvCache(ctx context.Context, envName string) error { + args := m.Called(ctx, envName) + return args.Error(0) +} + +func (m *MockEnvManager) GetStateCacheManager() *state.StateCacheManager { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*state.StateCacheManager) +} From 5959ec2b46e5ac74531598a34a0bdc99bbefc562 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:51:52 +0000 Subject: [PATCH 7/7] Fix: Handle nil azdContext in environment.Manager initialization - Added nil check for azdContext before calling EnvironmentDirectory() - StateCacheManager now initialized with empty path when no project exists - Fixes panic in functional tests when commands run without a project - Commands like provision, deploy, down now handle missing project gracefully Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- cli/azd/pkg/environment/manager.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/environment/manager.go b/cli/azd/pkg/environment/manager.go index 4a057b5a15b..51975a72bf7 100644 --- a/cli/azd/pkg/environment/manager.go +++ b/cli/azd/pkg/environment/manager.go @@ -133,13 +133,20 @@ func NewManager( } } + // Initialize state cache manager with environment directory path + // If azdContext is nil (no project), use empty path (cache won't be usable) + envDir := "" + if azdContext != nil { + envDir = azdContext.EnvironmentDirectory() + } + return &manager{ azdContext: azdContext, local: local, remote: remote, console: console, envCache: make(map[string]*Environment), - stateCacheManager: state.NewStateCacheManager(azdContext.EnvironmentDirectory()), + stateCacheManager: state.NewStateCacheManager(envDir), }, nil }