Skip to content

Commit f1162e2

Browse files
authored
feat: add image build using kiwi (#420)
1 parent be26909 commit f1162e2

9 files changed

Lines changed: 1129 additions & 8 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package image
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"log/slog"
10+
"path/filepath"
11+
12+
"github.com/gim-home/azldev-preview/internal/app/azldev"
13+
"github.com/gim-home/azldev-preview/internal/app/azldev/core/workdir"
14+
"github.com/gim-home/azldev-preview/internal/projectconfig"
15+
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
16+
"github.com/gim-home/azldev-preview/internal/utils/kiwi"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
// Options for building images.
21+
type ImageBuildOptions struct {
22+
// Name of the image to build.
23+
ImageName string
24+
25+
// Paths to local repositories to include during build.
26+
LocalRepoPaths []string
27+
}
28+
29+
// ImageBuildResult summarizes the results of building an image.
30+
type ImageBuildResult struct {
31+
// Name of the image that was built.
32+
ImageName string `json:"imageName" table:",sortkey"`
33+
34+
// Path to the output directory containing the built image.
35+
OutputDir string `json:"outputDir" table:"Output Dir"`
36+
37+
// Paths to the artifact files that were linked into the output directory.
38+
ArtifactPaths []string `json:"artifactPaths" table:"Artifact Paths"`
39+
}
40+
41+
func buildOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
42+
parentCmd.AddCommand(NewImageBuildCmd())
43+
}
44+
45+
// Constructs a [cobra.Command] for the 'image build' command.
46+
func NewImageBuildCmd() *cobra.Command {
47+
options := &ImageBuildOptions{}
48+
49+
cmd := &cobra.Command{
50+
Use: "build [image-name]",
51+
Short: "Build an image using kiwi-ng",
52+
Long: `Build an image using kiwi-ng.
53+
54+
The image must be defined in the project configuration with a kiwi definition type.
55+
This command invokes kiwi-ng via sudo to build the image.`,
56+
Args: cobra.MaximumNArgs(1),
57+
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
58+
if len(args) > 0 {
59+
options.ImageName = args[0]
60+
}
61+
62+
return BuildImage(env, options)
63+
}),
64+
ValidArgsFunction: generateImageNameCompletions,
65+
}
66+
67+
cmd.Flags().StringArrayVar(&options.LocalRepoPaths, "local-repo", []string{},
68+
"Paths to local repositories to include during build (can be specified multiple times)")
69+
70+
return cmd
71+
}
72+
73+
// BuildImage builds the specified image using kiwi-ng.
74+
func BuildImage(env *azldev.Env, options *ImageBuildOptions) (*ImageBuildResult, error) {
75+
if err := checkBuildPrerequisites(env); err != nil {
76+
return nil, err
77+
}
78+
79+
// Resolve the image from config.
80+
imageConfig, err := resolveImage(env, options.ImageName)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
// Validate the image definition type.
86+
if imageConfig.Definition.DefinitionType != projectconfig.ImageDefinitionTypeKiwi {
87+
return nil, fmt.Errorf(
88+
"image %#q has definition type %#q, but only %#q is currently supported for building",
89+
options.ImageName,
90+
imageConfig.Definition.DefinitionType,
91+
projectconfig.ImageDefinitionTypeKiwi,
92+
)
93+
}
94+
95+
// Check required directories.
96+
if env.WorkDir() == "" {
97+
return nil, errors.New("can't build images without a valid work directory configured")
98+
}
99+
100+
if env.OutputDir() == "" {
101+
return nil, errors.New("can't build images without a valid output directory configured")
102+
}
103+
104+
// Validate that the kiwi definition file exists.
105+
kiwiDefPath := imageConfig.Definition.Path
106+
if exists, _ := fileutils.Exists(env.FS(), kiwiDefPath); !exists {
107+
return nil, fmt.Errorf("kiwi definition file not found at %#q", kiwiDefPath)
108+
}
109+
110+
// Handle dry-run mode.
111+
if env.DryRun() {
112+
slog.Info("Dry run: would build image with kiwi-ng",
113+
"image", options.ImageName,
114+
"definition", kiwiDefPath,
115+
)
116+
117+
return &ImageBuildResult{
118+
ImageName: options.ImageName,
119+
}, nil
120+
}
121+
122+
// Create workdir factory for intermediate outputs.
123+
workDirFactory, err := workdir.NewFactory(env.FS(), env.WorkDir(), env.ConstructionTime())
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to create work dir factory:\n%w", err)
126+
}
127+
128+
// Create a work directory for kiwi's output. Kiwi requires a fresh directory.
129+
kiwiWorkDir, err := workDirFactory.Create(options.ImageName, "kiwi")
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to create work directory for image build:\n%w", err)
132+
}
133+
134+
// Run kiwi to build the image into the work directory.
135+
kiwiRunner := kiwi.NewRunner(env, filepath.Dir(kiwiDefPath)).WithTargetDir(kiwiWorkDir)
136+
for _, repoPath := range options.LocalRepoPaths {
137+
kiwiRunner.AddLocalRepo(repoPath)
138+
}
139+
140+
err = kiwiRunner.Build(env)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to build image %#q:\n%w", options.ImageName, err)
143+
}
144+
145+
// Final output directory for linked artifacts.
146+
imageOutputDir := filepath.Join(env.OutputDir(), "images", options.ImageName)
147+
148+
// Link the final artifacts to the output directory.
149+
artifactPaths, err := linkImageArtifacts(env, kiwiWorkDir, imageOutputDir)
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to link image artifacts to output directory:\n%w", err)
152+
}
153+
154+
return &ImageBuildResult{
155+
ImageName: options.ImageName,
156+
OutputDir: imageOutputDir,
157+
ArtifactPaths: artifactPaths,
158+
}, nil
159+
}
160+
161+
// checkBuildPrerequisites verifies that required tools are available for building images.
162+
func checkBuildPrerequisites(env *azldev.Env) error {
163+
if err := kiwi.CheckPrerequisites(env); err != nil {
164+
return fmt.Errorf("kiwi prerequisite check failed:\n%w", err)
165+
}
166+
167+
return nil
168+
}
169+
170+
// resolveImage looks up an image by name from the project configuration.
171+
func resolveImage(env *azldev.Env, imageName string) (*projectconfig.ImageConfig, error) {
172+
cfg := env.Config()
173+
if cfg == nil {
174+
return nil, errors.New("no project configuration loaded")
175+
}
176+
177+
if imageName == "" {
178+
return nil, errors.New("image name is required")
179+
}
180+
181+
imageConfig, ok := cfg.Images[imageName]
182+
if !ok {
183+
return nil, fmt.Errorf("image %#q not found in project configuration", imageName)
184+
}
185+
186+
return &imageConfig, nil
187+
}
188+
189+
// linkImageArtifacts hard-links the final image artifacts from the work directory to the
190+
// output directory. Uses symlinks to avoid duplicating large image files.
191+
// It parses kiwi's result JSON to determine which files are artifacts.
192+
func linkImageArtifacts(env *azldev.Env, workDir, outputDir string) ([]string, error) {
193+
// Parse kiwi's result file to get artifact paths.
194+
artifactSourcePaths, err := kiwi.ParseResult(env.FS(), workDir)
195+
if err != nil {
196+
return nil, fmt.Errorf("failed to parse kiwi result:\n%w", err)
197+
}
198+
199+
if len(artifactSourcePaths) == 0 {
200+
slog.Warn("No artifacts found in kiwi result", "workDir", workDir)
201+
202+
return []string{}, nil
203+
}
204+
205+
linkedPaths := make([]string, 0, len(artifactSourcePaths))
206+
207+
// Ensure output directory exists.
208+
err = fileutils.MkdirAll(env.FS(), outputDir)
209+
if err != nil {
210+
return nil, fmt.Errorf("failed to create output directory %#q:\n%w", outputDir, err)
211+
}
212+
213+
// First remove any existing files from previous builds.
214+
for _, sourcePath := range artifactSourcePaths {
215+
filename := filepath.Base(sourcePath)
216+
destPath := filepath.Join(outputDir, filename)
217+
218+
if exists, _ := fileutils.Exists(env.FS(), destPath); exists {
219+
err = env.FS().Remove(destPath)
220+
if err != nil {
221+
return linkedPaths, fmt.Errorf("failed to remove existing file %#q:\n%w", destPath, err)
222+
}
223+
}
224+
}
225+
226+
// Now try to link each artifact to the output directory.
227+
for _, sourcePath := range artifactSourcePaths {
228+
filename := filepath.Base(sourcePath)
229+
destPath := filepath.Join(outputDir, filename)
230+
231+
// Try symlink first (most efficient, no extra space), fall back to copy.
232+
err = fileutils.SymLinkOrCopy(env, env.FS(), sourcePath, destPath, fileutils.CopyFileOptions{
233+
PreserveFileMode: true,
234+
})
235+
if err != nil {
236+
return linkedPaths, fmt.Errorf("failed to link or copy artifact %#q to output:\n%w", filename, err)
237+
}
238+
239+
linkedPaths = append(linkedPaths, destPath)
240+
241+
slog.Info("Linked image artifact to output", "path", destPath)
242+
}
243+
244+
return linkedPaths, nil
245+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func OnAppInit(app *azldev.App) {
1616
}
1717

1818
app.AddTopLevelCommand(cmd)
19+
buildOnAppInit(app, cmd)
1920
customizeOnAppInit(app, cmd)
2021
injectFilesOnAppInit(app, cmd)
2122
listOnAppInit(app, cmd)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (b *Builder) prepSourcesForSRPM(
118118
defer prepEvent.End()
119119

120120
// Create a temp dir for the sources.
121-
preparedSourcesDir, err = b.workDirFactory.Create(component.GetConfig(), "sources")
121+
preparedSourcesDir, err = b.workDirFactory.Create(component.GetName(), "sources")
122122
if err != nil {
123123
return "", fmt.Errorf("failed to create work dir for source preparation:\n%w", err)
124124
}
@@ -139,7 +139,7 @@ func (b *Builder) buildSRPMFromPreparedSources(
139139
preparedSpecPath := path.Join(preparedSourcesDir, component.GetName()+".spec")
140140

141141
// Create a work dir for the SRPM itself.
142-
srpmOutputDir, err = b.workDirFactory.Create(component.GetConfig(), "srpm-build")
142+
srpmOutputDir, err = b.workDirFactory.Create(component.GetName(), "srpm-build")
143143
if err != nil {
144144
return "", fmt.Errorf("failed to create work dir:\n%w", err)
145145
}
@@ -206,7 +206,7 @@ func (b *Builder) buildRPM(
206206
var tempRPMDir string
207207

208208
// Create a temp dir for the RPM itself.
209-
tempRPMDir, err = b.workDirFactory.Create(component.GetConfig(), "rpm-build")
209+
tempRPMDir, err = b.workDirFactory.Create(component.GetName(), "rpm-build")
210210
if err != nil {
211211
return outputRPMPaths, fmt.Errorf("failed to create temp dir:\n%w", err)
212212
}

internal/app/azldev/core/workdir/workdir.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ func NewFactory(fs opctx.FS, baseDir string, timestampTime time.Time) (*Factory,
4646
// If a work directory with that label has already been used, then a unique one will be
4747
// generated with a random suffix.
4848
func (f *Factory) Create(
49-
component *projectconfig.ComponentConfig, label string,
49+
componentName string, label string,
5050
) (dirPath string, err error) {
5151
dateBasedDirName := f.timestampTime.Format("2006-01-02.150405")
5252

5353
componentNameToUse := defaultNameForGlobalWorkDir
54-
if component != nil {
55-
componentNameToUse = component.Name
54+
if componentName != "" {
55+
componentNameToUse = componentName
5656
}
5757

5858
parentDirPath := filepath.Join(f.baseDir, componentNameToUse, dateBasedDirName)
@@ -83,7 +83,7 @@ func MkComponentBuildEnvironment(
8383
label string,
8484
) (buildEnv buildenv.RPMAwareBuildEnv, err error) {
8585
// Create a work directory for us to place the environment under.
86-
workDir, err := factory.Create(component, label)
86+
workDir, err := factory.Create(component.Name, label)
8787
if err != nil {
8888
return nil, fmt.Errorf("failed to create work dir for component %q:\n%w", component.Name, err)
8989
}

internal/app/azldev/core/workdir/workdir_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func Test_Create(t *testing.T) {
4545
factory, err := workdir.NewFactory(env.FS(), env.Env.WorkDir(), env.Env.ConstructionTime())
4646
require.NoError(t, err)
4747

48-
dirPath, err := factory.Create(&component, testLabelName)
48+
dirPath, err := factory.Create(component.Name, testLabelName)
4949
require.NoError(t, err)
5050
require.NotEmpty(t, dirPath)
5151

internal/utils/fileutils/copy.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212

1313
"github.com/gim-home/azldev-preview/internal/global/opctx"
14+
"github.com/spf13/afero"
1415
)
1516

1617
// Options regarding file copying.
@@ -182,3 +183,44 @@ func CopyDirRecursiveCrossFS(
182183

183184
return nil
184185
}
186+
187+
// SymLinkOrCopy attempts to symlink a file, falling back to copy if symlinking
188+
// is not supported or fails. Symlinks are only attempted on real OS filesystems
189+
// (afero.OsFs). For other filesystem types (e.g., in-memory filesystems used in tests),
190+
// this function logs a warning and falls back directly to copying.
191+
func SymLinkOrCopy(
192+
dryRunnable opctx.DryRunnable,
193+
fs opctx.FS,
194+
sourcePath, destPath string,
195+
options CopyFileOptions,
196+
) error {
197+
if dryRunnable.DryRun() {
198+
slog.Info("Dry run; would symlink or copy file", "source", sourcePath, "dest", destPath)
199+
200+
return nil
201+
}
202+
203+
// See if the filesystem supports symlinks.
204+
if linker, ok := fs.(afero.Linker); ok {
205+
err := linker.SymlinkIfPossible(sourcePath, destPath)
206+
if err == nil {
207+
return nil
208+
}
209+
210+
// Symlink failed. Warn and fall back to copy.
211+
slog.Warn("Symlink failed, falling back to copy",
212+
"source", sourcePath,
213+
"dest", destPath,
214+
"error", err,
215+
)
216+
} else {
217+
// Not supported, skip symlink attempt.
218+
slog.Warn("Symlinking not supported on this filesystem, falling back to copy",
219+
"source", sourcePath,
220+
"dest", destPath,
221+
"fsType", fmt.Sprintf("%T", fs),
222+
)
223+
}
224+
225+
return CopyFile(dryRunnable, fs, sourcePath, destPath, options)
226+
}

0 commit comments

Comments
 (0)