Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 93 additions & 73 deletions src/pkg/cli/client/byoc/azure/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
azuredns "github.com/DefangLabs/defang/src/pkg/clouds/azure/dns"
"github.com/DefangLabs/defang/src/pkg/clouds/azure/keyvault"
defanghttp "github.com/DefangLabs/defang/src/pkg/http"
"github.com/DefangLabs/defang/src/pkg/logs"
"github.com/DefangLabs/defang/src/pkg/term"
"github.com/DefangLabs/defang/src/pkg/tokenstore"
"github.com/DefangLabs/defang/src/pkg/types"
Expand Down Expand Up @@ -809,103 +810,122 @@ func (b *ByocAzure) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (i
return nil, err
}

// Resolve the CD job execution for this request. The deploying process caches
// the run ID in memory; a standalone `defang logs` has none, so recover it by
// matching the request etag against each execution's recorded ETAG env var.
runID := b.cdRunID
// Honor the requested log types (a bitmask of CD/RUN/BUILD), like the other
// providers do. In particular `cd down`/refresh request CD logs only, so the
// service and build watchers below must not run against the project resource
// group — it is being (or already) torn down and would return ResourceGroupNotFound.
logType := logs.LogType(req.LogType)

// The etag labels CD, service, and build entries alike, so resolve it before
// branching on log type.
etag := b.cdEtag
if req.Etag != "" && req.Etag != b.cdEtag {
runID, etag = "", req.Etag
}
if runID == "" && etag != "" {
found, err := b.job.FindExecutionByEtag(ctx, etag)
if err != nil {
return nil, fmt.Errorf("failed to find CD deployment for etag %q: %w", etag, err)
}
runID = found
}
if runID == "" {
// Unknown or empty etag: no CD run to tail. Service and build logs are keyed
// by resource group rather than the CD execution, so still surface those.
term.Warnf("No CD logs found for etag %q; showing service and build logs only", req.Etag)
etag = req.Etag
}

