Skip to content

Commit 3415552

Browse files
authored
feat: best-effort error log extraction (#442)
1 parent ba6c6ec commit 3415552

6 files changed

Lines changed: 148 additions & 7 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/charmbracelet/x/term v0.2.2
2020
github.com/creachadair/tomledit v0.0.29
2121
github.com/docker/docker v28.5.2+incompatible
22+
github.com/fatih/color v1.18.0
2223
github.com/gkampitakis/go-snaps v0.5.19
2324
github.com/go-playground/validator/v10 v10.30.1
2425
github.com/google/renameio v1.0.1
@@ -79,7 +80,6 @@ require (
7980
github.com/docker/go-units v0.5.0 // indirect
8081
github.com/ebitengine/purego v0.8.4 // indirect
8182
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
82-
github.com/fatih/color v1.18.0 // indirect
8383
github.com/felixge/httpsnoop v1.0.4 // indirect
8484
github.com/fsnotify/fsnotify v1.9.0 // indirect
8585
github.com/gabriel-vasile/mimetype v1.4.12 // indirect

internal/app/azldev/core/componentbuilder/componentbuilder.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99
"context"
1010
"fmt"
1111
"log/slog"
12+
"os"
1213
"path"
1314
"strings"
1415

16+
"github.com/fatih/color"
1517
"github.com/gim-home/azldev-preview/internal/app/azldev/core/components"
1618
"github.com/gim-home/azldev-preview/internal/app/azldev/core/sources"
1719
"github.com/gim-home/azldev-preview/internal/app/azldev/core/workdir"
@@ -234,6 +236,9 @@ func (b *Builder) buildRPM(
234236
if err != nil {
235237
slog.Error("failed to build RPM", "logs", tempRPMDir)
236238

239+
// Make a best-effort attempt to find relevant details from failure logs.
240+
displayPossiblyRelevantFailureLogs(b.fs, buildEnv, tempRPMDir)
241+
237242
return outputRPMPaths, fmt.Errorf("failed to build rpm:\n%w", err)
238243
}
239244

@@ -271,3 +276,39 @@ func (b *Builder) copyRPMsToOutputDir(inputDir, outputRPMDir string) ([]string,
271276

272277
return outputRPMPaths, nil
273278
}
279+
280+
func displayPossiblyRelevantFailureLogs(fs opctx.FS, buildEnv buildenv.RPMAwareBuildEnv, mockOutputDirPath string) {
281+
details := buildEnv.TryGetFailureDetails(fs, mockOutputDirPath)
282+
if details == nil {
283+
return
284+
}
285+
286+
color.Set(color.FgHiBlack)
287+
288+
if len(details.LastRPMBuildLogLines) > 0 {
289+
color.Set(color.Italic)
290+
fmt.Fprintf(os.Stderr, "Last %d RPM build log lines: ────────────────────────────────────────────\n",
291+
len(details.LastRPMBuildLogLines))
292+
color.Set(color.ResetItalic)
293+
294+
for _, line := range details.LastRPMBuildLogLines {
295+
fmt.Fprintf(os.Stderr, " │ %s\n", line)
296+
}
297+
298+
fmt.Fprintf(os.Stderr, " └──────────────────────────────────────────────────────────────────────\n")
299+
}
300+
301+
if len(details.RPMBuildErrors) > 0 {
302+
color.Set(color.Italic)
303+
fmt.Fprintf(os.Stderr, "RPM build errors: ───────────────────────────────────────────────────────\n")
304+
color.Set(color.ResetItalic)
305+
306+
for _, buildError := range details.RPMBuildErrors {
307+
fmt.Fprintf(os.Stderr, " │ %s\n", buildError)
308+
}
309+
310+
fmt.Fprintf(os.Stderr, " └──────────────────────────────────────────────────────────────────────\n")
311+
}
312+
313+
color.Unset()
314+
}

internal/buildenv/buildenv.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ type SRPMBuildOptions = mock.SRPMBuildOptions
3939
// using an [RPMAwareBuildEnv].
4040
type RPMBuildOptions = mock.RPMBuildOptions
4141

42+
// RPMBuildLogDetails encapsulates details extracted from RPM build logs that may be relevant to
43+
// understanding the cause of a build failure.
44+
type RPMBuildLogDetails = mock.BuildLogDetails
45+
4246
// RPMAwareBuildEnv is an interface that extends the [BuildEnv] interface for the subset of build
4347
// environments that provide native support RPM build operations.
4448
type RPMAwareBuildEnv interface {
@@ -49,6 +53,11 @@ type RPMAwareBuildEnv interface {
4953

5054
// BuildRPM uses this build environment to build binary RPM packages from the provided SRPM package.
5155
BuildRPM(ctx context.Context, srpmPath, outputDirPath string, options RPMBuildOptions) error
56+
57+
// TryGetFailureDetails makes a best-effort attempt to extract details from build logs that may be
58+
// relevant to understanding the cause of a build failure. This is intended to be called after a build
59+
// failure to glean any insights we can from logs about why the failure might have occurred.
60+
TryGetFailureDetails(fs opctx.FS, outputDirPath string) (details *RPMBuildLogDetails)
5261
}
5362

5463
// RunOptions encapsulate options that may be specified at runtime when executing

internal/buildenv/buildenv_testutils/buildenv_mocks.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/buildenv/mockroot.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ func (r *MockRoot) BuildRPM(ctx context.Context, srpmPath, outputDirPath string,
9898
return r.mockRunner.BuildRPM(ctx, srpmPath, outputDirPath, options)
9999
}
100100

101+
// TryGetFailureDetails makes a best-effort attempt to extract details from build logs that may be
102+
// relevant to understanding the cause of a build failure. This is intended to be called after a build
103+
// failure to glean any insights we can from logs about why the failure might have occurred.
104+
func (r *MockRoot) TryGetFailureDetails(fs opctx.FS, outputDirPath string) (details *RPMBuildLogDetails) {
105+
return r.mockRunner.TryGetFailureDetails(fs, outputDirPath)
106+
}
107+
101108
func (r *MockRoot) GetInfo() BuildEnvInfo {
102109
// Return stubbed info for now. When we support parallel mock with separate roots, we will fill this out
103110
// correctly.

internal/rpm/mock/mock.go

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import (
1515
"strings"
1616

1717
"github.com/brunoga/deep"
18+
"github.com/fatih/color"
1819
"github.com/gim-home/azldev-preview/internal/global/opctx"
20+
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
1921
"github.com/gim-home/azldev-preview/internal/utils/hostinfo"
2022
"github.com/kballard/go-shellquote"
2123
"github.com/samber/lo"
@@ -60,6 +62,17 @@ type Runner struct {
6062
rootDir string // optional
6163
}
6264

65+
// BuildLogDetails encapsulates details extracted from mock build logs that may be relevant to
66+
// understanding the cause of a build failure.
67+
type BuildLogDetails struct {
68+
// RPMBuildErrors is a list of error messages extracted from mock build logs.
69+
RPMBuildErrors []string
70+
71+
// LastRPMBuildLogLines is a list of the last lines of the mock build log, which may contain
72+
// relevant context about why a build failure occurred.
73+
LastRPMBuildLogLines []string
74+
}
75+
6376
type bindMountRequest struct {
6477
hostPath string
6578
mockRootPath string
@@ -301,7 +314,6 @@ func (r *Runner) BuildSRPM(
301314

302315
cmd := exec.CommandContext(ctx, MockBinary, args...)
303316
cmd.Stdout = os.Stdout
304-
cmd.Stderr = os.Stderr
305317

306318
extcmd, err := r.cmdFactory.Command(cmd)
307319
if err != nil {
@@ -313,7 +325,7 @@ func (r *Runner) BuildSRPM(
313325
}
314326

315327
// Watch output logs in real-time so we can asynchronously synthesize progress updates.
316-
err = addMockLogListeners(r.eventListener, extcmd, outputDirPath)
328+
err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath)
317329
if err != nil {
318330
return err
319331
}
@@ -383,7 +395,6 @@ func (r *Runner) BuildRPM(ctx context.Context, srpmPath, outputDirPath string, o
383395
// being passed through.
384396
cmd := exec.CommandContext(ctx, MockBinary, args...)
385397
cmd.Stdout = os.Stdout
386-
cmd.Stderr = os.Stderr
387398

388399
extcmd, err := r.cmdFactory.Command(cmd)
389400
if err != nil {
@@ -393,7 +404,7 @@ func (r *Runner) BuildRPM(ctx context.Context, srpmPath, outputDirPath string, o
393404
extcmd = extcmd.SetLongRunning("Waiting for mock (building RPM)...")
394405

395406
// Watch output logs in real-time so we can asynchronously synthesize progress updates.
396-
err = addMockLogListeners(r.eventListener, extcmd, outputDirPath)
407+
err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath)
397408
if err != nil {
398409
return err
399410
}
@@ -413,14 +424,30 @@ func (r *Runner) BuildRPM(ctx context.Context, srpmPath, outputDirPath string, o
413424
// will only run for the duration of mock's invocation, allowing us to get insights
414425
// into what's happening *inside* mock while we're otherwise opaquely blocking on its
415426
// execution.
416-
func addMockLogListeners(eventListener opctx.EventListener, extcmd opctx.Cmd, outputDirPath string) error {
427+
func addMockCmdListeners(eventListener opctx.EventListener, extcmd opctx.Cmd, outputDirPath string) error {
428+
// Color-code stderr lines.
429+
err := extcmd.SetRealTimeStderrListener(func(ctx context.Context, line string) {
430+
if strings.HasPrefix(line, "No matching package to install:") {
431+
color.Set(color.FgHiYellow)
432+
} else {
433+
color.Set(color.FgHiBlack, color.Italic)
434+
}
435+
436+
defer color.Unset()
437+
438+
fmt.Fprintf(os.Stderr, "%s\n", line)
439+
})
440+
if err != nil {
441+
return fmt.Errorf("failed to setup mock stderr listener: %w", err)
442+
}
443+
417444
// Messages we care about in 'state.log' will look something like the following (without the indent):
418445
// 2021-05-19 16:20:11,859 - Start: rpmbuild test-1.0.0.src.rpm
419446
stateLogRe := regexp.MustCompile(`^[^ ]+ [^ ]+ - (.*)$`)
420447

421448
// Watch well-known log 'state.log', which gives us very high-level information
422449
// about where we are in the mock build process.
423-
err := extcmd.AddRealTimeFileListener(
450+
err = extcmd.AddRealTimeFileListener(
424451
filepath.Join(outputDirPath, "state.log"),
425452
func(_ context.Context, line string) {
426453
if matches := stateLogRe.FindStringSubmatch(line); len(matches) > 1 {
@@ -619,3 +646,46 @@ func (r *Runner) getBaseArgs() (args []string) {
619646

620647
return args
621648
}
649+
650+
// TryGetFailureDetails makes a best-effort attempt to extract details from mock build logs that may be
651+
// relevant to understanding the cause of a build failure. This is intended to be called after a build
652+
// failure to glean any insights we can from mock's logs about why the failure might have occurred.
653+
func (r *Runner) TryGetFailureDetails(fs opctx.FS, outputDirPath string) (details *BuildLogDetails) {
654+
const maxContextLinesToCapture = 10
655+
656+
details = &BuildLogDetails{}
657+
658+
// Go through build.log.
659+
buildLogPath := filepath.Join(outputDirPath, "build.log")
660+
buildLogBytes, _ := fileutils.ReadFile(fs, buildLogPath)
661+
buildLogLines := strings.Split(string(buildLogBytes), "\n")
662+
663+
rpmBuildErrorsStartIndex := -1
664+
rpmBuildErrorsEndIndex := -1
665+
666+
for lineIndex, line := range buildLogLines {
667+
if strings.HasPrefix(line, "RPM build errors:") {
668+
rpmBuildErrorsStartIndex = lineIndex + 1
669+
} else if strings.HasPrefix(line, "EXCEPTION:") {
670+
rpmBuildErrorsEndIndex = lineIndex
671+
}
672+
}
673+
674+
// If we see evidence of "RPM build errors", then try to capture relevant lines.
675+
if rpmBuildErrorsStartIndex > 0 {
676+
contextEndIndex := rpmBuildErrorsStartIndex - 1
677+
678+
if rpmBuildErrorsEndIndex < 0 {
679+
rpmBuildErrorsEndIndex = len(buildLogLines)
680+
}
681+
682+
details.RPMBuildErrors = buildLogLines[rpmBuildErrorsStartIndex:rpmBuildErrorsEndIndex]
683+
684+
contextLinesToCapture := min(maxContextLinesToCapture, contextEndIndex)
685+
contextStartIndex := contextEndIndex - contextLinesToCapture
686+
687+
details.LastRPMBuildLogLines = buildLogLines[contextStartIndex:contextEndIndex]
688+
}
689+
690+
return details
691+
}

0 commit comments

Comments
 (0)