Skip to content

Commit ac37d02

Browse files
authored
feat(render): Render files into subdirectories (#88)
1 parent efbc453 commit ac37d02

File tree

10 files changed

+93
-50
lines changed

10 files changed

+93
-50
lines changed

docs/user/reference/cli/azldev_component_render.md

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

docs/user/reference/config/project.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The `log-dir`, `work-dir`, `output-dir`, and `rendered-specs-dir` paths are reso
2424
- **`log-dir`** — build logs are written here (e.g., `azldev.log`)
2525
- **`work-dir`** — temporary per-component working directories are created under this path during builds (e.g., source preparation, SRPM construction)
2626
- **`output-dir`** — final build artifacts (RPMs, SRPMs) are placed here
27-
- **`rendered-specs-dir`** — rendered spec and sidecar files are written here by `azldev component render`
27+
- **`rendered-specs-dir`** — rendered spec and sidecar files are written here by `azldev component render`. Components are organized into letter-prefixed subdirectories (e.g., `SPECS/c/curl`, `SPECS/v/vim`)
2828

2929
> **Note:** Do not edit files under these directories manually — they are managed by azldev and may be overwritten or cleaned at any time.
3030

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func TestListComponents_WithRenderedSpecsDir(t *testing.T) {
9393

9494
result := results[0]
9595
assert.Equal(t, testComponentName, result.Name)
96-
assert.Equal(t, filepath.Join(testRenderedDir, testComponentName), result.RenderedSpecDir)
96+
assert.Equal(t, filepath.Join(testRenderedDir, "v", testComponentName), result.RenderedSpecDir)
9797
}
9898

9999
func TestListComponents_MultipleWithRenderedSpecsDir(t *testing.T) {
@@ -121,7 +121,14 @@ func TestListComponents_MultipleWithRenderedSpecsDir(t *testing.T) {
121121
require.NoError(t, err)
122122
require.Len(t, results, 2)
123123

124+
expectedDirs := map[string]string{
125+
"curl": filepath.Join(testRenderedDir, "c", "curl"),
126+
"vim": filepath.Join(testRenderedDir, "v", "vim"),
127+
}
128+
124129
for _, result := range results {
125-
assert.Equal(t, filepath.Join(testRenderedDir, result.Name), result.RenderedSpecDir)
130+
expected, ok := expectedDirs[result.Name]
131+
require.True(t, ok, "unexpected component %q in results", result.Name)
132+
assert.Equal(t, expected, result.RenderedSpecDir)
126133
}
127134
}

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

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ intended for check-in.
5252
5353
The output directory is set via rendered-specs-dir in the project config, or
5454
via --output-dir on the command line. If neither is set, an error is returned.
55+
Within the output directory, components are organized into letter-prefixed
56+
subdirectories based on the first character of their name (e.g., specs/c/curl,
57+
specs/v/vim).
5558
5659
Unlike prepare-sources, render skips downloading source tarballs from the
5760
lookaside cache — only spec files, patches, scripts, and other git-tracked
@@ -179,7 +182,7 @@ func RenderComponents(env *azldev.Env, options *RenderOptions) ([]*RenderResult,
179182
mockResultMap := batchMockProcess(env, mockProcessor, stagingDir, prepared)
180183

181184
// ── Phase 3: Parallel finishing ──
182-
parallelFinish(env, prepared, mockResultMap, results, stagingDir, options.OutputDir,
185+
parallelFinish(env, prepared, mockResultMap, results, stagingDir,
183186
options.Force)
184187

185188
// Clean up stale rendered directories when explicitly requested.
@@ -511,7 +514,6 @@ func parallelFinish(
511514
mockResultMap map[string]*sources.ComponentMockResult,
512515
results []*RenderResult,
513516
stagingDir string,
514-
outputDir string,
515517
allowOverwrite bool,
516518
) {
517519
if len(prepared) == 0 {
@@ -540,7 +542,7 @@ func parallelFinish(
540542
go func(prep *preparedComponent) {
541543
defer waitGroup.Done()
542544

543-
result := finishOneComponent(workerEnv, env, prep, mockResultMap, semaphore, stagingDir, outputDir, allowOverwrite)
545+
result := finishOneComponent(workerEnv, env, prep, mockResultMap, semaphore, stagingDir, allowOverwrite)
544546
resultsChan <- finishResult{index: prep.index, result: result}
545547
}(prep)
546548
}
@@ -568,7 +570,6 @@ func finishOneComponent(
568570
mockResultMap map[string]*sources.ComponentMockResult,
569571
semaphore chan struct{},
570572
stagingDir string,
571-
outputDir string,
572573
allowOverwrite bool,
573574
) *RenderResult {
574575
componentName := prep.comp.GetName()
@@ -593,7 +594,7 @@ func finishOneComponent(
593594
Status: renderStatusOK,
594595
}
595596

596-
err := finishComponentRender(env, prep, mockResultMap, stagingDir, outputDir, allowOverwrite)
597+
err := finishComponentRender(env, prep, mockResultMap, stagingDir, allowOverwrite)
597598
if err != nil {
598599
slog.Error("Failed to finish rendering component",
599600
"component", componentName, "error", err)
@@ -624,7 +625,6 @@ func finishComponentRender(
624625
prep *preparedComponent,
625626
mockResultMap map[string]*sources.ComponentMockResult,
626627
stagingDir string,
627-
baseOutputDir string,
628628
allowOverwrite bool,
629629
) error {
630630
componentName := prep.comp.GetName()
@@ -658,22 +658,20 @@ func finishComponentRender(
658658
}
659659

660660
// Copy rendered files to the component's output directory.
661-
if copyErr := copyRenderedOutput(env, componentDir, baseOutputDir, componentName, allowOverwrite); copyErr != nil {
661+
if copyErr := copyRenderedOutput(env, componentDir, prep.compOutputDir, allowOverwrite); copyErr != nil {
662662
return copyErr
663663
}
664664

665665
slog.Info("Rendered component", "component", componentName,
666-
"output", filepath.Join(baseOutputDir, componentName))
666+
"output", prep.compOutputDir)
667667

668668
return nil
669669
}
670670

671671
// copyRenderedOutput copies the rendered files from tempDir to the component's output directory.
672672
// For managed output (inside project root), existing output is removed before copying.
673673
// For external output, existing directories cause an error.
674-
func copyRenderedOutput(env *azldev.Env, tempDir, baseOutputDir, componentName string, allowOverwrite bool) error {
675-
componentOutputDir := filepath.Join(baseOutputDir, componentName)
676-
674+
func copyRenderedOutput(env *azldev.Env, tempDir, componentOutputDir string, allowOverwrite bool) error {
677675
exists, existsErr := fileutils.DirExists(env.FS(), componentOutputDir)
678676
if existsErr != nil {
679677
return fmt.Errorf("checking output directory %#q:\n%w", componentOutputDir, existsErr)
@@ -704,7 +702,7 @@ func copyRenderedOutput(env *azldev.Env, tempDir, baseOutputDir, componentName s
704702
}
705703

706704
if copyErr := fileutils.CopyDirRecursive(env, env.FS(), tempDir, componentOutputDir, copyOptions); copyErr != nil {
707-
return fmt.Errorf("copying rendered files for %#q:\n%w", componentName, copyErr)
705+
return fmt.Errorf("copying rendered files to %#q:\n%w", componentOutputDir, copyErr)
708706
}
709707

710708
return nil
@@ -770,6 +768,8 @@ func findSpecFile(fs opctx.FS, dir, componentName string) (string, error) {
770768

771769
// cleanupStaleRenders removes rendered output directories for components that
772770
// no longer exist in the current configuration. Only called during full renders (-a).
771+
// The output directory uses letter-prefix subdirectories (e.g., SPECS/c/curl),
772+
// so this walks two levels: letter directories, then component directories within each.
773773
func cleanupStaleRenders(fs opctx.FS, currentComponents *components.ComponentSet, outputDir string) error {
774774
exists, existsErr := fileutils.Exists(fs, outputDir)
775775
if existsErr != nil {
@@ -780,7 +780,7 @@ func cleanupStaleRenders(fs opctx.FS, currentComponents *components.ComponentSet
780780
return nil
781781
}
782782

783-
entries, err := fileutils.ReadDir(fs, outputDir)
783+
letterEntries, err := fileutils.ReadDir(fs, outputDir)
784784
if err != nil {
785785
return fmt.Errorf("reading output directory %#q:\n%w", outputDir, err)
786786
}
@@ -791,22 +791,34 @@ func cleanupStaleRenders(fs opctx.FS, currentComponents *components.ComponentSet
791791
currentNames[comp.GetName()] = true
792792
}
793793

794-
for _, entry := range entries {
795-
// Skip non-directories and known non-component files.
796-
if !entry.IsDir() {
794+
for _, letterEntry := range letterEntries {
795+
if !letterEntry.IsDir() {
797796
continue
798797
}
799798

800-
if currentNames[entry.Name()] {
801-
continue
799+
letterDir := filepath.Join(outputDir, letterEntry.Name())
800+
801+
compEntries, readErr := fileutils.ReadDir(fs, letterDir)
802+
if readErr != nil {
803+
return fmt.Errorf("reading letter directory %#q:\n%w", letterDir, readErr)
802804
}
803805

804-
stalePath := filepath.Join(outputDir, entry.Name())
806+
for _, compEntry := range compEntries {
807+
if !compEntry.IsDir() {
808+
continue
809+
}
810+
811+
if currentNames[compEntry.Name()] {
812+
continue
813+
}
805814

806-
slog.Info("Removing stale rendered output", "directory", stalePath)
815+
stalePath := filepath.Join(letterDir, compEntry.Name())
807816

808-
if removeErr := fs.RemoveAll(stalePath); removeErr != nil {
809-
return fmt.Errorf("removing stale directory %#q:\n%w", stalePath, removeErr)
817+
slog.Info("Removing stale rendered output", "directory", stalePath)
818+
819+
if removeErr := fs.RemoveAll(stalePath); removeErr != nil {
820+
return fmt.Errorf("removing stale directory %#q:\n%w", stalePath, removeErr)
821+
}
810822
}
811823
}
812824

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,13 @@ func TestCleanupStaleRenders(t *testing.T) {
8686
testFS := afero.NewMemMapFs()
8787
ctrl := gomock.NewController(t)
8888

89-
// Create output directories for curl, wget, and stale-pkg.
89+
// Create letter-prefixed output directories for curl, wget, and stale-pkg.
9090
for _, name := range []string{"curl", "wget", "stale-pkg"} {
91-
require.NoError(t, fileutils.MkdirAll(testFS, filepath.Join("/output", name)))
91+
prefix := string(name[0])
92+
dir := filepath.Join("/output", prefix, name)
93+
require.NoError(t, fileutils.MkdirAll(testFS, dir))
9294
require.NoError(t, fileutils.WriteFile(testFS,
93-
filepath.Join("/output", name, name+".spec"),
95+
filepath.Join(dir, name+".spec"),
9496
[]byte("Name: "+name), fileperms.PublicFile))
9597
}
9698

@@ -108,13 +110,14 @@ func TestCleanupStaleRenders(t *testing.T) {
108110

109111
// curl and wget should still exist.
110112
for _, name := range []string{"curl", "wget"} {
111-
exists, existsErr := fileutils.Exists(testFS, filepath.Join("/output", name))
113+
prefix := string(name[0])
114+
exists, existsErr := fileutils.Exists(testFS, filepath.Join("/output", prefix, name))
112115
require.NoError(t, existsErr)
113116
assert.True(t, exists, "%s should still exist", name)
114117
}
115118

116119
// stale-pkg should be removed.
117-
exists, err := fileutils.Exists(testFS, "/output/stale-pkg")
120+
exists, err := fileutils.Exists(testFS, "/output/s/stale-pkg")
118121
require.NoError(t, err)
119122
assert.False(t, exists, "stale-pkg should be removed")
120123
})

internal/app/azldev/core/components/renderedspecdir.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ package components
66
import (
77
"fmt"
88
"path/filepath"
9+
"strings"
910

1011
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
1112
)
1213

1314
// RenderedSpecDir returns the rendered spec output directory for a given component.
14-
// The path is computed as {renderedSpecsDir}/{componentName}.
15+
// Components are organized by the lowercase first letter of their name:
16+
// {renderedSpecsDir}/{letter}/{componentName} (e.g., "SPECS/c/curl").
1517
// Returns an empty string if renderedSpecsDir is not configured (empty).
1618
// Returns an error if componentName is unsafe (absolute, contains path separators
1719
// or traversal sequences).
@@ -24,5 +26,7 @@ func RenderedSpecDir(renderedSpecsDir, componentName string) (string, error) {
2426
return "", nil
2527
}
2628

27-
return filepath.Join(renderedSpecsDir, componentName), nil
29+
prefix := strings.ToLower(componentName[:1])
30+
31+
return filepath.Join(renderedSpecsDir, prefix, componentName), nil
2832
}

internal/app/azldev/core/components/renderedspecdir_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import (
1212
)
1313

1414
func TestRenderedSpecDir(t *testing.T) {
15-
t.Run("ReturnsPathWhenConfigured", func(t *testing.T) {
15+
t.Run("ReturnsLetterPrefixedPath", func(t *testing.T) {
1616
result, err := components.RenderedSpecDir("/path/to/specs", "vim")
1717
require.NoError(t, err)
18-
assert.Equal(t, "/path/to/specs/vim", result)
18+
assert.Equal(t, "/path/to/specs/v/vim", result)
1919
})
2020

2121
t.Run("ReturnsEmptyWhenNotConfigured", func(t *testing.T) {
@@ -24,10 +24,16 @@ func TestRenderedSpecDir(t *testing.T) {
2424
assert.Empty(t, result)
2525
})
2626

27+
t.Run("LowercasesPrefixForUppercaseName", func(t *testing.T) {
28+
result, err := components.RenderedSpecDir("/specs", "SymCrypt")
29+
require.NoError(t, err)
30+
assert.Equal(t, "/specs/s/SymCrypt", result)
31+
})
32+
2733
t.Run("HandlesComponentNameWithDashes", func(t *testing.T) {
2834
result, err := components.RenderedSpecDir("/rendered", "my-component")
2935
require.NoError(t, err)
30-
assert.Equal(t, "/rendered/my-component", result)
36+
assert.Equal(t, "/rendered/m/my-component", result)
3137
})
3238

3339
t.Run("RejectsAbsoluteComponentName", func(t *testing.T) {

internal/utils/fileutils/file.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,12 @@ func ValidateFilename(filename string) error {
9696
return fmt.Errorf("filename %#q must not contain backslashes", filename)
9797
}
9898

99+
// Reject non-ASCII characters. RPM package names are ASCII-only, and
100+
// non-ASCII bytes would produce garbled single-byte prefixes when used
101+
// for letter-bucketed directory layouts.
102+
if strings.ContainsFunc(filename, func(r rune) bool { return r > unicode.MaxASCII }) {
103+
return fmt.Errorf("filename %#q must contain only ASCII characters", filename)
104+
}
105+
99106
return nil
100107
}

internal/utils/fileutils/file_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func TestValidateFilename(t *testing.T) {
9292
{name: "tab in name", filename: "has\ttab.tar.gz", expectedError: "must not contain whitespace"},
9393
{name: "null byte in name", filename: "has\x00null.tar.gz", expectedError: "must not contain null bytes"},
9494
{name: "backslash in name", filename: "foo\\bar.tar.gz", expectedError: "must not contain backslashes"},
95+
{name: "non-ASCII characters", filename: "foo\x80bar.tar.gz", expectedError: "must contain only ASCII characters"},
9596
}
9697

9798
for _, tc := range tests {

0 commit comments

Comments
 (0)