// CD logs. cdCh stays nil when there's no CD run, which makes the select below skip
// it and treat the CD source as already drained. Follow streams line-by-line; the
// snapshot reads the whole buffered run content once.
// CD logs. cdCh stays nil when CD logs aren't requested or there's no CD run,
// which makes the select below skip it and treat the CD source as already
// drained. Follow streams line-by-line; the snapshot reads the whole buffered
// run content once.
type cdLogEntry struct {
line string
err error
}
var cdCh chan cdLogEntry
if runID != "" {
cdCh = make(chan cdLogEntry)
if req.Follow {
logIter, err := b.job.TailJobLogs(ctx, runID)
if logType.Has(logs.LogTypeCD) {
// Resolve the CD job execution for this request. The deploying process caches
// the run ID in memory; a standalone `defang logs` has none, so recover it by
// matching the request etag against each execution's recorded ETAG env var.
runID := b.cdRunID
if req.Etag != "" && req.Etag != b.cdEtag {
runID = ""
}
if runID == "" && etag != "" {
found, err := b.job.FindExecutionByEtag(ctx, etag)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to find CD deployment for etag %q: %w", etag, err)
}
go func() {
defer close(cdCh)
for line, err := range logIter {
runID = found
}
if runID == "" {
term.Warnf("No CD logs found for etag %q", req.Etag)
} else {
cdCh = make(chan cdLogEntry)
if req.Follow {
logIter, err := b.job.TailJobLogs(ctx, runID)
if err != nil {
return nil, err
}
go func() {
defer close(cdCh)
for line, err := range logIter {
select {
case cdCh <- cdLogEntry{line: line, err: err}:
case <-ctx.Done():
return
}
}
}()
} else {
content, err := b.job.ReadJobLogs(ctx, runID)
if err != nil {
return nil, err
}
go func() {
defer close(cdCh)
if content == "" {
return
}
select {
case cdCh <- cdLogEntry{line: line, err: err}:
case cdCh <- cdLogEntry{line: content}:
case <-ctx.Done():
return
}
}
}()
} else {
content, err := b.job.ReadJobLogs(ctx, runID)
if err != nil {
return nil, err
}()
}
go func() {
defer close(cdCh)
if content == "" {
return
}
select {
case cdCh <- cdLogEntry{line: content}:
case <-ctx.Done():
}
}()
}
}

projectRG := b.projectResourceGroupName(req.Project)

// Service logs from the project's Container Apps and build logs from ACR both
// live in the PROJECT resource group (independent of the CD run). For a one-shot
// query, if that group doesn't exist the project isn't deployed under this
// name/stack — surface one clear message instead of letting both watchers fail
// with raw "ResourceGroupNotFound" errors. In follow mode we skip the check: the
// group may be created mid-session (deploy-then-tail), so the watchers poll for it.
// Service logs from the project's Container Apps (RUN) and build logs from ACR
// (BUILD) both live in the PROJECT resource group (independent of the CD run).
// For a one-shot query, if that group doesn't exist the project isn't deployed
// under this name/stack — surface one clear message instead of letting the
// watchers fail with raw "ResourceGroupNotFound" errors. In follow mode we skip
// the check: the group may be created mid-session (deploy-then-tail), so the
// watchers poll for it.
var acaCh <-chan aca.ServiceLogEntry
var buildCh <-chan acr.BuildLogEntry
startWatchers := true
if !req.Follow {
exists, err := b.driver.ResourceGroupExists(ctx, projectRG)
if err != nil {
return nil, err
}
if !exists {
term.Warnf("No deployed services found for project %q on stack %q (resource group %q not found)", req.Project, b.PulumiStack, projectRG)
startWatchers = false
}
}
if startWatchers {
acaClient := &aca.ContainerApp{
Azure: b.driver.Azure,
ResourceGroup: projectRG,
wantRun := logType.Has(logs.LogTypeRun)
wantBuild := logType.Has(logs.LogTypeBuild)
if wantRun || wantBuild {
startWatchers := true
if !req.Follow {
exists, err := b.driver.ResourceGroupExists(ctx, projectRG)
if err != nil {
return nil, err
}
if !exists {
term.Warnf("No deployed services found for project %q on stack %q (resource group %q not found)", req.Project, b.PulumiStack, projectRG)
startWatchers = false
}
}
acaCh = acaClient.WatchLogs(ctx, req.Follow)

buildWatcher := &acr.BuildLogWatcher{
Azure: b.driver.Azure,
ResourceGroup: projectRG,
if startWatchers {
if wantRun {
acaClient := &aca.ContainerApp{
Azure: b.driver.Azure,
ResourceGroup: projectRG,
}
acaCh = acaClient.WatchLogs(ctx, req.Follow)
}
if wantBuild {
buildWatcher := &acr.BuildLogWatcher{
Azure: b.driver.Azure,
ResourceGroup: projectRG,
}
buildCh = buildWatcher.WatchBuildLogs(ctx, req.Follow)
}
}
buildCh = buildWatcher.WatchBuildLogs(ctx, req.Follow)
}

return func(yield func(*defangv1.TailResponse, error) bool) {
Expand Down
26 changes: 23 additions & 3 deletions src/pkg/cli/client/byoc/azure/byoc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/DefangLabs/defang/src/pkg/cli/client"
cloudazure "github.com/DefangLabs/defang/src/pkg/clouds/azure"
"github.com/DefangLabs/defang/src/pkg/logs"
defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1"
composeTypes "github.com/compose-spec/compose-go/v2/types"
)
Expand Down Expand Up @@ -267,7 +268,7 @@ func TestQueryLogsDiscoversRunByEtag(t *testing.T) {
b := newTestProvider(t, cloudazure.LocationEastUS, "sub")
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "some-etag"})
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "some-etag", LogType: uint32(logs.LogTypeCD)})
if err == nil || !strings.Contains(err.Error(), "failed to find CD deployment for etag") {
t.Errorf("expected etag-lookup failure, got: %v", err)
}
Expand All @@ -282,12 +283,31 @@ func TestQueryLogsEtagMismatchTriggersLookup(t *testing.T) {
b.cdEtag = "etag-A"
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "etag-B"})
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "etag-B", LogType: uint32(logs.LogTypeCD)})
if err == nil || !strings.Contains(err.Error(), "failed to find CD deployment for etag") {
t.Errorf("expected mismatched-etag lookup failure, got: %v", err)
}
}

