diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index 5b9d999a87d..6df0849392f 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -90,7 +90,7 @@ func (m *HooksMiddleware) registerCommandHooks( m.console, projectConfig.Path, projectConfig.Hooks, - env.Environ(), + env, ) var actionResult *actions.ActionResult @@ -142,7 +142,7 @@ func (m *HooksMiddleware) registerServiceHooks( m.console, service.Path(), service.Hooks, - env.Environ(), + env, ) for hookName, hookConfig := range service.Hooks { diff --git a/cli/azd/pkg/config/manager.go b/cli/azd/pkg/config/manager.go index f8b90b5f5f7..ca6e21ea399 100644 --- a/cli/azd/pkg/config/manager.go +++ b/cli/azd/pkg/config/manager.go @@ -54,6 +54,11 @@ func (c *manager) Save(config Config, filePath string) error { return fmt.Errorf("failed marshalling config JSON: %w", err) } + folderPath := filepath.Dir(filePath) + if err := os.MkdirAll(folderPath, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("failed creating config directory: %w", err) + } + err = os.WriteFile(filePath, configJson, osutil.PermissionFile) if err != nil { return fmt.Errorf("failed writing configuration data: %w", err) diff --git a/cli/azd/pkg/environment/environment.go b/cli/azd/pkg/environment/environment.go index 994d4d2222b..7c64d03c5f8 100644 --- a/cli/azd/pkg/environment/environment.go +++ b/cli/azd/pkg/environment/environment.go @@ -75,24 +75,8 @@ func FromRoot(root string) (*Environment, error) { Root: root, } - envPath := filepath.Join(root, azdcontext.DotEnvFileName) - if e, err := godotenv.Read(envPath); errors.Is(err, os.ErrNotExist) { - env.Values = make(map[string]string) - } else if err != nil { - return EmptyWithRoot(root), fmt.Errorf("loading .env: %w", err) - } else { - env.Values = e - } - - cfgPath := filepath.Join(root, azdcontext.ConfigFileName) - - cfgMgr := config.NewManager() - if cfg, err := cfgMgr.Load(cfgPath); errors.Is(err, os.ErrNotExist) { - env.Config = config.NewConfig(nil) - } else if err != nil { - return EmptyWithRoot(root), fmt.Errorf("loading config: %w", err) - } else { - env.Config = cfg + if err := env.Reload(); err != nil { + return EmptyWithRoot(root), err } return env, nil @@ -144,6 +128,32 @@ func (e *Environment) Getenv(key string) string { return os.Getenv(key) } +// Reloads environment variables and configuration +func (e *Environment) Reload() error { + // Reload env values + envPath := filepath.Join(e.Root, azdcontext.DotEnvFileName) + if envMap, err := godotenv.Read(envPath); errors.Is(err, os.ErrNotExist) { + e.Values = make(map[string]string) + } else if err != nil { + return fmt.Errorf("loading .env: %w", err) + } else { + e.Values = envMap + } + + // Reload env config + cfgPath := filepath.Join(e.Root, azdcontext.ConfigFileName) + cfgMgr := config.NewManager() + if cfg, err := cfgMgr.Load(cfgPath); errors.Is(err, os.ErrNotExist) { + e.Config = config.NewConfig(nil) + } else if err != nil { + return fmt.Errorf("loading config: %w", err) + } else { + e.Config = cfg + } + + return nil +} + // If `Root` is set, Save writes the current contents of the environment to // the given directory, creating it and any intermediate directories as needed. func (e *Environment) Save() error { @@ -151,6 +161,23 @@ func (e *Environment) Save() error { return nil } + // Update configuration + cfgMgr := config.NewManager() + if err := cfgMgr.Save(e.Config, filepath.Join(e.Root, azdcontext.ConfigFileName)); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + // Cache current values & reload to get any new env vars + currentValues := e.Values + if err := e.Reload(); err != nil { + return fmt.Errorf("failed reloading env vars, %w", err) + } + + // Overlay current values before saving + for key, value := range currentValues { + e.Values[key] = value + } + err := os.MkdirAll(e.Root, osutil.PermissionDirectory) if err != nil { return fmt.Errorf("failed to create a directory: %w", err) @@ -161,13 +188,6 @@ func (e *Environment) Save() error { return fmt.Errorf("saving .env: %w", err) } - cfgMgr := config.NewManager() - - err = cfgMgr.Save(e.Config, filepath.Join(e.Root, azdcontext.ConfigFileName)) - if err != nil { - return fmt.Errorf("saving config: %w", err) - } - return nil } diff --git a/cli/azd/pkg/environment/environment_test.go b/cli/azd/pkg/environment/environment_test.go index 7949ed41e39..5e1fe3ae5cb 100644 --- a/cli/azd/pkg/environment/environment_test.go +++ b/cli/azd/pkg/environment/environment_test.go @@ -8,7 +8,10 @@ import ( "path/filepath" "testing" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/test/ostest" + "github.com/joho/godotenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -106,3 +109,41 @@ func TestFromRoot(t *testing.T) { require.True(t, e.Config.IsEmpty()) }) } + +func Test_SaveAndReload(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + env, err := FromRoot(tempDir) + require.NotNil(t, env) + require.NoError(t, err) + + env.SetLocation("eastus2") + env.SetSubscriptionId("SUBSCRIPTION_ID") + + err = env.Save() + require.NoError(t, err) + + // Simulate another process updating the .env file + envPath := filepath.Join(tempDir, azdcontext.DotEnvFileName) + envMap, err := godotenv.Read(envPath) + require.NotNil(t, envMap) + require.NoError(t, err) + + // This entry does not exist in the current env state but is added as part of the reload process + envMap["SERVICE_API_ENDPOINT_URL"] = "http://api.example.com" + err = godotenv.Write(envMap, envPath) + require.NoError(t, err) + + // Set a new property in the env + env.SetServiceProperty("web", "ENDPOINT_URL", "http://web.example.com") + err = env.Save() + require.NoError(t, err) + + // Verify all values exist with expected values + // All values now exist whether or not they were in the env state to start with + require.Equal(t, env.Values["SERVICE_WEB_ENDPOINT_URL"], "http://web.example.com") + require.Equal(t, env.Values["SERVICE_API_ENDPOINT_URL"], "http://api.example.com") + require.Equal(t, "SUBSCRIPTION_ID", env.GetSubscriptionId()) + require.Equal(t, "eastus2", env.GetLocation()) +} diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 925c75f9bee..1d0702d065f 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -23,7 +24,7 @@ type HooksRunner struct { console input.Console cwd string hooks map[string]*HookConfig - envVars []string + env *environment.Environment } // NewHooks creates a new instance of CommandHooks @@ -34,7 +35,7 @@ func NewHooksRunner( console input.Console, cwd string, hooks map[string]*HookConfig, - envVars []string, + env *environment.Environment, ) *HooksRunner { if cwd == "" { osWd, err := os.Getwd() @@ -51,7 +52,7 @@ func NewHooksRunner( console: console, cwd: cwd, hooks: hooks, - envVars: envVars, + env: env, } } @@ -82,6 +83,11 @@ func (h *HooksRunner) RunHooks(ctx context.Context, hookType HookType, commands return fmt.Errorf("failed running scripts for hooks '%s', %w", strings.Join(commands, ","), err) } + // Reload env vars before execution to enable support for hooks to generate new env vars between commands + if err := h.env.Reload(); err != nil { + return fmt.Errorf("failed reloading env values, %w", err) + } + for _, hookConfig := range hooks { err := h.execHook(ctx, hookConfig) if err != nil { @@ -101,9 +107,9 @@ func (h *HooksRunner) GetScript(hookConfig *HookConfig) (tools.Script, error) { switch hookConfig.Shell { case ShellTypeBash: - return bash.NewBashScript(h.commandRunner, h.cwd, h.envVars), nil + return bash.NewBashScript(h.commandRunner, h.cwd, h.env.Environ()), nil case ShellTypePowershell: - return powershell.NewPowershellScript(h.commandRunner, h.cwd, h.envVars), nil + return powershell.NewPowershellScript(h.commandRunner, h.cwd, h.env.Environ()), nil default: return nil, fmt.Errorf( "shell type '%s' is not a valid option. Only 'sh' and 'pwsh' are supported", diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 19a53d85684..1d3afe42043 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -18,10 +19,13 @@ func Test_Hooks_Execute(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) - env := []string{ - "a=apple", - "b=banana", - } + env := environment.EphemeralWithValues( + "test", + map[string]string{ + "a": "apple", + "b": "banana", + }, + ) hooks := map[string]*HookConfig{ "preinline": { @@ -56,7 +60,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPreHook = true require.Equal(t, "scripts/precommand.sh", args.Args[0]) require.Equal(t, cwd, args.Cwd) - require.Equal(t, env, args.Env) + require.Equal(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) return exec.NewRunResult(0, "", ""), nil @@ -82,7 +86,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook = true require.Equal(t, "scripts/postcommand.sh", args.Args[0]) require.Equal(t, cwd, args.Cwd) - require.Equal(t, env, args.Env) + require.Equal(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) return exec.NewRunResult(0, "", ""), nil @@ -108,7 +112,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook = true require.Equal(t, "scripts/preinteractive.sh", args.Args[0]) require.Equal(t, cwd, args.Cwd) - require.Equal(t, env, args.Env) + require.Equal(t, env.Environ(), args.Env) require.Equal(t, true, args.Interactive) return exec.NewRunResult(0, "", ""), nil @@ -200,10 +204,13 @@ func Test_Hooks_GetScript(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) - env := []string{ - "a=apple", - "b=banana", - } + env := environment.EphemeralWithValues( + "test", + map[string]string{ + "a": "apple", + "b": "banana", + }, + ) hooks := map[string]*HookConfig{ "bash": { @@ -286,7 +293,7 @@ func Test_GetScript_Validation(t *testing.T) { err := os.WriteFile("my-script.ps1", nil, osutil.PermissionFile) require.NoError(t, err) - env := []string{} + env := environment.Ephemeral() mockContext := mocks.NewMockContext(context.Background()) hooksManager := NewHooksManager(tempDir) diff --git a/cli/azd/pkg/project/service_target_aks.go b/cli/azd/pkg/project/service_target_aks.go index dcaad117768..16fefb4e3aa 100644 --- a/cli/azd/pkg/project/service_target_aks.go +++ b/cli/azd/pkg/project/service_target_aks.go @@ -265,6 +265,17 @@ func (t *aksTarget) Deploy( return ServiceDeploymentResult{}, err } + if len(endpoints) > 0 { + // The AKS endpoints contain some additional identifying information + // Split on common to pull out the URL as the first segment + // The last endpoint in the array will be the most publicly exposed + endpointParts := strings.Split(endpoints[len(endpoints)-1], ",") + t.env.SetServiceProperty(t.config.Name, "ENDPOINT_URL", endpointParts[0]) + if err := t.env.Save(); err != nil { + return ServiceDeploymentResult{}, fmt.Errorf("failed updating environment with endpoint url, %w", err) + } + } + return ServiceDeploymentResult{ TargetResourceId: azure.KubernetesServiceRID( t.env.GetSubscriptionId(), @@ -455,11 +466,11 @@ func (t *aksTarget) getServiceEndpoints(ctx context.Context, namespace string, s var endpoints []string if service.Spec.Type == kubectl.ServiceTypeLoadBalancer { for _, resource := range service.Status.LoadBalancer.Ingress { - endpoints = append(endpoints, fmt.Sprintf("http://%s (Service, Type: LoadBalancer)", resource.Ip)) + endpoints = append(endpoints, fmt.Sprintf("http://%s, (Service, Type: LoadBalancer)", resource.Ip)) } } else if service.Spec.Type == kubectl.ServiceTypeClusterIp { for index, ip := range service.Spec.ClusterIps { - endpoints = append(endpoints, fmt.Sprintf("http://%s:%d (Service, Type: ClusterIP)", ip, service.Spec.Ports[index].Port)) + endpoints = append(endpoints, fmt.Sprintf("http://%s:%d, (Service, Type: ClusterIP)", ip, service.Spec.Ports[index].Port)) } } @@ -495,7 +506,7 @@ func (t *aksTarget) getIngressEndpoints(ctx context.Context, namespace string, r return nil, fmt.Errorf("failed constructing service endpoints, %w", err) } - endpoints = append(endpoints, fmt.Sprintf("%s (Ingress, Type: LoadBalancer)", endpointUrl)) + endpoints = append(endpoints, fmt.Sprintf("%s, (Ingress, Type: LoadBalancer)", endpointUrl)) } return endpoints, nil