From 315e4e3f2f26e0846028cf8278b24a87cf5d8da2 Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Fri, 21 Aug 2026 21:03:33 +0000 Subject: [PATCH 1/2] fix(sources): Convert custom scripts to absolute at runtime --- docs/user/reference/config/components.md | 6 +- .../cmds/component/history_customizations.go | 4 +- .../cmds/component/history_internal_test.go | 20 +++++ internal/app/azldev/cmds/config/dump.go | 47 +++++++++- internal/app/azldev/cmds/config/dump_test.go | 17 ++++ internal/fingerprint/fingerprint.go | 8 ++ internal/fingerprint/fingerprint_test.go | 38 ++++++++ internal/projectconfig/component.go | 22 +++++ internal/projectconfig/component_test.go | 89 +++++++++++++++++++ internal/projectconfig/loader_test.go | 46 ++++++++++ internal/projectconfig/origin_json.go | 29 ++++++ .../sourceproviders/customsourceprovider.go | 38 +++++--- .../customsourceprovider_internal_test.go | 28 ++++++ 13 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 internal/projectconfig/origin_json.go diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 8a25d9ef..a24b5d3f 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -373,11 +373,13 @@ Use `origin.type = "custom"` when a source archive must be assembled or modified Custom sources are regenerated on every source preparation rather than restored from lookaside. The generated archive is validated against its configured hash, so changes to the script or its inputs fail with a hash mismatch until the hash is intentionally refreshed. +For an upstream component, each script filename is resolved relative to the TOML file that declares that `source-files` entry. This remains true when the component is assembled from multiple included configuration files. For a local component, the script remains a sidecar beside the component's spec file. + The `script`, `mock-packages`, and `inputs` fields are nested under `[origin]`: | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Script | `origin.script` | string | **Yes** | Script filename (relative to the component's spec dir) to run in mock. Required for `origin.type = "custom"`. | +| Script | `origin.script` | string | **Yes** | Script filename to run in mock. Relative to the declaring TOML file for upstream components, or the spec directory for local components. Required for `origin.type = "custom"`. | | Mock packages | `origin.mock-packages` | array of string | No | Extra RPM packages to install in the mock chroot before the script runs. | | Inputs | `origin.inputs` | array of string | No | Unique filenames to make available in the mock chroot before the script runs. Each file must already be present in the fetched source output directory — upstream source tarballs, sidecar files (patches, scripts), and any earlier `source-files` entries are all placed there by the upstream fetch before custom scripts run. | @@ -389,7 +391,7 @@ filename = "yara-4.5.4-azl-stripped.tar.gz" hash-type = "SHA512" hash = "abc123..." # from: prep-sources --allow-no-hashes origin.type = "custom" -origin.script = "gen-yara-stripped.sh" # relative to the component's spec directory +origin.script = "gen-yara-stripped.sh" # beside this TOML file for an upstream component origin.mock-packages = ["cmake"] # omit if not needed origin.inputs = ["yara-4.5.4.tar.gz"] # available to the script as ./yara-4.5.4.tar.gz ``` diff --git a/internal/app/azldev/cmds/component/history_customizations.go b/internal/app/azldev/cmds/component/history_customizations.go index dca357b6..2e90e7eb 100644 --- a/internal/app/azldev/cmds/component/history_customizations.go +++ b/internal/app/azldev/cmds/component/history_customizations.go @@ -230,10 +230,10 @@ func appendSourceFileItems( }) } - if sourceFile.Origin.Script != "" { + if scriptName := sourceFile.Origin.EffectiveScriptName(); scriptName != "" { items = append(items, CustomizationItem{ Kind: "source-files.script", - Value: sourceFile.Origin.Script, + Value: scriptName, }) } diff --git a/internal/app/azldev/cmds/component/history_internal_test.go b/internal/app/azldev/cmds/component/history_internal_test.go index 9e6a339e..70e6db54 100644 --- a/internal/app/azldev/cmds/component/history_internal_test.go +++ b/internal/app/azldev/cmds/component/history_internal_test.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestHasExplicitComponentSelection pins the NEW-1 fix: only an exact name or @@ -256,6 +257,25 @@ func TestCollectCustomizationsEmitsEveryKind(t *testing.T) { } } +func TestCollectCustomizationsUsesEffectiveScriptName(t *testing.T) { + t.Parallel() + + config := projectconfig.ComponentConfig{ + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/components/generate.sh", + }, + }}, + } + + items := collectCustomizations("comp", &config) + + require.Len(t, items, 2) + assert.Equal(t, CustomizationItem{Kind: "source-files.script", Value: "generate.sh"}, items[1]) +} + // TestFingerprintChangeDTOMirrorsSource guards the direction the explicit // field-by-field copy in [toFingerprintChanges] cannot: a NEW field added to // [sources.FingerprintChange] / [sources.CommitMetadata] would compile fine diff --git a/internal/app/azldev/cmds/config/dump.go b/internal/app/azldev/cmds/config/dump.go index 0fa58c43..5630006c 100644 --- a/internal/app/azldev/cmds/config/dump.go +++ b/internal/app/azldev/cmds/config/dump.go @@ -6,8 +6,11 @@ package config import ( "encoding/json" "fmt" + "maps" + "slices" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -97,16 +100,18 @@ issues or inspecting effective values.`, } func DumpConfig(env *azldev.Env, format configDumpFormat) (string, error) { + config := portableConfigCopy(env.Config()) + switch format { case ConfigDumpFormatTOML: - tomlBytes, err := toml.Marshal(env.Config()) + tomlBytes, err := toml.Marshal(config) if err != nil { return "", fmt.Errorf("failed to serialize config to TOML:\n%w", err) } return string(tomlBytes), nil case ConfigDumpFormatJSON: - jsonBytes, err := json.MarshalIndent(env.Config(), "", " ") + jsonBytes, err := json.MarshalIndent(config, "", " ") if err != nil { return "", fmt.Errorf("failed to serialize config to JSON:\n%w", err) } @@ -116,3 +121,41 @@ func DumpConfig(env *azldev.Env, format configDumpFormat) (string, error) { return "", fmt.Errorf("unsupported format: %#q", format) } } + +func portableConfigCopy(config *projectconfig.ProjectConfig) *projectconfig.ProjectConfig { + result := *config + + result.Components = maps.Clone(config.Components) + for name, component := range result.Components { + normalizeCustomScriptNames(&component) + result.Components[name] = component + } + + normalizeCustomScriptNames(&result.DefaultComponentConfig) + + result.ComponentGroups = maps.Clone(config.ComponentGroups) + for name, group := range result.ComponentGroups { + normalizeCustomScriptNames(&group.DefaultComponentConfig) + result.ComponentGroups[name] = group + } + + result.Distros = maps.Clone(config.Distros) + for distroName, distro := range result.Distros { + distro.Versions = maps.Clone(distro.Versions) + for versionName, version := range distro.Versions { + normalizeCustomScriptNames(&version.DefaultComponentConfig) + distro.Versions[versionName] = version + } + + result.Distros[distroName] = distro + } + + return &result +} + +func normalizeCustomScriptNames(component *projectconfig.ComponentConfig) { + component.SourceFiles = slices.Clone(component.SourceFiles) + for i := range component.SourceFiles { + component.SourceFiles[i].Origin.Script = component.SourceFiles[i].Origin.EffectiveScriptName() + } +} diff --git a/internal/app/azldev/cmds/config/dump_test.go b/internal/app/azldev/cmds/config/dump_test.go index 43f6f6ad..1bbb0562 100644 --- a/internal/app/azldev/cmds/config/dump_test.go +++ b/internal/app/azldev/cmds/config/dump_test.go @@ -28,6 +28,17 @@ func TestDumpConfig(t *testing.T) { WorkDir: testWorkDir, OutputDir: testOutputDir, }, + Components: map[string]projectconfig.ComponentConfig{ + "example": { + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/components/generate.sh", + }, + }}, + }, + }, } ctx, cancelFunc := context.WithCancel(t.Context()) @@ -47,8 +58,14 @@ func TestDumpConfig(t *testing.T) { configText, err := config.DumpConfig(env, config.ConfigDumpFormatTOML) require.NoError(t, err) require.NotEmpty(t, configText) + require.Contains(t, configText, "generate.sh") + require.NotContains(t, configText, "/project/components") configText, err = config.DumpConfig(env, config.ConfigDumpFormatJSON) require.NoError(t, err) require.NotEmpty(t, configText) + require.Contains(t, configText, "generate.sh") + require.NotContains(t, configText, "/project/components") + require.Equal(t, "/project/components/generate.sh", + cfg.Components["example"].SourceFiles[0].Origin.Script) } diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go index 646e317b..391559d6 100644 --- a/internal/fingerprint/fingerprint.go +++ b/internal/fingerprint/fingerprint.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "io" + "slices" "sort" "strconv" @@ -107,6 +108,13 @@ func ComputeIdentity( } // 3. Hash the resolved config struct (excluding fingerprint:"-" fields). + // Script paths are absolute internally so merged definitions retain their + // declaration context; hash only their checkout-independent filenames. + component.SourceFiles = slices.Clone(component.SourceFiles) + for i := range component.SourceFiles { + component.SourceFiles[i].Origin.Script = component.SourceFiles[i].Origin.EffectiveScriptName() + } + configHash, err := hashstructure.Hash(component, hashstructure.FormatV2, &hashstructure.HashOptions{ TagName: hashstructureTagName, }) diff --git a/internal/fingerprint/fingerprint_test.go b/internal/fingerprint/fingerprint_test.go index 6be16477..9ed921c9 100644 --- a/internal/fingerprint/fingerprint_test.go +++ b/internal/fingerprint/fingerprint_test.go @@ -382,6 +382,44 @@ func TestComputeIdentity_SourceFilesChange(t *testing.T) { assert.NotEqual(t, fp1, fp2, "different source file hash must produce different fingerprints") } +func TestComputeIdentity_CustomScriptPathIsCheckoutIndependent(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp1.SourceFiles = []projectconfig.SourceFileReference{{ + Filename: "source.tar.gz", + Hash: "aaa111", + HashType: fileutils.HashTypeSHA256, + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/home/user1/repo/generate.sh", + }, + }} + + comp2 := comp1 + comp2.SourceFiles = []projectconfig.SourceFileReference{{ + Filename: "source.tar.gz", + Hash: "aaa111", + HashType: fileutils.HashTypeSHA256, + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/home/user2/repo/generate.sh", + }, + }} + + fp1 := computeFingerprint(t, ctx, comp1, testReleaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, testReleaseVer, 0) + + assert.Equal(t, fp1, fp2) + assert.Equal(t, "/home/user1/repo/generate.sh", comp1.SourceFiles[0].Origin.Script) + + comp2.SourceFiles[0].Origin.Script = "/home/user2/repo/different.sh" + fp2 = computeFingerprint(t, ctx, comp2, testReleaseVer, 0) + assert.NotEqual(t, fp1, fp2) +} + func TestComputeIdentity_SourceFileOriginExcluded(t *testing.T) { ctx := newTestFS(t, map[string]string{ "/specs/test.spec": "Name: testpkg\nVersion: 1.0", diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 02c53c9d..229714f2 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -6,6 +6,7 @@ package projectconfig import ( "errors" "fmt" + "path/filepath" "slices" "sort" "strings" @@ -86,6 +87,15 @@ type Origin struct { Inputs []string `toml:"inputs,omitempty" json:"inputs,omitempty" jsonschema:"title=Inputs,description=Source-output filenames to make available next to the generation script before it runs. Only valid when origin type is 'custom'."` } +// EffectiveScriptName returns the checkout-independent filename of [Origin.Script]. +func (o Origin) EffectiveScriptName() string { + if o.Script == "" { + return "" + } + + return filepath.Base(o.Script) +} + // HashInclude implements the hashstructure [Includable] interface so that // [Origin.Script], [Origin.MockPackages], and [Origin.Inputs] are omitted from // the component fingerprint when they hold their zero values. @@ -530,6 +540,18 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi // Fix up paths. result.Spec.Path = makeAbsolute(referenceDir, result.Spec.Path) + scriptDir := referenceDir + if result.Spec.SourceType == SpecSourceTypeLocal && result.Spec.Path != "" { + scriptDir = filepath.Dir(result.Spec.Path) + } + + for i := range result.SourceFiles { + origin := &result.SourceFiles[i].Origin + if origin.Type == OriginTypeCustom { + origin.Script = makeAbsolute(scriptDir, origin.Script) + } + } + // Copy and fix up overlays. if c.Overlays != nil { result.Overlays = make([]ComponentOverlay, len(c.Overlays)) diff --git a/internal/projectconfig/component_test.go b/internal/projectconfig/component_test.go index cfd1f215..49a1389f 100644 --- a/internal/projectconfig/component_test.go +++ b/internal/projectconfig/component_test.go @@ -4,6 +4,7 @@ package projectconfig_test import ( + "encoding/json" "path/filepath" "reflect" "strings" @@ -108,6 +109,94 @@ func TestComponentConfigWithAbsolutePaths(t *testing.T) { require.Equal(t, comp.Overlays[0].Type, absComp.Overlays[0].Type) require.Equal(t, filepath.Join(testRefDir, comp.Overlays[0].Source), absComp.Overlays[0].Source) }) + + t.Run("custom source scripts", func(t *testing.T) { + comp := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeUpstream}, + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "generate.sh", + }, + }, + { + Filename: "downloaded.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeURI, + Script: "unchanged.sh", + }, + }, + }, + } + + absComp := comp.WithAbsolutePaths(testRefDir) + + assert.Equal(t, filepath.Join(testRefDir, "generate.sh"), absComp.SourceFiles[0].Origin.Script) + assert.Equal(t, "unchanged.sh", absComp.SourceFiles[1].Origin.Script) + assert.Equal(t, "generate.sh", comp.SourceFiles[0].Origin.Script) + }) + + t.Run("local custom source script", func(t *testing.T) { + comp := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "specs/test.spec", + }, + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "generate.sh", + }, + }}, + } + + absComp := comp.WithAbsolutePaths(testRefDir) + + assert.Equal(t, filepath.Join(testRefDir, "specs", "generate.sh"), absComp.SourceFiles[0].Origin.Script) + }) +} + +func TestOriginEffectiveScriptName(t *testing.T) { + tests := []struct { + name string + script string + expected string + }{ + {name: "empty", script: "", expected: ""}, + {name: "relative", script: "generate.sh", expected: "generate.sh"}, + {name: "absolute", script: "/project/components/generate.sh", expected: "generate.sh"}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + origin := projectconfig.Origin{Script: testCase.script} + + assert.Equal(t, testCase.expected, origin.EffectiveScriptName()) + }) + } +} + +func TestOriginMarshalJSONUsesEffectiveScriptName(t *testing.T) { + origin := projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/components/generate.sh", + MockPackages: []string{"golang"}, + Inputs: []string{"source.tar.gz"}, + } + + data, err := json.Marshal(origin) + require.NoError(t, err) + + assert.JSONEq(t, `{ + "type": "custom", + "script": "generate.sh", + "mockPackages": ["golang"], + "inputs": ["source.tar.gz"] + }`, string(data)) + assert.NotContains(t, string(data), "/project/components") } func TestComponentGroupConfigWithAbsolutePaths_DefaultComponentConfig(t *testing.T) { diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 8ade9ea3..4cbcf458 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -551,6 +551,52 @@ upstream-commit = "bbb2222" assert.Equal(t, "/project/sub", comp.SourceConfigFile.dir) } +func TestLoadAndResolveProjectConfig_MergeComponentsPreservesCustomScriptDirectories(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["sub/include.toml"] + +[components.example.spec] +type = "upstream" + +[[components.example.source-files]] +filename = "base-generated.tar.gz" +origin.type = "custom" +origin.script = "generate-base.sh" +`}, + {"/project/sub/include.toml", ` +[components.example.spec] +type = "upstream" +upstream-commit = "abc1234" + +[[components.example.source-files]] +filename = "included-generated.tar.gz" +origin.type = "custom" +origin.script = "generate-included.sh" +`}, + } + + ctx := testctx.NewCtx() + + for _, testFile := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) + } + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.NoError(t, err) + + component := config.Components["example"] + require.Len(t, component.SourceFiles, 2) + assert.Equal(t, "/project/generate-base.sh", component.SourceFiles[0].Origin.Script) + assert.Equal(t, "/project/sub/generate-included.sh", component.SourceFiles[1].Origin.Script) + require.NotNil(t, component.SourceConfigFile) + assert.Equal(t, "/project/sub", component.SourceConfigFile.dir) +} + func TestLoadAndResolveProjectConfig_MergeComponentsMultipleComponents(t *testing.T) { // When two files define different components, both should be present. // When they also share a component, that component should be merged. diff --git a/internal/projectconfig/origin_json.go b/internal/projectconfig/origin_json.go new file mode 100644 index 00000000..2d70acc5 --- /dev/null +++ b/internal/projectconfig/origin_json.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "encoding/json" + "fmt" +) + +type originJSON Origin + +var _ json.Marshaler = Origin{} + +// MarshalJSON emits the configured script filename instead of its absolute internal path. +func (o Origin) MarshalJSON() ([]byte, error) { + data, err := json.Marshal(struct { + originJSON + Script string `json:"script,omitempty"` + }{ + originJSON: originJSON(o), + Script: o.EffectiveScriptName(), + }) + if err != nil { + return nil, fmt.Errorf("marshaling source origin:\n%w", err) + } + + return data, nil +} diff --git a/internal/providers/sourceproviders/customsourceprovider.go b/internal/providers/sourceproviders/customsourceprovider.go index f60c7622..f287c927 100644 --- a/internal/providers/sourceproviders/customsourceprovider.go +++ b/internal/providers/sourceproviders/customsourceprovider.go @@ -80,9 +80,11 @@ func generateCustomSourceFile( ref *projectconfig.SourceFileReference, destPath string, ) error { + scriptName := ref.Origin.EffectiveScriptName() + slog.Info("Generating custom source file", "filename", ref.Filename, - "script", ref.Origin.Script, + "script", scriptName, "component", component.GetName()) if dryRunnable.DryRun() { @@ -91,21 +93,19 @@ func generateCustomSourceFile( return nil } - specDir, err := resolveComponentSpecDir(component) + // Verify the generation script is present before spinning up a mock chroot. + scriptHostPath, err := resolveCustomScriptHostPath(component, ref.Origin.Script) if err != nil { - return fmt.Errorf("failed to resolve spec directory for component %#q:\n%w", + return fmt.Errorf("failed to resolve generation script for component %#q:\n%w", component.GetName(), err) } - // Verify the generation script is present before spinning up a mock chroot. - scriptHostPath := filepath.Join(specDir, ref.Origin.Script) - if _, statErr := fs.Stat(scriptHostPath); statErr != nil { return fmt.Errorf("generation script %#q not found at %#q:\n%w", - ref.Origin.Script, scriptHostPath, statErr) + scriptName, scriptHostPath, statErr) } - scriptTmpDir, genOutputTmpDir, cleanup, err := prepareStagingDirs(fs, scriptHostPath, ref.Origin.Script) + scriptTmpDir, genOutputTmpDir, cleanup, err := prepareStagingDirs(fs, scriptHostPath, scriptName) if err != nil { return err } @@ -118,7 +118,7 @@ func generateCustomSourceFile( if len(ref.Origin.Inputs) > 0 { if inputsErr := stageInputFiles( - dryRunnable, fs, ref.Origin.Inputs, destDirPath, scriptTmpDir, ref.Origin.Script, + dryRunnable, fs, ref.Origin.Inputs, destDirPath, scriptTmpDir, scriptName, ); inputsErr != nil { return fmt.Errorf("failed to resolve inputs for custom source %#q:\n%w", ref.Filename, inputsErr) @@ -138,7 +138,7 @@ func generateCustomSourceFile( // download upstream tarballs or toolchain artifacts. runner := buildCustomRunner(baseRunner, scriptTmpDir, genOutputTmpDir) - if err := execScriptInChroot(ctx, runner, verbose, ref); err != nil { + if err := execScriptInChroot(ctx, runner, verbose, ref, scriptName); err != nil { return err } @@ -159,6 +159,19 @@ func generateCustomSourceFile( return nil } +func resolveCustomScriptHostPath(component components.Component, scriptPath string) (string, error) { + if filepath.IsAbs(scriptPath) { + return scriptPath, nil + } + + specDir, err := resolveComponentSpecDir(component) + if err != nil { + return "", err + } + + return filepath.Join(specDir, scriptPath), nil +} + // resolveComponentSpecDir returns the directory on the host filesystem that contains the // component's spec file and its sidecar files (patches, generation scripts, etc.). // @@ -254,6 +267,7 @@ func execScriptInChroot( runner *mock.Runner, verbose bool, ref *projectconfig.SourceFileReference, + scriptName string, ) error { if initErr := runner.InitRoot(ctx); initErr != nil { return fmt.Errorf("failed to initialize mock root for generating %#q:\n%w", @@ -279,7 +293,7 @@ func execScriptInChroot( // Use positional parameters so the script name is never re-parsed as shell code // ($1=scriptDir, $2=scriptName; '--' sets $0 and keeps bash from consuming them). cmd, cmdErr := runner.CmdInChroot(ctx, []string{ - "sh", "-c", `cd "$1" && ./"$2"`, "--", customGenScriptDir, ref.Origin.Script, + "sh", "-c", `cd "$1" && ./"$2"`, "--", customGenScriptDir, scriptName, }, false /* interactive */) if cmdErr != nil { return fmt.Errorf("failed to create chroot command for generating %#q:\n%w", @@ -301,7 +315,7 @@ func execScriptInChroot( scriptOutput := formatCustomScriptOutput(stdout.String(), stderr.String()) return fmt.Errorf("generation script %#q failed for source %#q%s\n%w", - ref.Origin.Script, ref.Filename, scriptOutput, runErr) + scriptName, ref.Filename, scriptOutput, runErr) } return nil diff --git a/internal/providers/sourceproviders/customsourceprovider_internal_test.go b/internal/providers/sourceproviders/customsourceprovider_internal_test.go index 547d66e5..50e5ec08 100644 --- a/internal/providers/sourceproviders/customsourceprovider_internal_test.go +++ b/internal/providers/sourceproviders/customsourceprovider_internal_test.go @@ -116,6 +116,34 @@ func TestCustomFileSourceProvider_GetFile_MissingScriptReturnsError(t *testing.T assert.NotErrorIs(t, err, ErrNotFound) } +func TestCustomFileSourceProvider_GetFile_AbsoluteMissingScriptUsesNormalizedPath(t *testing.T) { + ctx := testctx.NewCtx() + + provider := &customFileSourceProvider{ + dryRunnable: ctx, + fs: ctx.FS(), + runner: nil, // never reached - script stat check fails first + } + + ctrl := gomock.NewController(t) + comp := components_testutils.NewMockComponent(ctrl) + comp.EXPECT().GetName().Return("yara").AnyTimes() + + ref := projectconfig.SourceFileReference{ + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/base/gen.sh", + }, + } + + err := provider.GetFile(context.Background(), comp, ref, "/output") + require.Error(t, err) + assert.Contains(t, err.Error(), "generation script") + assert.Contains(t, err.Error(), "gen.sh") + assert.Contains(t, err.Error(), "/project/base/gen.sh") +} + func TestResolveComponentSpecDir_LocalComponent(t *testing.T) { ctrl := gomock.NewController(t) comp := components_testutils.NewMockComponent(ctrl) From ce4327abe23bbe14733da80c82d5bfab75b728f2 Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Fri, 21 Aug 2026 23:22:09 +0000 Subject: [PATCH 2/2] Resolve pr comments --- internal/projectconfig/component.go | 23 ++++++-- internal/projectconfig/component_test.go | 54 +++++++++++++++++++ internal/projectconfig/loader_test.go | 35 ++++++++++++ ...ainer_config_generate-schema_stdout_1.snap | 2 +- ...shots_config_generate-schema_stdout_1.snap | 2 +- schemas/azldev.schema.json | 2 +- 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 229714f2..9041d53e 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -72,10 +72,11 @@ type Origin struct { // Uri to download the source file from if origin type is 'download'. Ignored for other origin types. Uri string `toml:"uri,omitempty" json:"uri,omitempty" jsonschema:"title=URI,description=URI to download the source file from if origin type is 'download',example=https://example.com/source.tar.gz" fingerprint:"-"` - // Script is the filename of a shell script, relative to the component's spec directory, - // that is run inside a mock chroot to generate this source file. + // Script is the filename of a shell script run inside a mock chroot to generate this source file. + // For upstream components it is relative to the declaring config file; for local components + // it is relative to the component's spec directory. // Required when [Origin.Type] is 'custom'; must be empty otherwise. - Script string `toml:"script,omitempty" json:"script,omitempty" jsonschema:"title=Script,description=Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'."` + Script string `toml:"script,omitempty" json:"script,omitempty" jsonschema:"title=Script,description=Shell script filename to run in mock to generate this source file. Relative to the declaring config file for upstream components or the component spec directory for local components. Required when origin type is 'custom'."` // MockPackages is a list of RPM package names to install in the mock chroot before // running [Origin.Script]. Only valid when [Origin.Type] is 'custom'. @@ -457,9 +458,25 @@ func (c *ComponentConfig) MergeUpdatesFrom(other *ComponentConfig) error { c.OverlayFiles = otherOverlayFiles } + c.resolveLocalCustomScriptPaths() + return nil } +func (c *ComponentConfig) resolveLocalCustomScriptPaths() { + if c.Spec.SourceType != SpecSourceTypeLocal || c.Spec.Path == "" { + return + } + + scriptDir := filepath.Dir(c.Spec.Path) + for i := range c.SourceFiles { + origin := &c.SourceFiles[i].Origin + if origin.Type == OriginTypeCustom && origin.Script != "" { + origin.Script = filepath.Join(scriptDir, origin.EffectiveScriptName()) + } + } +} + // EffectiveUpstreamCommit returns the commit to use for upstream operations. // Prefers the locked commit (resolved reality) over the config pin (user intent). // Falls back to Spec.UpstreamCommit for SkipLockValidation paths (update, list, diff --git a/internal/projectconfig/component_test.go b/internal/projectconfig/component_test.go index 49a1389f..cec939ae 100644 --- a/internal/projectconfig/component_test.go +++ b/internal/projectconfig/component_test.go @@ -272,6 +272,60 @@ func TestMergeComponentUpdates(t *testing.T) { require.Equal(t, []string{"x", "y", "w"}, base.Build.Without) } +func TestMergeComponentUpdates_LocalCustomScriptsUseSpecDirectory(t *testing.T) { + tests := []struct { + name string + base projectconfig.ComponentConfig + updates projectconfig.ComponentConfig + }{ + { + name: "spec before source file", + base: projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/project/specs/example.spec", + }, + }, + updates: projectconfig.ComponentConfig{ + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/overrides/generate.sh", + }, + }}, + }, + }, + { + name: "source file before spec", + base: projectconfig.ComponentConfig{ + SourceFiles: []projectconfig.SourceFileReference{{ + Filename: "generated.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "/project/overrides/generate.sh", + }, + }}, + }, + updates: projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/project/specs/example.spec", + }, + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + err := testCase.base.MergeUpdatesFrom(&testCase.updates) + require.NoError(t, err) + require.Len(t, testCase.base.SourceFiles, 1) + assert.Equal(t, "/project/specs/generate.sh", testCase.base.SourceFiles[0].Origin.Script) + }) + } +} + func TestMergeComponentUpdates_OverlayFilesOverride(t *testing.T) { base := projectconfig.ComponentConfig{ OverlayFiles: []string{"overlays/*.overlay.toml"}, diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 4cbcf458..344206fc 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -597,6 +597,41 @@ origin.script = "generate-included.sh" assert.Equal(t, "/project/sub", component.SourceConfigFile.dir) } +func TestLoadAndResolveProjectConfig_MergeLocalComponentUsesSpecDirectoryForCustomScript(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["sub/include.toml"] + +[components.example.spec] +type = "local" +path = "specs/example.spec" +`}, + {"/project/sub/include.toml", ` +[[components.example.source-files]] +filename = "generated.tar.gz" +origin.type = "custom" +origin.script = "generate.sh" +`}, + } + + ctx := testctx.NewCtx() + + for _, testFile := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) + } + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.NoError(t, err) + + component := config.Components["example"] + require.Len(t, component.SourceFiles, 1) + assert.Equal(t, "/project/specs/generate.sh", component.SourceFiles[0].Origin.Script) +} + func TestLoadAndResolveProjectConfig_MergeComponentsMultipleComponents(t *testing.T) { // When two files define different components, both should be present. // When they also share a component, that component should be merged. diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 5f5542a2..b1262e7b 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -804,7 +804,7 @@ "script": { "type": "string", "title": "Script", - "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + "description": "Shell script filename to run in mock to generate this source file. Relative to the declaring config file for upstream components or the component spec directory for local components. Required when origin type is 'custom'." }, "mock-packages": { "items": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 5f5542a2..b1262e7b 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -804,7 +804,7 @@ "script": { "type": "string", "title": "Script", - "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + "description": "Shell script filename to run in mock to generate this source file. Relative to the declaring config file for upstream components or the component spec directory for local components. Required when origin type is 'custom'." }, "mock-packages": { "items": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 5f5542a2..b1262e7b 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -804,7 +804,7 @@ "script": { "type": "string", "title": "Script", - "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + "description": "Shell script filename to run in mock to generate this source file. Relative to the declaring config file for upstream components or the component spec directory for local components. Required when origin type is 'custom'." }, "mock-packages": { "items": {