func TestQueryLogsCDOnlySkipsProjectResourceGroup(t *testing.T) {
// `cd down`/refresh tail only CD logs. With no CD run to tail, QueryLogs must
// not start the service/build watchers nor probe the project resource group —
// which is being torn down and would fail with ResourceGroupNotFound. A working
// credential is provided so any stray project-RG call would reach Azure (and
// hang/fail) instead of short-circuiting on missing creds.
useFakeCred(t, "tok", nil)
b := newTestProvider(t, cloudazure.LocationEastUS, "sub")
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
it, err := b.QueryLogs(ctx, &defangv1.TailRequest{Project: "proj", Follow: false, LogType: uint32(logs.LogTypeCD)})
if err != nil {
t.Fatalf("QueryLogs returned error, want none: %v", err)
}
for _, err := range it {
t.Fatalf("expected no log entries for CD-only request without a run, got err=%v", err)
}
}

func TestAuthenticateNonInteractiveFailsWithoutCreds(t *testing.T) {
// Point the SDK at an ARM endpoint that returns 401 so DefaultAzureCredential's
// token always fails validation — no real Azure call is made by our code beyond
Expand Down Expand Up @@ -415,7 +435,7 @@ func TestQueryLogsNonFollow(t *testing.T) {
// an error (not panic).
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "etag", Follow: false})
_, err := b.QueryLogs(ctx, &defangv1.TailRequest{Etag: "etag", Follow: false, LogType: uint32(logs.LogTypeCD)})
if err == nil {
t.Error("QueryLogs non-follow should fail without real Azure workspace")
}
Expand Down
18 changes: 18 additions & 0 deletions src/pkg/clouds/aws/codebuild/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ type buildspecBuild struct {
Commands []string `yaml:"commands"`
}

// environmentTypeForImage returns the CodeBuild environment type required to run
// the given container image. The project is created as LINUX_CONTAINER (x86_64),
// but some CD images are arm64 (e.g. the legacy public-cd-image-*-arm64 tag).
// Running an arm64 image on an x86_64 environment fails at runtime with
// "node: not found" / exit 127, so the environment type must match the image.
//
// The image architecture is inferred from the reference: a sha256 digest is
// hexadecimal and cannot contain the letters in "arm64"/"aarch64", so a simple
// substring check is unambiguous. Unknown/x86_64 images keep the project default.
func environmentTypeForImage(image string) cbtypes.EnvironmentType {
lower := strings.ToLower(image)
if strings.Contains(lower, "arm64") || strings.Contains(lower, "aarch64") {
return cbtypes.EnvironmentTypeArmContainer
}
return cbtypes.EnvironmentTypeLinuxContainer
}

