Skip to content

Commit 5879f59

Browse files
George Milekagmileka
andauthored
feat: implement image customizer live output with filtering (#339)
* feat: implement image customizer live output with filtering * Address copilot feedback * Address feedback * Update comments * Remove a few more info messages * Allow empty workDir and logsDir in project again - and move the defaults to image specific use case * Fix change after rebasing --------- Co-authored-by: George Mileka <gmileka@outlook.com>
1 parent db07c19 commit 5879f59

4 files changed

Lines changed: 174 additions & 26 deletions

File tree

internal/app/azldev/cmds/image/imageutils.go

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@
44
package image
55

66
import (
7+
"context"
78
"errors"
89
"fmt"
910
"path"
11+
"path/filepath"
1012
"strings"
13+
"time"
1114

1215
"github.com/microsoft/azldev/internal/app/azldev"
16+
"github.com/microsoft/azldev/internal/projectgen"
1317
"github.com/microsoft/azldev/internal/utils/docker"
1418
)
1519

1620
const (
21+
logFileTimeFormat = "20060102-150405"
1722
imageCustomizerCustomize = "customize"
1823
imageCustomizerInject = "inject-files"
24+
// The Image Customizer log always goes to a debug-level log file - regardless
25+
// of the log level set for azldev.
26+
// The log level set for azldev controls what is shown on the console.
27+
defaultLogLevel = "debug"
1928
)
2029

2130
type imageCustomizerOptions struct {
@@ -28,17 +37,6 @@ type imageCustomizerOptions struct {
2837
packageSnapshotTime string
2938
}
3039

31-
func getLogLevel(env *azldev.Env) string {
32-
switch {
33-
case env.Verbose():
34-
return "debug"
35-
case env.Quiet():
36-
return "error"
37-
default:
38-
return "info"
39-
}
40-
}
41-
4240
func getImageCustomizerImageFormats() []string {
4341
// This is temporarily hardcoded until there is a dynamic way of getting the supported formats.
4442
// Tracking Item: https://dev.azure.com/mariner-org/polar/_workitems/edit/15309
@@ -54,7 +52,7 @@ func getImageCustomizerImageFormatsString() string {
5452
}
5553

5654
func buildDockerArgs(
57-
buildDir, configDir, inputImageDir, outputPathDir string, rpmSources []rpmSourceInfo,
55+
buildDir, configDir, inputImageDir, logsDir, outputPathDir string, rpmSources []rpmSourceInfo,
5856
) []string {
5957
args := []string{
6058
"run", "--rm",
@@ -63,6 +61,7 @@ func buildDockerArgs(
6361
"-v", buildDir + ":" + buildDir + docker.MountRWOption,
6462
"-v", configDir + ":" + configDir + docker.MountROOption,
6563
"-v", inputImageDir + ":" + inputImageDir + docker.MountROOption,
64+
"-v", logsDir + ":" + logsDir + docker.MountRWOption,
6665
"-v", outputPathDir + ":" + outputPathDir + docker.MountRWOption,
6766
"-v", "/dev:/dev",
6867
}
@@ -106,7 +105,7 @@ func buildRpmSourcesInfo(rpmSources []string) []rpmSourceInfo {
106105
}
107106

108107
func buildImageCustomizerArgs(
109-
options *imageCustomizerOptions, buildDir, logLevel, logColor string, rpmSources []rpmSourceInfo,
108+
options *imageCustomizerOptions, buildDir, logFile, logLevel, logColor string, rpmSources []rpmSourceInfo,
110109
) []string {
111110
args := []string{
112111
"--build-dir", buildDir,
@@ -116,6 +115,7 @@ func buildImageCustomizerArgs(
116115
"--output-path", options.outputPath,
117116
"--log-level", logLevel,
118117
"--log-color", logColor,
118+
"--log-file", logFile,
119119
}
120120

121121
for _, rpmSource := range rpmSources {
@@ -145,27 +145,44 @@ func runImageCustomizerContainer(
145145
return errors.New("the Image Customizer container tag is not set in the project configuration")
146146
}
147147

148+
// Because buildDir and logsDir will be mapped to the container, they
149+
// cannot be empty.
148150
buildDir := env.WorkDir()
151+
if buildDir == "" {
152+
buildDir = filepath.Join(env.ProjectDir(), projectgen.DefaultWorkDir)
153+
}
154+
155+
logsDir := env.LogsDir()
156+
if logsDir == "" {
157+
logsDir = filepath.Join(env.ProjectDir(), projectgen.DefaultLogDir)
158+
}
159+
149160
configDir := path.Dir(options.configFile)
150161
inputImageDir := path.Dir(options.imageFile)
151162
outputPathDir := path.Dir(options.outputPath)
152163

164+
timestamp := time.Now().Format(logFileTimeFormat)
165+
logFile := path.Join(logsDir, fmt.Sprintf("image-customizer-%s.log", timestamp))
166+
logMarkers := getImageCustomizerInfoMarkers()
167+
168+
env.Event("Saving detailed logs to: " + logFile)
169+
153170
rpmSourcesInfo := buildRpmSourcesInfo(options.rpmSources)
154171

155-
dockerArgs := buildDockerArgs(buildDir, configDir, inputImageDir, outputPathDir, rpmSourcesInfo)
172+
dockerArgs := buildDockerArgs(buildDir, configDir, inputImageDir, logsDir, outputPathDir, rpmSourcesInfo)
156173

157174
imageCustomizerArgs := buildImageCustomizerArgs(
158-
options, buildDir, getLogLevel(env), string(env.ColorMode()), rpmSourcesInfo,
175+
options, buildDir, logFile, defaultLogLevel, string(env.ColorMode()), rpmSourcesInfo,
159176
)
160177

161-
output, err := docker.RunDocker(env.Context(), env, dockerArgs, containerTag, command, imageCustomizerArgs)
178+
_, err := docker.RunDocker(env.Context(), env, dockerArgs, containerTag, command, imageCustomizerArgs, logFile,
179+
func(_ context.Context, line string) {
180+
filterImageCustomizerOutput(env, line, logMarkers)
181+
},
182+
)
162183
if err != nil {
163-
fmt.Printf("%s\n", output)
164-
165-
return fmt.Errorf("failed to customize image:\n%w", err)
184+
return fmt.Errorf("failed to customize image: %w", err)
166185
}
167186

168-
fmt.Printf("%s\n", output)
169-
170187
return nil
171188
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package image
5+
6+
import (
7+
"regexp"
8+
"strings"
9+
10+
"github.com/microsoft/azldev/internal/app/azldev"
11+
)
12+
13+
// infoPackageListRegex is compiled once at package initialization to extract
14+
// package lists from log messages that contain "packages: [...]" patterns.
15+
var infoPackageListRegex = regexp.MustCompile(`packages:\s*\[(.*?)\]`)
16+
17+
// We need a filtering mechanism because the Image Customizer 'info' log level
18+
// is still more verbose than we want for normal azldev usage - where we want
19+
// to display just the title of the main operations being performed.
20+
//
21+
// To achieve that, we define an 'allow list' of substrings that indicate
22+
// that a log message is important enough to show to the user in normal mode.
23+
// If the user runs azldev in verbose mode, we show all messages.
24+
//
25+
// The downside to this approach is that if new operations are added to the
26+
// Image Customizer, we may need to update this list to ensure they are shown.
27+
//
28+
// We have https://dev.azure.com/mariner-org/polar/_workitems/edit/15516 to
29+
// track toning down its output and then removing this filtering from azldev.
30+
func getImageCustomizerInfoMarkers() []string {
31+
return []string{
32+
"Converting input image",
33+
"Regenerate initramfs file",
34+
"Creating full OS initrd",
35+
"Creating bootstrap initrd for",
36+
"Creating squashfs",
37+
"Installing files to empty image",
38+
"Creating UKIs",
39+
"Customizing partitions",
40+
"Setting file SELinux labels",
41+
"Provisioning verity",
42+
"Running script",
43+
"Removing packages",
44+
"Installing packages",
45+
"Updating packages",
46+
"Writing:",
47+
}
48+
}
49+
50+
// extractMessage extracts the message content from a log line.
51+
// A typical log line from the Image Customizer looks like this:
52+
// time="2025-09-24T20:02:34Z" level=info msg="<message>"
53+
// time="2025-09-24T20:02:34Z" level=debug msg="<message>".
54+
func extractMessage(line string) string {
55+
const (
56+
prefix = " msg=\""
57+
suffix = `"`
58+
)
59+
60+
start := strings.Index(line, prefix)
61+
if start == -1 {
62+
return ""
63+
}
64+
65+
end := strings.LastIndex(line, suffix)
66+
if end == -1 || end <= start+len(prefix) {
67+
return ""
68+
}
69+
70+
return line[start+len(prefix) : end]
71+
}
72+
73+
// doesMessageContainPackages checks if the log message indicates that one or
74+
// more packages are being installed, removed, or updated.
75+
// A typical log message might look like:
76+
// "Installing packages: [package1 package2 ...]"
77+
// "Removing packages: []" (no packages)
78+
// "Updating packages: [package1]".
79+
func doesMessageContainPackages(message string) bool {
80+
const (
81+
expectedRegexMatches = 2
82+
)
83+
84+
matches := infoPackageListRegex.FindStringSubmatch(message)
85+
if len(matches) < expectedRegexMatches {
86+
// expectedRegexMatches is 2: one for the full match, one for the capture group.
87+
return false // No match or no content group
88+
}
89+
90+
return len(matches[1]) > 0
91+
}
92+
93+
func filterImageCustomizerOutput(env *azldev.Env, line string, logMarkers []string) {
94+
message := extractMessage(line)
95+
if message == "" {
96+
return
97+
}
98+
99+
switch {
100+
case env.Quiet():
101+
case env.Verbose():
102+
env.Event(message)
103+
default:
104+
// Default behavior: only show certain "info" messages.
105+
for _, logMarker := range logMarkers {
106+
if strings.Contains(message, logMarker) {
107+
// We don't display 'Installing packages:' if there are
108+
// no packages actually being installed.
109+
// Same for 'Removing packages:' and 'Updating packages:'.
110+
if strings.Contains(message, " packages:") {
111+
if !doesMessageContainPackages(message) {
112+
return
113+
}
114+
}
115+
116+
env.Event(message)
117+
118+
return
119+
}
120+
}
121+
}
122+
}

internal/projectgen/projectgen.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import (
1818

1919
const (
2020
defaultIntermediateOutputDir = "build"
21-
defaultLogDir = "build/logs"
22-
defaultWorkDir = "build/work"
21+
DefaultLogDir = "build/logs"
22+
DefaultWorkDir = "build/work"
2323
defaultOutputDir = "out"
2424

2525
defaultFilePerms = 0o644
@@ -108,8 +108,8 @@ func GenerateBasicConfig(projectName string) projectconfig.ConfigFile {
108108
SchemaURI: projectconfig.DefaultSchemaURI,
109109
Project: &projectconfig.ProjectInfo{
110110
Description: projectName,
111-
LogDir: defaultLogDir,
112-
WorkDir: defaultWorkDir,
111+
LogDir: DefaultLogDir,
112+
WorkDir: DefaultWorkDir,
113113
OutputDir: defaultOutputDir,
114114
},
115115

internal/utils/docker/docker.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ func validateDockerArgs(dockerArgs []string) error {
4242
return nil
4343
}
4444

45+
// RunDocker runs a docker command with the specified arguments and returns the
46+
// stdout output as a string. It can also capture output from a log file (logFile)
47+
// in real-time, filter it (logFilter) and send it to the console.
4548
func RunDocker(ctx context.Context, cmdFactory opctx.CmdFactory, dockerArgs []string,
46-
containerTag string, command string, commandArgs []string,
49+
containerTag string, command string, commandArgs []string, logFile string,
50+
logFilter func(context.Context, string),
4751
) (string, error) {
4852
err := validateDockerArgs(dockerArgs)
4953
if err != nil {
@@ -77,6 +81,11 @@ func RunDocker(ctx context.Context, cmdFactory opctx.CmdFactory, dockerArgs []st
7781
return "", fmt.Errorf("failed to wrap the 'docker' command:\n%w", err)
7882
}
7983

84+
err = cmd.AddRealTimeFileListener(logFile, logFilter)
85+
if err != nil {
86+
return "", fmt.Errorf("failed to add docker log listeners: %w", err)
87+
}
88+
8089
err = cmd.Run(ctx)
8190
if err != nil {
8291
return stdout.String(), fmt.Errorf("failed to execute 'docker' command:\n%w, stderr: %s", err, stderr.String())

0 commit comments

Comments
 (0)