diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 6f63832b4fb..27c854fca80 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "slices" "time" @@ -68,6 +69,7 @@ type downAction struct { provisionManager *provisioning.Manager importManager *project.ImportManager env *environment.Environment + envManager environment.Manager console input.Console projectConfig *project.ProjectConfig alphaFeatureManager *alpha.FeatureManager @@ -78,6 +80,7 @@ func newDownAction( flags *downFlags, provisionManager *provisioning.Manager, env *environment.Environment, + envManager environment.Manager, projectConfig *project.ProjectConfig, console input.Console, alphaFeatureManager *alpha.FeatureManager, @@ -87,6 +90,7 @@ func newDownAction( flags: flags, provisionManager: provisionManager, env: env, + envManager: envManager, console: console, projectConfig: projectConfig, importManager: importManager, @@ -150,6 +154,11 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } + // Invalidate cache after successful down so azd show will refresh + if err := a.envManager.InvalidateEnvCache(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..8e09dd2953a 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -113,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 @@ -136,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, @@ -152,6 +154,7 @@ func NewDeployAction( projectConfig: projectConfig, azdCtx: azdCtx, env: environment, + envManager: envManager, projectManager: projectManager, serviceManager: serviceManager, resourceManager: resourceManager, @@ -362,6 +365,11 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) } } + // Invalidate cache after successful deploy so azd show will refresh + if err := da.envManager.InvalidateEnvCache(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..f679d192abe 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -424,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 1bf5d907ff7..eca759ca2ce 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( @@ -133,6 +135,7 @@ func NewShowAction( lazyServiceManager: lazyServiceManager, lazyResourceManager: lazyResourceManager, portalUrlBase: cloud.PortalUrlBase, + stateCacheManager: envManager.GetStateCacheManager(), } } @@ -196,11 +199,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 +211,81 @@ 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/environment/manager.go b/cli/azd/pkg/environment/manager.go index 6a38e1c58ad..51975a72bf7 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 @@ -124,12 +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), + azdContext: azdContext, + local: local, + remote: remote, + console: console, + envCache: make(map[string]*Environment), + stateCacheManager: state.NewStateCacheManager(envDir), }, nil } @@ -564,6 +581,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 new file mode 100644 index 00000000000..8ebb44deb7e --- /dev/null +++ b/cli/azd/pkg/state/state_cache.go @@ -0,0 +1,207 @@ +// 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) { + // Check for context cancellation + if err := ctx.Err(); err != nil { + return nil, err + } + + 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 { + // Check for context cancellation + if err := ctx.Err(); err != nil { + return err + } + + 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) + } + + // 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) + } + + return nil +} + +// 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) + if err != nil && !errors.Is(err, os.ErrNotExist) { + 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) + } + + 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) +} 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) +}