diff --git a/cli/azd/cmd/monitor.go b/cli/azd/cmd/monitor.go index a1eea96ff58..810736d46df 100644 --- a/cli/azd/cmd/monitor.go +++ b/cli/azd/cmd/monitor.go @@ -6,6 +6,8 @@ package cmd import ( "context" "fmt" + "io" + "strings" "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" @@ -15,6 +17,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "github.com/azure/azure-dev/cli/azd/pkg/containerapps" "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" @@ -28,6 +31,7 @@ type monitorFlags struct { monitorLive bool monitorLogs bool monitorOverview bool + monitorTail bool global *internal.GlobalCommandOptions internal.EnvFlag } @@ -40,7 +44,15 @@ func (m *monitorFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommand "Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.", ) local.BoolVar(&m.monitorLogs, "logs", false, "Open a browser to Application Insights Logs.") - local.BoolVar(&m.monitorOverview, "overview", false, "Open a browser to Application Insights Overview Dashboard.") + local.BoolVar( + &m.monitorOverview, "overview", false, "Open a browser to Application Insights Overview Dashboard.", + ) + local.BoolVar( + &m.monitorTail, + "tail", + false, + "Stream application logs from a deployed service directly to the terminal.", + ) m.EnvFlag.Bind(local, global) m.global = global } @@ -65,6 +77,8 @@ type monitorAction struct { subResolver account.SubscriptionTenantResolver resourceManager infra.ResourceManager resourceService *azapi.ResourceService + azureClient *azapi.AzureClient + containerAppService containerapps.ContainerAppService console input.Console flags *monitorFlags portalUrlBase string @@ -77,6 +91,8 @@ func newMonitorAction( subResolver account.SubscriptionTenantResolver, resourceManager infra.ResourceManager, resourceService *azapi.ResourceService, + azureClient *azapi.AzureClient, + containerAppService containerapps.ContainerAppService, console input.Console, flags *monitorFlags, cloud *cloud.Cloud, @@ -87,6 +103,8 @@ func newMonitorAction( env: env, resourceManager: resourceManager, resourceService: resourceService, + azureClient: azureClient, + containerAppService: containerAppService, console: console, flags: flags, subResolver: subResolver, @@ -96,7 +114,8 @@ func newMonitorAction( } func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error) { - if !m.flags.monitorLive && !m.flags.monitorLogs && !m.flags.monitorOverview { + if !m.flags.monitorLive && !m.flags.monitorLogs && !m.flags.monitorOverview && + !m.flags.monitorTail { m.flags.monitorOverview = true } @@ -107,13 +126,20 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error) } } + // Handle --tail: stream application logs directly to the terminal + if m.flags.monitorTail { + return m.runTail(ctx) + } + aspireDashboard := apphost.AspireDashboardUrl(ctx, m.env, m.alphaFeaturesManager) if aspireDashboard != nil { openWithDefaultBrowser(ctx, m.console, aspireDashboard.Link) return nil, nil } - resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment(ctx, m.env.GetSubscriptionId(), m.env.Name()) + resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment( + ctx, m.env.GetSubscriptionId(), m.env.Name(), + ) if err != nil { return nil, fmt.Errorf("discovering resource groups from deployment: %w", err) } @@ -140,14 +166,20 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error) if len(insightsResources) == 0 && (m.flags.monitorLive || m.flags.monitorLogs) { return nil, &internal.ErrorWithSuggestion{ - Err: fmt.Errorf("no Application Insights resource found: %w", internal.ErrResourceNotConfigured), + Err: fmt.Errorf( + "no Application Insights resource found: %w", + internal.ErrResourceNotConfigured, + ), Suggestion: "Ensure your infrastructure includes an Application Insights component.", } } if len(portalResources) == 0 && m.flags.monitorOverview { return nil, &internal.ErrorWithSuggestion{ - Err: fmt.Errorf("no Application Insights dashboard found: %w", internal.ErrResourceNotConfigured), + Err: fmt.Errorf( + "no Application Insights dashboard found: %w", + internal.ErrResourceNotConfigured, + ), Suggestion: "Ensure your infrastructure includes an Application Insights dashboard.", } } @@ -160,25 +192,187 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error) for _, insightsResource := range insightsResources { if m.flags.monitorLive { openWithDefaultBrowser(ctx, m.console, - fmt.Sprintf("%s/#@%s/resource%s/quickPulse", m.portalUrlBase, tenantId, insightsResource.Id)) + fmt.Sprintf( + "%s/#@%s/resource%s/quickPulse", + m.portalUrlBase, tenantId, insightsResource.Id, + )) } if m.flags.monitorLogs { openWithDefaultBrowser(ctx, m.console, - fmt.Sprintf("%s/#@%s/resource%s/logs", m.portalUrlBase, tenantId, insightsResource.Id)) + fmt.Sprintf( + "%s/#@%s/resource%s/logs", + m.portalUrlBase, tenantId, insightsResource.Id, + )) } } for _, portalResource := range portalResources { if m.flags.monitorOverview { openWithDefaultBrowser(ctx, m.console, - fmt.Sprintf("%s/#@%s/dashboard/arm%s", m.portalUrlBase, tenantId, portalResource.Id)) + fmt.Sprintf( + "%s/#@%s/dashboard/arm%s", + m.portalUrlBase, tenantId, portalResource.Id, + )) + } + } + + return nil, nil +} + +// streamableResource represents a deployed Azure resource that supports log streaming. +type streamableResource struct { + Name string + ResourceGroup string + Type azapi.AzureResourceType +} + +// supportsLogStreaming reports whether the Azure resource type supports direct log streaming. +func supportsLogStreaming(resourceType string) bool { + switch azapi.AzureResourceType(resourceType) { + case azapi.AzureResourceTypeWebSite, + azapi.AzureResourceTypeContainerApp: + return true + } + return false +} + +// runTail discovers deployed resources and streams application logs to the terminal. +func (m *monitorAction) runTail(ctx context.Context) (*actions.ActionResult, error) { + resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment( + ctx, m.env.GetSubscriptionId(), m.env.Name(), + ) + if err != nil { + return nil, fmt.Errorf("discovering resource groups from deployment: %w", err) + } + + // Collect all resources that support log streaming + var streamable []streamableResource + for _, resourceGroup := range resourceGroups { + resources, err := m.resourceService.ListResourceGroupResources( + ctx, + azure.SubscriptionFromRID(resourceGroup.Id), + resourceGroup.Name, + nil, + ) + if err != nil { + return nil, fmt.Errorf("listing resources: %w", err) + } + + for _, resource := range resources { + if supportsLogStreaming(resource.Type) { + streamable = append(streamable, streamableResource{ + Name: resource.Name, + ResourceGroup: resourceGroup.Name, + Type: azapi.AzureResourceType(resource.Type), + }) + } + } + } + + if len(streamable) == 0 { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "no services that support log streaming found: %w", + internal.ErrResourceNotConfigured, + ), + Suggestion: "Ensure your project includes App Service, Azure Functions, or " + + "Container App resources.", + } + } + + // If there are multiple streamable resources, prompt the user to select one + selected := streamable[0] + if len(streamable) > 1 { + choices := make([]string, len(streamable)) + for i, r := range streamable { + displayType := azapi.GetResourceTypeDisplayName(r.Type) + if displayType == "" { + displayType = string(r.Type) + } + choices[i] = fmt.Sprintf("%s (%s)", r.Name, displayType) + } + + idx, err := m.console.Select(ctx, input.ConsoleOptions{ + Message: "Select a service to stream logs from:", + Options: choices, + }) + if err != nil { + return nil, fmt.Errorf("selecting service: %w", err) + } + selected = streamable[idx] + } + + displayType := azapi.GetResourceTypeDisplayName(selected.Type) + if displayType == "" { + displayType = string(selected.Type) + } + + m.console.Message(ctx, fmt.Sprintf( + "Streaming logs from %s (%s). Press Ctrl+C to stop.\n", + output.WithHighLightFormat(selected.Name), + displayType, + )) + + logStream, err := m.getLogStream(ctx, selected) + if err != nil { + return nil, fmt.Errorf("starting log stream for %s: %w", selected.Name, err) + } + defer logStream.Close() + + // Stream log data to the console output writer + writer := m.console.GetWriter() + if _, err := io.Copy(writer, logStream); err != nil { + // Context cancellation (Ctrl+C) is expected when streaming + if ctx.Err() != nil { + return nil, nil + } + // Check for connection close/reset errors during streaming, which are + // normal when the user stops streaming or the server closes the connection + if isStreamClosedError(err) { + return nil, nil } + return nil, fmt.Errorf("streaming logs: %w", err) } return nil, nil } +// isStreamClosedError reports whether the error indicates the log stream +// connection was closed, which is expected during normal termination. +func isStreamClosedError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "connection reset") || + strings.Contains(msg, "use of closed network connection") || + strings.Contains(msg, "EOF") +} + +// getLogStream returns a streaming reader for the given resource's application logs. +func (m *monitorAction) getLogStream( + ctx context.Context, + resource streamableResource, +) (io.ReadCloser, error) { + subscriptionId := m.env.GetSubscriptionId() + + switch resource.Type { + case azapi.AzureResourceTypeWebSite: + return m.azureClient.GetAppServiceLogStream( + ctx, subscriptionId, resource.ResourceGroup, resource.Name, + ) + case azapi.AzureResourceTypeContainerApp: + return m.containerAppService.GetLogStream( + ctx, subscriptionId, resource.ResourceGroup, resource.Name, + ) + default: + return nil, fmt.Errorf( + "log streaming is not supported for resource type %s", resource.Type, + ) + } +} + func getCmdMonitorHelpDescription(*cobra.Command) string { return generateCmdHelpDescription( fmt.Sprintf("Monitor a deployed application %s. For more information, go to: %s.", @@ -188,8 +382,9 @@ func getCmdMonitorHelpDescription(*cobra.Command) string { func getCmdMonitorHelpFooter(c *cobra.Command) string { return generateCmdHelpSamplesBlock(map[string]string{ - "Open Application Insights Overview Dashboard.": output.WithHighLightFormat("azd monitor --overview"), - "Open Application Insights Live Metrics.": output.WithHighLightFormat("azd monitor --live"), - "Open Application Insights Logs.": output.WithHighLightFormat("azd monitor --logs"), + "Open Application Insights Overview Dashboard.": output.WithHighLightFormat("azd monitor --overview"), + "Open Application Insights Live Metrics.": output.WithHighLightFormat("azd monitor --live"), + "Open Application Insights Logs.": output.WithHighLightFormat("azd monitor --logs"), + "Stream application logs directly to the terminal": output.WithHighLightFormat("azd monitor --tail"), }) } diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..8fd03197ea9 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2466,6 +2466,10 @@ const completionSpec: Fig.Spec = { name: ['--overview'], description: 'Open a browser to Application Insights Overview Dashboard.', }, + { + name: ['--tail'], + description: 'Stream application logs from a deployed service directly to the terminal.', + }, ], }, { diff --git a/cli/azd/cmd/testdata/TestUsage-azd-monitor.snap b/cli/azd/cmd/testdata/TestUsage-azd-monitor.snap index b3a61421a00..1578ffe7876 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-monitor.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-monitor.snap @@ -9,6 +9,7 @@ Flags --live : Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps. --logs : Open a browser to Application Insights Logs. --overview : Open a browser to Application Insights Overview Dashboard. + --tail : Stream application logs from a deployed service directly to the terminal. Global Flags -C, --cwd string : Sets the current working directory. @@ -27,4 +28,7 @@ Examples Open Application Insights Overview Dashboard. azd monitor --overview + Stream application logs directly to the terminal + azd monitor --tail + diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index 1518c82023e..75b30bf113f 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -14,8 +14,6 @@ require ( ) require ( - dario.cat/mergo v1.0.2 // indirect - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect @@ -83,20 +81,20 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum index d2e0d37834a..ff832bd9298 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -1,6 +1,4 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= @@ -17,7 +15,6 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -64,8 +61,8 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20251126160633-0b68cdcd21da h1:/fQ+NdolY1sAcLP5fVExkJrVG70QL7FTQElvYyI5Hzs= -github.com/charmbracelet/x/exp/golden v0.0.0-20251126160633-0b68cdcd21da/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee h1:B/JPEPNGIHyyhCPM483B+cfJQ1+9S2YBPWoTAJw3Ut0= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -77,7 +74,6 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -115,7 +111,6 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -144,10 +139,8 @@ github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8 github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -155,7 +148,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -203,10 +195,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -222,77 +212,53 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/azd/pkg/azapi/appservice_logstream.go b/cli/azd/pkg/azapi/appservice_logstream.go new file mode 100644 index 00000000000..a3c3e2bdb0a --- /dev/null +++ b/cli/azd/pkg/azapi/appservice_logstream.go @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +// GetAppServiceLogStream returns a streaming reader for App Service application logs. +// It connects to the Kudu SCM logstream endpoint and returns an io.ReadCloser that +// streams log data in real-time. The caller is responsible for closing the reader. +// This works for both App Service and Azure Functions targets (Microsoft.Web/sites). +func (cli *AzureClient) GetAppServiceLogStream( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, +) (io.ReadCloser, error) { + app, err := cli.appService(ctx, subscriptionId, resourceGroup, appName) + if err != nil { + return nil, fmt.Errorf("getting app service properties: %w", err) + } + + hostName, err := appServiceRepositoryHost(app, appName) + if err != nil { + return nil, fmt.Errorf("getting repository host: %w", err) + } + + credential, err := cli.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, fmt.Errorf("getting credential: %w", err) + } + + token, err := credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://management.azure.com/.default"}, + }) + if err != nil { + return nil, fmt.Errorf("getting access token: %w", err) + } + + logStreamURL := fmt.Sprintf("https://%s/api/logstream", hostName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logStreamURL, nil) + if err != nil { + return nil, fmt.Errorf("creating log stream request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + + //nolint:gosec // URL is constructed from trusted Azure ARM data (SCM hostname) + resp, err := (&http.Client{}).Do(req) + if err != nil { + return nil, fmt.Errorf("connecting to log stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + return nil, fmt.Errorf( + "log stream returned HTTP %d: %s", resp.StatusCode, string(body), + ) + } + + return resp.Body, nil +} diff --git a/cli/azd/pkg/containerapps/container_app.go b/cli/azd/pkg/containerapps/container_app.go index 0035aac0bae..a6023a612f5 100644 --- a/cli/azd/pkg/containerapps/container_app.go +++ b/cli/azd/pkg/containerapps/container_app.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "slices" @@ -93,6 +94,16 @@ type ContainerAppService interface { envVars map[string]string, options *ContainerAppOptions, ) error + // GetLogStream returns a streaming reader for Container App console logs. + // It discovers the latest revision and replica, obtains an auth token, and + // connects to the replica container's log stream endpoint. + // The caller is responsible for closing the returned reader. + GetLogStream( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, + ) (io.ReadCloser, error) } // NewContainerAppService creates a new ContainerAppService diff --git a/cli/azd/pkg/containerapps/logstream.go b/cli/azd/pkg/containerapps/logstream.go new file mode 100644 index 00000000000..a6efd31de6b --- /dev/null +++ b/cli/azd/pkg/containerapps/logstream.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package containerapps + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3" +) + +// GetLogStream returns a streaming reader for Container App console logs. +// It discovers the latest revision and replica, obtains an auth token via the +// Container Apps SDK, and connects to the replica container's LogStreamEndpoint. +// The caller is responsible for closing the returned reader. +func (cas *containerAppService) GetLogStream( + ctx context.Context, + subscriptionId string, + resourceGroup string, + appName string, +) (io.ReadCloser, error) { + // Get the container app's latest revision name + containerApp, err := cas.getContainerApp(ctx, subscriptionId, resourceGroup, appName, nil) + if err != nil { + return nil, fmt.Errorf("getting container app details: %w", err) + } + + latestRevision, ok := containerApp.GetString(pathLatestRevisionName) + if !ok || latestRevision == "" { + return nil, fmt.Errorf( + "could not determine latest revision for container app %s", appName, + ) + } + + // List replicas for the latest revision to find the log stream endpoint + credential, err := cas.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, fmt.Errorf("getting credential: %w", err) + } + + replicasClient, err := armappcontainers.NewContainerAppsRevisionReplicasClient( + subscriptionId, credential, cas.armClientOptions, + ) + if err != nil { + return nil, fmt.Errorf("creating replicas client: %w", err) + } + + replicasResp, err := replicasClient.ListReplicas( + ctx, resourceGroup, appName, latestRevision, nil, + ) + if err != nil { + return nil, fmt.Errorf("listing replicas for revision %s: %w", latestRevision, err) + } + + logStreamEndpoint := "" + if len(replicasResp.Value) > 0 { + replica := replicasResp.Value[0] + if replica.Properties != nil && len(replica.Properties.Containers) > 0 { + container := replica.Properties.Containers[0] + if container.LogStreamEndpoint != nil { + logStreamEndpoint = *container.LogStreamEndpoint + } + } + } + + if logStreamEndpoint == "" { + return nil, fmt.Errorf( + "no running replicas with log stream endpoints found for container app %s "+ + "(revision: %s) - ensure the app is running", + appName, latestRevision, + ) + } + + // Get auth token for the Container App to authenticate to the log stream endpoint + appClient, err := cas.createContainerAppsClient(ctx, subscriptionId, nil) + if err != nil { + return nil, fmt.Errorf("creating container apps client: %w", err) + } + + authResp, err := appClient.GetAuthToken(ctx, resourceGroup, appName, nil) + if err != nil { + return nil, fmt.Errorf("getting Container App auth token: %w", err) + } + + if authResp.Properties == nil || authResp.Properties.Token == nil || + *authResp.Properties.Token == "" { + return nil, fmt.Errorf("Container App auth token is empty") + } + + // Connect to the log stream endpoint with the auth token + // Append follow=true and tailLines query parameters for streaming + streamURL := logStreamEndpoint + "&follow=true&tailLines=300" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) + if err != nil { + return nil, fmt.Errorf("creating log stream request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+*authResp.Properties.Token) + + //nolint:gosec // URL is from ARM-provided LogStreamEndpoint on the replica container + resp, err := (&http.Client{}).Do(req) + if err != nil { + return nil, fmt.Errorf("connecting to Container App log stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + return nil, fmt.Errorf( + "Container App log stream returned HTTP %d: %s", + resp.StatusCode, string(body), + ) + } + + return resp.Body, nil +}