Skip to content

Commit cfccea8

Browse files
authored
feat(component): place built RPMs/SRPMs into structured output dirs and validate channel names (#68)
Routes component build outputs into dedicated subdirectories under the project output dir, mirroring how a real publish pipeline organises packages.
1 parent b6a9106 commit cfccea8

File tree

8 files changed

+383
-15
lines changed

8 files changed

+383
-15
lines changed

docs/user/how-to/build-component.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ Build a component by name:
1515
azldev comp build -p <component-name>
1616
```
1717

18-
Built RPMs appear in the project's output directory (configured by `output-dir` in [project config](../reference/config/project.md)).
18+
Built packages are placed in structured subdirectories under the project's output directory
19+
(configured by `output-dir` in [project config](../reference/config/project.md)):
20+
21+
| Directory | Contents |
22+
|-----------|----------|
23+
| `out/srpms/` | Source RPMs (SRPMs) |
24+
| `out/rpms/` | Binary RPMs with no channel configured, or channel `none` |
25+
| `out/rpms/<channel>/` | Binary RPMs assigned to a named publish channel |
1926

2027
## Common Options
2128

docs/user/reference/cli/azldev_component_build.md

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

internal/app/azldev/cmds/component/build.go

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type ComponentBuildOptions struct {
4444
// RPMResult encapsulates a single binary RPM produced by a component build,
4545
// together with the resolved publish channel for that package.
4646
type RPMResult struct {
47-
// Path is the absolute path to the RPM file.
47+
// Path is the absolute path to the RPM file in the build output directory.
4848
Path string `json:"path" table:"Path"`
4949

5050
// PackageName is the binary package name extracted from the RPM header tag (e.g., "libcurl-devel").
@@ -90,8 +90,16 @@ func NewBuildCmd() *cobra.Command {
9090
Long: `Build RPM packages for one or more components using mock.
9191
9292
This command fetches upstream sources (applying any configured overlays),
93-
creates an SRPM, and invokes mock to produce binary RPMs. Built packages
94-
are placed in the project's output directory.
93+
creates an SRPM, and invokes mock to produce binary RPMs. Build outputs
94+
are placed in structured subdirectories under the project output directory:
95+
96+
out/srpms/ - source RPMs (SRPMs)
97+
out/rpms/ - binary RPMs with no channel configured (or channel="none")
98+
out/rpms/<channel>/ - binary RPMs moved to their configured publish channel subdirectory
99+
100+
The publish channel for each package is resolved from the project's package
101+
configuration (package groups and per-component overrides). See 'azldev package'
102+
for details.
95103
96104
Use --local-repo-with-publish to build a chain of dependent components:
97105
each component's RPMs are published to a local repository that subsequent
@@ -281,10 +289,27 @@ func buildComponentUsingBuilder(
281289
// Compose the path to the output dir.
282290
outputDir := env.OutputDir()
283291

284-
// Make sure we have a final output dir.
292+
// All binary RPMs land in out/rpms/ so they are kept separate from SRPMs
293+
// and other build artifacts in out/.
294+
rpmsDir := filepath.Join(outputDir, "rpms")
295+
296+
// SRPMs land in out/srpms/.
297+
srpmsDir := filepath.Join(outputDir, "srpms")
298+
299+
// Make sure all output directories exist.
285300
err = fileutils.MkdirAll(env.FS(), outputDir)
286301
if err != nil {
287-
return results, fmt.Errorf("failed to ensure dir %q exists: %w", outputDir, err)
302+
return results, fmt.Errorf("failed to ensure dir %#q exists:\n%w", outputDir, err)
303+
}
304+
305+
err = fileutils.MkdirAll(env.FS(), rpmsDir)
306+
if err != nil {
307+
return results, fmt.Errorf("failed to ensure dir %#q exists:\n%w", rpmsDir, err)
308+
}
309+
310+
err = fileutils.MkdirAll(env.FS(), srpmsDir)
311+
if err != nil {
312+
return results, fmt.Errorf("failed to ensure dir %#q exists:\n%w", srpmsDir, err)
288313
}
289314

290315
buildEvent := env.StartEvent("Building packages with mock", "component", component.GetName())
@@ -295,9 +320,9 @@ func buildComponentUsingBuilder(
295320
// Build the SRPM.
296321
//
297322

298-
outputSourcePackagePath, err := builder.BuildSourcePackage(env, component, localRepoPaths, outputDir)
323+
outputSourcePackagePath, err := builder.BuildSourcePackage(env, component, localRepoPaths, srpmsDir)
299324
if err != nil {
300-
return results, fmt.Errorf("failed to build SRPM for %q:\n%w", component.GetName(), err)
325+
return results, fmt.Errorf("failed to build SRPM for %#q:\n%w", component.GetName(), err)
301326
}
302327

303328
// Start filling out results.
@@ -314,10 +339,10 @@ func buildComponentUsingBuilder(
314339
//
315340

316341
results.RPMPaths, err = builder.BuildBinaryPackage(
317-
env, component, outputSourcePackagePath, localRepoPaths, outputDir, noCheck,
342+
env, component, outputSourcePackagePath, localRepoPaths, rpmsDir, noCheck,
318343
)
319344
if err != nil {
320-
return results, fmt.Errorf("failed to build RPM for %q: %w", component.GetName(), err)
345+
return results, fmt.Errorf("failed to build RPM for %#q:\n%w", component.GetName(), err)
321346
}
322347

323348
// Enrich each RPM with its binary package name and resolved publish channel.
@@ -326,6 +351,17 @@ func buildComponentUsingBuilder(
326351
return results, fmt.Errorf("failed to resolve publish channels for %#q:\n%w", component.GetName(), err)
327352
}
328353

354+
// Move RPMs with a channel into out/rpms/<channel>/, leaving unconfigured ones in out/rpms/.
355+
if err = PlaceRPMsByChannel(env, results.RPMs, rpmsDir); err != nil {
356+
return results, fmt.Errorf("failed to place RPMs by channel for %#q:\n%w", component.GetName(), err)
357+
}
358+
359+
// Sync RPMPaths to the final (possibly moved) locations.
360+
results.RPMPaths = make([]string, len(results.RPMs))
361+
for rpmIdx, rpm := range results.RPMs {
362+
results.RPMPaths[rpmIdx] = rpm.Path
363+
}
364+
329365
// Populate the parallel Channels slice for table display.
330366
results.RPMChannels = make([]string, len(results.RPMs))
331367
for rpmIdx, rpm := range results.RPMs {
@@ -343,6 +379,34 @@ func buildComponentUsingBuilder(
343379
return results, nil
344380
}
345381

382+
// PlaceRPMsByChannel moves each RPM with a configured channel from its initial location in
383+
// rpmsDir to a channel-specific subdirectory rpmsDir/<channel>/.
384+
// RPMs whose channel is empty or the reserved value "none" remain in rpmsDir.
385+
// [RPMResult.Path] is updated in-place to reflect the final location of each RPM.
386+
func PlaceRPMsByChannel(env *azldev.Env, rpmResults []RPMResult, rpmsDir string) error {
387+
for rpmIdx, rpm := range rpmResults {
388+
if rpm.Channel == "" || rpm.Channel == "none" {
389+
continue
390+
}
391+
392+
channelDir := filepath.Join(rpmsDir, rpm.Channel)
393+
394+
if err := fileutils.MkdirAll(env.FS(), channelDir); err != nil {
395+
return fmt.Errorf("failed to create channel directory %#q:\n%w", channelDir, err)
396+
}
397+
398+
destPath := filepath.Join(channelDir, filepath.Base(rpm.Path))
399+
400+
if err := env.FS().Rename(rpm.Path, destPath); err != nil {
401+
return fmt.Errorf("failed to move package %#q to channel %#q:\n%w", rpm.PackageName, rpm.Channel, err)
402+
}
403+
404+
rpmResults[rpmIdx].Path = destPath
405+
}
406+
407+
return nil
408+
}
409+
346410
// validateBuildOptions validates the build options before any work is done.
347411
func validateBuildOptions(env *azldev.Env, options *ComponentBuildOptions) error {
348412
// Check for overlap between --local-repo and --local-repo-with-publish.

internal/app/azldev/cmds/component/build_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ package component_test
66
import (
77
"testing"
88

9+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
910
componentcmds "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component"
11+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
1012
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
15+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
1116
"github.com/stretchr/testify/assert"
1217
"github.com/stretchr/testify/require"
1318
)
@@ -95,3 +100,213 @@ func TestValidateBuildOptions_NoOverlapWithDifferentPaths(t *testing.T) {
95100
assert.Contains(t, err.Error(), "createrepo_c")
96101
assert.NotContains(t, err.Error(), "appears in both")
97102
}
103+
104+
func TestPlaceRPMsByChannel_EmptyChannelStaysInPlace(t *testing.T) {
105+
t.Parallel()
106+
107+
testEnv := testutils.NewTestEnv(t)
108+
109+
const (
110+
rpmsDir = "/output/rpms"
111+
rpmPath = "/output/rpms/curl-8.0-1.rpm"
112+
)
113+
114+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, rpmPath, []byte("rpm"), fileperms.PrivateFile))
115+
116+
rpmResults := []componentcmds.RPMResult{
117+
{Path: rpmPath, PackageName: "curl", Channel: ""},
118+
}
119+
120+
require.NoError(t, componentcmds.PlaceRPMsByChannel(testEnv.Env, rpmResults, rpmsDir))
121+
122+
// File stays at its original path; RPMResult.Path is unchanged.
123+
assert.Equal(t, rpmPath, rpmResults[0].Path)
124+
_, err := testEnv.TestFS.Stat(rpmPath)
125+
assert.NoError(t, err, "original path should still exist")
126+
}
127+
128+
func TestPlaceRPMsByChannel_NoneChannelStaysInPlace(t *testing.T) {
129+
t.Parallel()
130+
131+
testEnv := testutils.NewTestEnv(t)
132+
133+
const (
134+
rpmsDir = "/output/rpms"
135+
rpmPath = "/output/rpms/debuginfo-8.0-1.rpm"
136+
)
137+
138+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, rpmPath, []byte("rpm"), fileperms.PrivateFile))
139+
140+
rpmResults := []componentcmds.RPMResult{
141+
{Path: rpmPath, PackageName: "debuginfo", Channel: "none"},
142+
}
143+
144+
require.NoError(t, componentcmds.PlaceRPMsByChannel(testEnv.Env, rpmResults, rpmsDir))
145+
146+
assert.Equal(t, rpmPath, rpmResults[0].Path)
147+
_, err := testEnv.TestFS.Stat(rpmPath)
148+
assert.NoError(t, err, "original path should still exist")
149+
}
150+
151+
func TestPlaceRPMsByChannel_NamedChannelMovesFile(t *testing.T) {
152+
t.Parallel()
153+
154+
testEnv := testutils.NewTestEnv(t)
155+
156+
const (
157+
rpmsDir = "/output/rpms"
158+
rpmPath = "/output/rpms/curl-8.0-1.rpm"
159+
expectedPath = "/output/rpms/base/curl-8.0-1.rpm"
160+
)
161+
162+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, rpmPath, []byte("rpm"), fileperms.PrivateFile))
163+
164+
rpmResults := []componentcmds.RPMResult{
165+
{Path: rpmPath, PackageName: "curl", Channel: "base"},
166+
}
167+
168+
require.NoError(t, componentcmds.PlaceRPMsByChannel(testEnv.Env, rpmResults, rpmsDir))
169+
170+
// RPMResult.Path must be updated to the new location.
171+
assert.Equal(t, expectedPath, rpmResults[0].Path)
172+
173+
// File must exist at the channel subdirectory.
174+
_, err := testEnv.TestFS.Stat(expectedPath)
175+
require.NoError(t, err, "file should exist at channel subdirectory")
176+
177+
// File must no longer exist at the original location.
178+
_, err = testEnv.TestFS.Stat(rpmPath)
179+
assert.Error(t, err, "file should have been moved away from the original path")
180+
}
181+
182+
func TestPlaceRPMsByChannel_MultipleRPMsDifferentChannels(t *testing.T) {
183+
t.Parallel()
184+
185+
testEnv := testutils.NewTestEnv(t)
186+
187+
const rpmsDir = "/output/rpms"
188+
189+
type rpmInput struct {
190+
path string
191+
channel string
192+
}
193+
194+
inputs := []rpmInput{
195+
{"/output/rpms/curl-8.0-1.rpm", "base"},
196+
{"/output/rpms/curl-devel-8.0-1.rpm", "devel"},
197+
{"/output/rpms/curl-debuginfo-8.0-1.rpm", "none"},
198+
{"/output/rpms/curl-static-8.0-1.rpm", ""},
199+
}
200+
201+
rpmResults := make([]componentcmds.RPMResult, 0, len(inputs))
202+
203+
for _, in := range inputs {
204+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, in.path, []byte("rpm"), fileperms.PrivateFile))
205+
206+
rpmResults = append(rpmResults, componentcmds.RPMResult{
207+
Path: in.path, PackageName: "curl", Channel: in.channel,
208+
})
209+
}
210+
211+
require.NoError(t, componentcmds.PlaceRPMsByChannel(testEnv.Env, rpmResults, rpmsDir))
212+
213+
for _, result := range rpmResults {
214+
switch result.Channel {
215+
case "base":
216+
assert.Equal(t, "/output/rpms/base/curl-8.0-1.rpm", result.Path)
217+
case "devel":
218+
assert.Equal(t, "/output/rpms/devel/curl-devel-8.0-1.rpm", result.Path)
219+
case "none":
220+
assert.Equal(t, "/output/rpms/curl-debuginfo-8.0-1.rpm", result.Path)
221+
case "":
222+
assert.Equal(t, "/output/rpms/curl-static-8.0-1.rpm", result.Path)
223+
}
224+
225+
_, statErr := testEnv.TestFS.Stat(result.Path)
226+
assert.NoError(t, statErr, "RPM should exist at its final resolved path")
227+
}
228+
}
229+
230+
func TestPlaceRPMsByChannel_MultipleRPMsSameChannel(t *testing.T) {
231+
t.Parallel()
232+
233+
testEnv := testutils.NewTestEnv(t)
234+
235+
const rpmsDir = "/output/rpms"
236+
237+
paths := []string{
238+
"/output/rpms/curl-8.0-1.rpm",
239+
"/output/rpms/libcurl-8.0-1.rpm",
240+
}
241+
242+
rpmResults := make([]componentcmds.RPMResult, 0, len(paths))
243+
244+
for _, path := range paths {
245+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, path, []byte("rpm"), fileperms.PrivateFile))
246+
247+
rpmResults = append(rpmResults, componentcmds.RPMResult{
248+
Path: path, PackageName: "curl", Channel: "base",
249+
})
250+
}
251+
252+
require.NoError(t, componentcmds.PlaceRPMsByChannel(testEnv.Env, rpmResults, rpmsDir))
253+
254+
assert.Equal(t, "/output/rpms/base/curl-8.0-1.rpm", rpmResults[0].Path)
255+
assert.Equal(t, "/output/rpms/base/libcurl-8.0-1.rpm", rpmResults[1].Path)
256+
257+
for _, result := range rpmResults {
258+
_, err := testEnv.TestFS.Stat(result.Path)
259+
assert.NoError(t, err, "both RPMs should exist in the shared channel subdirectory")
260+
}
261+
}
262+
263+
func TestPlaceRPMsByChannel_EmptyInput(t *testing.T) {
264+
t.Parallel()
265+
266+
testEnv := testutils.NewTestEnv(t)
267+
268+
err := componentcmds.PlaceRPMsByChannel(testEnv.Env, nil, "/output/rpms")
269+
assert.NoError(t, err)
270+
}
271+
272+
func makeEnvWithDirs(t *testing.T, workDir, outputDir string) *azldev.Env {
273+
t.Helper()
274+
275+
base := testutils.NewTestEnv(t)
276+
277+
cfg := projectconfig.NewProjectConfig()
278+
cfg.Project.WorkDir = workDir
279+
cfg.Project.OutputDir = outputDir
280+
281+
options := azldev.NewEnvOptions()
282+
options.DryRunnable = base.DryRunnable
283+
options.EventListener = base.EventListener
284+
options.Interfaces = base.TestInterfaces
285+
options.Config = &cfg
286+
287+
return azldev.NewEnv(t.Context(), options)
288+
}
289+
290+
func TestBuildComponents_NoWorkDir(t *testing.T) {
291+
t.Parallel()
292+
293+
env := makeEnvWithDirs(t, "", "/output")
294+
comps := components.NewComponentSet()
295+
296+
_, err := componentcmds.BuildComponents(env, comps, &componentcmds.ComponentBuildOptions{})
297+
298+
require.Error(t, err)
299+
assert.Contains(t, err.Error(), "work dir")
300+
}
301+
302+
func TestBuildComponents_NoOutputDir(t *testing.T) {
303+
t.Parallel()
304+
305+
env := makeEnvWithDirs(t, "/work", "")
306+
comps := components.NewComponentSet()
307+
308+
_, err := componentcmds.BuildComponents(env, comps, &componentcmds.ComponentBuildOptions{})
309+
310+
require.Error(t, err)
311+
assert.Contains(t, err.Error(), "output dir")
312+
}

0 commit comments

Comments
 (0)