Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/user/reference/cli/azldev_component_render.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/user/reference/config/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The `log-dir`, `work-dir`, `output-dir`, and `rendered-specs-dir` paths are reso
- **`log-dir`** — build logs are written here (e.g., `azldev.log`)
- **`work-dir`** — temporary per-component working directories are created under this path during builds (e.g., source preparation, SRPM construction)
- **`output-dir`** — final build artifacts (RPMs, SRPMs) are placed here
- **`rendered-specs-dir`** — rendered spec and sidecar files are written here by `azldev component render`
- **`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`)

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

Expand Down
11 changes: 9 additions & 2 deletions internal/app/azldev/cmds/component/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestListComponents_WithRenderedSpecsDir(t *testing.T) {

result := results[0]
assert.Equal(t, testComponentName, result.Name)
assert.Equal(t, filepath.Join(testRenderedDir, testComponentName), result.RenderedSpecDir)
assert.Equal(t, filepath.Join(testRenderedDir, "v", testComponentName), result.RenderedSpecDir)
}

func TestListComponents_MultipleWithRenderedSpecsDir(t *testing.T) {
Expand Down Expand Up @@ -121,7 +121,14 @@ func TestListComponents_MultipleWithRenderedSpecsDir(t *testing.T) {
require.NoError(t, err)
require.Len(t, results, 2)

expectedDirs := map[string]string{
"curl": filepath.Join(testRenderedDir, "c", "curl"),
"vim": filepath.Join(testRenderedDir, "v", "vim"),
}

for _, result := range results {
assert.Equal(t, filepath.Join(testRenderedDir, result.Name), result.RenderedSpecDir)
expected, ok := expectedDirs[result.Name]
require.True(t, ok, "unexpected component %q in results", result.Name)
assert.Equal(t, expected, result.RenderedSpecDir)
}
}
56 changes: 34 additions & 22 deletions internal/app/azldev/cmds/component/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ intended for check-in.

The output directory is set via rendered-specs-dir in the project config, or
via --output-dir on the command line. If neither is set, an error is returned.
Within the output directory, components are organized into letter-prefixed
subdirectories based on the first character of their name (e.g., specs/c/curl,
specs/v/vim).

Unlike prepare-sources, render skips downloading source tarballs from the
lookaside cache — only spec files, patches, scripts, and other git-tracked
Expand Down Expand Up @@ -179,7 +182,7 @@ func RenderComponents(env *azldev.Env, options *RenderOptions) ([]*RenderResult,
mockResultMap := batchMockProcess(env, mockProcessor, stagingDir, prepared)

// ── Phase 3: Parallel finishing ──
parallelFinish(env, prepared, mockResultMap, results, stagingDir, options.OutputDir,
parallelFinish(env, prepared, mockResultMap, results, stagingDir,
options.Force)

// Clean up stale rendered directories when explicitly requested.
Expand Down Expand Up @@ -511,7 +514,6 @@ func parallelFinish(
mockResultMap map[string]*sources.ComponentMockResult,
results []*RenderResult,
stagingDir string,
outputDir string,
allowOverwrite bool,
) {
if len(prepared) == 0 {
Expand Down Expand Up @@ -540,7 +542,7 @@ func parallelFinish(
go func(prep *preparedComponent) {
defer waitGroup.Done()

result := finishOneComponent(workerEnv, env, prep, mockResultMap, semaphore, stagingDir, outputDir, allowOverwrite)
result := finishOneComponent(workerEnv, env, prep, mockResultMap, semaphore, stagingDir, allowOverwrite)
resultsChan <- finishResult{index: prep.index, result: result}
}(prep)
}
Expand Down Expand Up @@ -568,7 +570,6 @@ func finishOneComponent(
mockResultMap map[string]*sources.ComponentMockResult,
semaphore chan struct{},
stagingDir string,
outputDir string,
allowOverwrite bool,
) *RenderResult {
componentName := prep.comp.GetName()
Expand All @@ -593,7 +594,7 @@ func finishOneComponent(
Status: renderStatusOK,
}

err := finishComponentRender(env, prep, mockResultMap, stagingDir, outputDir, allowOverwrite)
err := finishComponentRender(env, prep, mockResultMap, stagingDir, allowOverwrite)
if err != nil {
slog.Error("Failed to finish rendering component",
"component", componentName, "error", err)
Expand Down Expand Up @@ -624,7 +625,6 @@ func finishComponentRender(
prep *preparedComponent,
mockResultMap map[string]*sources.ComponentMockResult,
stagingDir string,
baseOutputDir string,
allowOverwrite bool,
) error {
componentName := prep.comp.GetName()
Expand Down Expand Up @@ -658,22 +658,20 @@ func finishComponentRender(
}

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

slog.Info("Rendered component", "component", componentName,
"output", filepath.Join(baseOutputDir, componentName))
"output", prep.compOutputDir)

return nil
}

// copyRenderedOutput copies the rendered files from tempDir to the component's output directory.
// For managed output (inside project root), existing output is removed before copying.
// For external output, existing directories cause an error.
func copyRenderedOutput(env *azldev.Env, tempDir, baseOutputDir, componentName string, allowOverwrite bool) error {
componentOutputDir := filepath.Join(baseOutputDir, componentName)

func copyRenderedOutput(env *azldev.Env, tempDir, componentOutputDir string, allowOverwrite bool) error {
exists, existsErr := fileutils.DirExists(env.FS(), componentOutputDir)
if existsErr != nil {
return fmt.Errorf("checking output directory %#q:\n%w", componentOutputDir, existsErr)
Expand Down Expand Up @@ -704,7 +702,7 @@ func copyRenderedOutput(env *azldev.Env, tempDir, baseOutputDir, componentName s
}

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

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

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

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

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

if currentNames[entry.Name()] {
continue
letterDir := filepath.Join(outputDir, letterEntry.Name())

compEntries, readErr := fileutils.ReadDir(fs, letterDir)
if readErr != nil {
return fmt.Errorf("reading letter directory %#q:\n%w", letterDir, readErr)
}

stalePath := filepath.Join(outputDir, entry.Name())
for _, compEntry := range compEntries {
if !compEntry.IsDir() {
continue
}

if currentNames[compEntry.Name()] {
continue
}

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

if removeErr := fs.RemoveAll(stalePath); removeErr != nil {
return fmt.Errorf("removing stale directory %#q:\n%w", stalePath, removeErr)
slog.Info("Removing stale rendered output", "directory", stalePath)

if removeErr := fs.RemoveAll(stalePath); removeErr != nil {
return fmt.Errorf("removing stale directory %#q:\n%w", stalePath, removeErr)
}
}
}

Expand Down
13 changes: 8 additions & 5 deletions internal/app/azldev/cmds/component/render_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@ func TestCleanupStaleRenders(t *testing.T) {
testFS := afero.NewMemMapFs()
ctrl := gomock.NewController(t)

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

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

// curl and wget should still exist.
for _, name := range []string{"curl", "wget"} {
exists, existsErr := fileutils.Exists(testFS, filepath.Join("/output", name))
prefix := string(name[0])
exists, existsErr := fileutils.Exists(testFS, filepath.Join("/output", prefix, name))
require.NoError(t, existsErr)
assert.True(t, exists, "%s should still exist", name)
}

// stale-pkg should be removed.
exists, err := fileutils.Exists(testFS, "/output/stale-pkg")
exists, err := fileutils.Exists(testFS, "/output/s/stale-pkg")
require.NoError(t, err)
assert.False(t, exists, "stale-pkg should be removed")
})
Expand Down
8 changes: 6 additions & 2 deletions internal/app/azldev/core/components/renderedspecdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ package components
import (
"fmt"
"path/filepath"
"strings"

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

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

return filepath.Join(renderedSpecsDir, componentName), nil
prefix := strings.ToLower(componentName[:1])

return filepath.Join(renderedSpecsDir, prefix, componentName), nil
}
12 changes: 9 additions & 3 deletions internal/app/azldev/core/components/renderedspecdir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import (
)

func TestRenderedSpecDir(t *testing.T) {
t.Run("ReturnsPathWhenConfigured", func(t *testing.T) {
t.Run("ReturnsLetterPrefixedPath", func(t *testing.T) {
result, err := components.RenderedSpecDir("/path/to/specs", "vim")
require.NoError(t, err)
assert.Equal(t, "/path/to/specs/vim", result)
assert.Equal(t, "/path/to/specs/v/vim", result)
})

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

t.Run("LowercasesPrefixForUppercaseName", func(t *testing.T) {
result, err := components.RenderedSpecDir("/specs", "SymCrypt")
require.NoError(t, err)
assert.Equal(t, "/specs/s/SymCrypt", result)
})

t.Run("HandlesComponentNameWithDashes", func(t *testing.T) {
result, err := components.RenderedSpecDir("/rendered", "my-component")
require.NoError(t, err)
assert.Equal(t, "/rendered/my-component", result)
assert.Equal(t, "/rendered/m/my-component", result)
})

t.Run("RejectsAbsoluteComponentName", func(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/utils/fileutils/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,12 @@ func ValidateFilename(filename string) error {
return fmt.Errorf("filename %#q must not contain backslashes", filename)
}

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

return nil
}
1 change: 1 addition & 0 deletions internal/utils/fileutils/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func TestValidateFilename(t *testing.T) {
{name: "tab in name", filename: "has\ttab.tar.gz", expectedError: "must not contain whitespace"},
{name: "null byte in name", filename: "has\x00null.tar.gz", expectedError: "must not contain null bytes"},
{name: "backslash in name", filename: "foo\\bar.tar.gz", expectedError: "must not contain backslashes"},
{name: "non-ASCII characters", filename: "foo\x80bar.tar.gz", expectedError: "must contain only ASCII characters"},
}

for _, tc := range tests {
Expand Down
Loading
Loading