func buildspec(workingDir string, cmd ...string) (string, error) {
if workingDir == "" {
return "", errors.New("workingDir must not be empty")
Expand Down Expand Up @@ -82,6 +99,7 @@ func (a *AwsCodeBuild) Run(ctx context.Context, workingDir, image string, env ma
input := &codebuild.StartBuildInput{
ProjectName: ptr.String(a.ProjectName),
ImageOverride: ptr.String(image),
EnvironmentTypeOverride: environmentTypeForImage(image),
EnvironmentVariablesOverride: envOverrides,
BuildspecOverride: ptr.String(spec),
}
Expand Down
38 changes: 38 additions & 0 deletions src/pkg/clouds/aws/codebuild/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,47 @@ import (
"strings"
"testing"

cbtypes "github.com/aws/aws-sdk-go-v2/service/codebuild/types"
"go.yaml.in/yaml/v4"
)

func TestEnvironmentTypeForImage(t *testing.T) {
tests := []struct {
name string
image string
want cbtypes.EnvironmentType
}{
{
name: "arm64 public CD image with digest",
image: "public.ecr.aws/defang-io/cd:public-cd-image-fb20b70e-arm64@sha256:6cdb3f11e548700a673098642ed25e3fb50ebf3b39d533364f75d207bf66ea9b",
want: cbtypes.EnvironmentTypeArmContainer,
},
{
name: "x86_64 private CD image with digest",
image: "426819183542.dkr.ecr.us-west-2.amazonaws.com/cd:nodejs-cd-image-e87eefcf-x86_64@sha256:7efe4a59ed9d1c06f4bf396d76dbfb0f1d4784c289999bf88664fabe43409793",
want: cbtypes.EnvironmentTypeLinuxContainer,
},
{
name: "aarch64 spelling",
image: "public.ecr.aws/defang-io/cd:some-tag-aarch64",
want: cbtypes.EnvironmentTypeArmContainer,
},
{
name: "unknown arch defaults to linux",
image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
want: cbtypes.EnvironmentTypeLinuxContainer,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := environmentTypeForImage(tt.image); got != tt.want {
t.Errorf("environmentTypeForImage(%q) = %q, want %q", tt.image, got, tt.want)
}
})
}
}

func TestBuildspec(t *testing.T) {
tests := []struct {
name string
Expand Down
29 changes: 29 additions & 0 deletions src/pkg/clouds/aws/codebuild/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codebuild

import (
"context"
"fmt"
"io"
"strings"
"time"
Expand Down Expand Up @@ -32,6 +33,14 @@ func GetBuildStatus(ctx context.Context, cfg aws.Config, buildID BuildID) (bool,
func buildStatus(build cbtypes.Build) (bool, error) {
switch build.BuildStatus {
case cbtypes.StatusTypeInProgress:
// The top-level BuildStatus can lag a phase that has already failed: when
// the CodeBuild agent faults before our command runs (e.g. it cannot exec
// the shell), the build can sit at IN_PROGRESS until the project timeout
// (up to an hour) even though it is already doomed. Phases are sequential
// and never recover, so a failed phase means the build is done.
if err := failedPhaseError(build); err != nil {
return true, err
}
return false, nil
case cbtypes.StatusTypeSucceeded:
return true, io.EOF
Expand All @@ -45,6 +54,26 @@ func buildStatus(build cbtypes.Build) (bool, error) {
}
}

// failedPhaseError returns a BuildFailure when any build phase has reached a
// terminal failure state, or nil when none has. It lets us surface a doomed
// build while its top-level BuildStatus still reads IN_PROGRESS, rather than
// waiting for CodeBuild to finalize (or time out).
func failedPhaseError(build cbtypes.Build) error {
for _, phase := range build.Phases {
switch phase.PhaseStatus {
case cbtypes.StatusTypeFailed, cbtypes.StatusTypeFault,
cbtypes.StatusTypeTimedOut, cbtypes.StatusTypeStopped:
reason := getBuildPhaseErrorContexts(build)
if reason == "" {
// No context message (e.g. an agent fault): fall back to naming the phase.
reason = fmt.Sprintf("build %s phase %s", phase.PhaseType, strings.ToLower(string(phase.PhaseStatus)))
}
return BuildFailure{Reason: reason}
}
}
return nil
}

func getBuildPhaseErrorContexts(build cbtypes.Build) string {
var messages []string
for _, phase := range build.Phases {
Expand Down
Loading
Loading