diff --git a/internal/app/azldev/cmds/advanced/mock_test.go b/internal/app/azldev/cmds/advanced/mock_test.go index 61a295f1..cb7a6864 100644 --- a/internal/app/azldev/cmds/advanced/mock_test.go +++ b/internal/app/azldev/cmds/advanced/mock_test.go @@ -117,10 +117,18 @@ func TestBuildRPMS(t *testing.T) { Versions: map[string]projectconfig.DistroVersionDefinition{ "1.0": { MockConfigPath: testMockConfigPath, + Inputs: projectconfig.DistroVersionInputs{ + RpmBuild: []string{"test-repo"}, + }, }, }, } + testEnv.Config.Resources.RpmRepos["test-repo"] = projectconfig.RpmRepoResource{ + BaseURI: "https://example.com/test-repo/$basearch", + DisableGPGCheck: true, + } + // Pretend that "mock" exists. testEnv.CmdFactory.RegisterCommandInSearchPath("mock") diff --git a/internal/app/azldev/cmds/component/render.go b/internal/app/azldev/cmds/component/render.go index ccfaccec..e57bc98c 100644 --- a/internal/app/azldev/cmds/component/render.go +++ b/internal/app/azldev/cmds/component/render.go @@ -15,6 +15,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/mockconfig" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" @@ -1007,7 +1008,14 @@ func createMockProcessor(env *azldev.Env) *sources.MockProcessor { return nil } - slog.Info("Mock processor available", "mockConfig", distroVerDef.MockConfigPath) + preparedConfigPath, err := mockconfig.PrepareForRPMBuild(env) + if err != nil { + slog.Warn("Mock processor unavailable; failed to prepare mock config", "error", err) + + return nil + } + + slog.Info("Mock processor available", "mockConfig", preparedConfigPath) - return sources.NewMockProcessor(env, distroVerDef.MockConfigPath) + return sources.NewMockProcessor(env, preparedConfigPath) } diff --git a/internal/app/azldev/cmds/image/build.go b/internal/app/azldev/cmds/image/build.go index f5869245..e1482e66 100644 --- a/internal/app/azldev/cmds/image/build.go +++ b/internal/app/azldev/cmds/image/build.go @@ -256,7 +256,12 @@ func createKiwiRunner( runner.WithTargetArch(string(options.TargetArch)) } - // Build per-repo options for remote repositories. + // Inject TOML-defined image-build repos for the active distro version. + if err := addConfiguredImageBuildRepos(env, runner, options); err != nil { + return nil, err + } + + // Build per-repo options for user-supplied remote repositories (additive). remoteRepoOptions := &kiwi.RepoOptions{ DisableRepoGPGCheck: options.NoRemoteRepoGpgCheck, ImageInclude: options.RemoteRepoIncludeInImage, @@ -275,6 +280,113 @@ func createKiwiRunner( return runner, nil } +// addConfiguredImageBuildRepos resolves the active distro version's +// inputs.image-build list to RPM repo resources and registers each with the kiwi +// runner. When no inputs are configured for image-build the function is a no-op: +// images may still get their repos from the .kiwi description and/or user-supplied +// --repo flags. When inputs *are* configured but every entry is filtered out for +// the target architecture, that is treated as an error (it would otherwise +// silently produce a no-repo image). +func addConfiguredImageBuildRepos( + env *azldev.Env, runner *kiwi.Runner, options *ImageBuildOptions, +) error { + _, distroVerDef, err := env.Distro() + if err != nil { + return fmt.Errorf("failed to resolve distro for image build:\n%w", err) + } + + repoNames := distroVerDef.Inputs.ImageBuild + if len(repoNames) == 0 { + // No TOML-driven image-build repos. The .kiwi description and/or + // user-supplied --repo flags are expected to supply repos. + slog.Info("No TOML-driven image-build inputs configured; relying on .kiwi/--repo for repositories") + + return nil + } + + cfg := env.Config() + if cfg == nil { + return errors.New("no project config loaded; cannot resolve image-build inputs") + } + + repos := cfg.Resources.RpmRepos + + targetArch := string(options.TargetArch) + + addedCount := 0 + skippedForArch := []string{} + + for _, name := range repoNames { + repo, ok := repos[name] + if !ok { + // Should have been caught by load-time validation, but defend defensively. + return fmt.Errorf("inputs.image-build references undefined rpm-repo %#q", name) + } + + if targetArch != "" && !repo.IsAvailableForArch(targetArch) { + slog.Warn("Skipping rpm-repo for image-build (arch mismatch)", + "repo", name, "targetArch", targetArch, "repoArches", repo.Arches) + + skippedForArch = append(skippedForArch, name) + + continue + } + + if err := addKiwiRepoFromResource(runner, name, &repo); err != nil { + return fmt.Errorf("failed to add image-build rpm-repo %#q:\n%w", name, err) + } + + addedCount++ + } + + if addedCount == 0 { + return fmt.Errorf( + "all %d image-build rpm-repos were filtered out for target arch %q (skipped: %v); "+ + "check the `arches` settings on these repos", + len(repoNames), targetArch, skippedForArch, + ) + } + + return nil +} + +// addKiwiRepoFromResource registers a single [projectconfig.RpmRepoResource] with the +// kiwi runner via [kiwi.Runner.AddRemoteRepo], applying the repo's GPG-check / signing +// key and source-type semantics. +// +// `disable-gpg-check` is mapped to *both* kiwi GPG knobs (package and repo metadata). +// dnf treats `gpgcheck=0` as covering package signature verification, and we want the +// kiwi semantics to mirror that: a single TOML field means "don't enforce signatures +// at all for this repo". If we ever need finer control we can add a second field. +func addKiwiRepoFromResource(runner *kiwi.Runner, name string, repo *projectconfig.RpmRepoResource) error { + opts := &kiwi.RepoOptions{ + Alias: name, + DisablePackageGPGCheck: repo.DisableGPGCheck, + DisableRepoGPGCheck: repo.DisableGPGCheck, + } + + if repo.GPGKey != "" { + opts.SigningKeys = []string{repo.GPGKey} + } + + source := repo.BaseURI + if source == "" && repo.Metalink != "" { + // kiwi v10.x supports metalink via repo_sourcetype. + source = repo.Metalink + opts.SourceType = kiwi.RepoSourceTypeMetalink + } + + if source == "" { + return fmt.Errorf("rpm-repo %#q has neither base-uri nor metalink", name) + } + + if err := runner.AddRemoteRepo(source, opts); err != nil { + return fmt.Errorf("invalid repo source for %#q:\n%w", name, err) + } + + return nil +} + // linkImageArtifacts hard-links the final image artifacts from the work directory to the // output directory. Uses symlinks to avoid duplicating large image files. // It parses kiwi's result JSON to determine which files are artifacts. diff --git a/internal/app/azldev/cmds/image/build_repos_internal_test.go b/internal/app/azldev/cmds/image/build_repos_internal_test.go new file mode 100644 index 00000000..f42d54ed --- /dev/null +++ b/internal/app/azldev/cmds/image/build_repos_internal_test.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package image + +import ( + "context" + "os/exec" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/kiwi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const addRepoFlag = "--add-repo" + +// captureKiwiAddRepoArgs builds a kiwi.Runner against the given setup function and +// returns the list of `--add-repo ` values that the kiwi command would receive. +func captureKiwiAddRepoArgs(t *testing.T, setup func(*kiwi.Runner) error) []string { + t.Helper() + + ctx := testctx.NewCtx() + + var captured []string + + ctx.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + captured = cmd.Args + + return nil + } + + runner := kiwi.NewRunner(ctx, "/description").WithTargetDir("/output") + + require.NoError(t, setup(runner)) + require.NoError(t, runner.Build(context.Background())) + + var args []string + + for i, a := range captured { + if a == addRepoFlag && i+1 < len(captured) { + args = append(args, captured[i+1]) + } + } + + return args +} + +func TestAddKiwiRepoFromResource_BaseURI(t *testing.T) { + t.Parallel() + + args := captureKiwiAddRepoArgs(t, func(r *kiwi.Runner) error { + return addKiwiRepoFromResource(r, "test-repo", &projectconfig.RpmRepoResource{ + BaseURI: "https://example.com/repo/$basearch", + DisableGPGCheck: true, + }) + }) + + require.Len(t, args, 1) + parts := strings.Split(args[0], ",") + + // Positional fields per kiwi: source,rpm-md,alias,priority,imageinclude, + // package_gpgcheck,signing_keys,components,distribution,repo_gpgcheck,repo_sourcetype. + assert.Equal(t, "https://example.com/repo/$basearch", parts[0]) + assert.Equal(t, "rpm-md", parts[1]) + assert.Equal(t, "test-repo", parts[2], "alias must be the repo name (used as-is for kiwi)") + // Priority 50 = remote default. + assert.Equal(t, "50", parts[3]) + + // disable-gpg-check=true must turn OFF *both* package_gpgcheck (field 6, index 5) + // and repo_gpgcheck (field 10, index 9) — they correspond to fields[1] and fields[5] + // after the first 4 positional fields. + assert.Equal(t, "false", parts[5], "package_gpgcheck must be false when disable-gpg-check=true (field 6 / index 5)") + require.GreaterOrEqual(t, len(parts), 10, "expected at least 10 fields when disable-gpg-check=true: %v", parts) + assert.Equal(t, "false", parts[9], "repo_gpgcheck must be false when disable-gpg-check=true (field 10 / index 9)") +} + +func TestAddKiwiRepoFromResource_GPGEnabled_BothChecksOn(t *testing.T) { + t.Parallel() + + args := captureKiwiAddRepoArgs(t, func(r *kiwi.Runner) error { + return addKiwiRepoFromResource(r, "signed", &projectconfig.RpmRepoResource{ + BaseURI: "https://example.com/repo", + GPGKey: "https://example.com/key.gpg", + }) + }) + + require.Len(t, args, 1) + // The "false" sentinel for either gpgcheck field must NOT appear when GPG is enabled. + // (kiwi defaults both to true; we only emit "false" overrides.) + assert.NotContains(t, args[0], "false", + "no GPG-disable override should appear when DisableGPGCheck=false (got %q)", args[0]) + // Signing key must be wrapped in {} braces. + assert.Contains(t, args[0], "{https://example.com/key.gpg}", + "signing key must be projected as `{key}` in field 7 (got %q)", args[0]) +} + +func TestAddKiwiRepoFromResource_Metalink(t *testing.T) { + t.Parallel() + + args := captureKiwiAddRepoArgs(t, func(r *kiwi.Runner) error { + return addKiwiRepoFromResource(r, "ml", &projectconfig.RpmRepoResource{ + Metalink: "https://mirrors.example.com/metalink?repo=foo", + DisableGPGCheck: true, + }) + }) + + require.Len(t, args, 1) + assert.True(t, strings.HasPrefix(args[0], "https://mirrors.example.com/metalink?repo=foo,rpm-md,ml,"), + "metalink must be used as the source (got %q)", args[0]) + assert.True(t, strings.HasSuffix(args[0], ",metalink"), + "sourcetype trailing field must be `metalink` (got %q)", args[0]) +} + +func TestAddKiwiRepoFromResource_NoSourceErrors(t *testing.T) { + t.Parallel() + + ctx := testctx.NewCtx() + runner := kiwi.NewRunner(ctx, "/description") + err := addKiwiRepoFromResource(runner, "broken", &projectconfig.RpmRepoResource{ + DisableGPGCheck: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "neither base-uri nor metalink") +} diff --git a/internal/app/azldev/core/buildenvfactory/factory.go b/internal/app/azldev/core/buildenvfactory/factory.go index e96f738c..c9b8e559 100644 --- a/internal/app/azldev/core/buildenvfactory/factory.go +++ b/internal/app/azldev/core/buildenvfactory/factory.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/mockconfig" "github.com/microsoft/azure-linux-dev-tools/internal/buildenv" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" ) @@ -59,8 +60,16 @@ func NewMockRootFactoryForEnv(env *azldev.Env) (*buildenv.MockRootFactory, error return nil, fmt.Errorf("failed to access configured mock config file '%s':\n%w", mockConfigPath, statErr) } + // Stage a per-build configdir that injects the TOML-derived RPM repos via a + // generated site-defaults.cfg. This replaces hardcoded `[base]`/`[sdk]`/etc. + // blocks in the chroot template with a Jinja loop driven by `azl_repos`. + preparedConfigPath, err := mockconfig.PrepareForRPMBuild(env) + if err != nil { + return nil, fmt.Errorf("failed to prepare mock config:\n%w", err) + } + // Get set up with mock. - factory, err := buildenv.NewMockRootFactory(env, mockConfigPath) + factory, err := buildenv.NewMockRootFactory(env, preparedConfigPath) if err != nil { return nil, fmt.Errorf("failed to create mock root factory:\n%w", err) } diff --git a/internal/app/azldev/core/mockconfig/prepare.go b/internal/app/azldev/core/mockconfig/prepare.go new file mode 100644 index 00000000..6f2b732e --- /dev/null +++ b/internal/app/azldev/core/mockconfig/prepare.go @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package mockconfig prepares a per-build mock config directory that injects +// TOML-derived RPM repository definitions into mock without modifying any +// checked-in mock config files. +// +// The approach takes advantage of mock's built-in extensibility: +// +// 1. mock loads `site-defaults.cfg` from `--configdir` *before* the chroot +// `.cfg`, then enables Jinja expansion only after all configs have loaded +// (see mockbuild/config.py:load_config). We use this to seed +// `config_opts['azl_repos']` with a list-of-dicts derived from TOML. +// 2. The chroot `.cfg`'s `include('foo.tpl')` calls are resolved relative to +// `config_opts["config_path"]` (i.e., `--configdir`), so symlinking the +// `.cfg`/`.tpl` files into a temp configdir lets includes keep working +// unchanged (see mockbuild/config.py:include). +// 3. The `.tpl` then iterates `azl_repos` via Jinja inside +// `config_opts['dnf.conf']`, rendering one `[name]` block per repo. +// +// This avoids both passing dicts via `--config-opts` (which only supports +// strings/bools/ints) and rewriting the `.tpl` at runtime. +package mockconfig + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "path/filepath" + "runtime" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" +) + +// PrepareForRPMBuild prepares a per-build mock config directory wired up with +// the repos configured for the active distro version's `inputs.rpm-build`. +// +// The returned path lives inside a freshly allocated temp directory under +// [azldev.Env.WorkDir]; that directory contains symlinks back to the original +// `.cfg`/`.tpl` files plus a generated `site-defaults.cfg` that sets +// `config_opts['azl_repos'] = [...]`. +// +// The site-defaults file is **always** generated, even when `inputs.rpm-build` +// is empty (in which case it sets `azl_repos = []` and a warning is logged). +// This keeps the chroot template's `{% for r in azl_repos %}` loop happy +// regardless of whether a particular distro version has opted into TOML-driven +// repo injection. Templates that don't reference `azl_repos` (e.g., the +// hand-maintained stage1 mock) are unaffected. +// +// Pass the returned path to [mock.NewRunner] (which derives `--configdir` from +// `filepath.Dir(returnedPath)`). +// +// Returns an error if any referenced repo name does not resolve to a defined +// resource in [projectconfig.ResourcesConfig], or if the staging itself fails. +func PrepareForRPMBuild(env *azldev.Env) (string, error) { + _, distroVerDef, err := env.Distro() + if err != nil { + return "", fmt.Errorf("failed to resolve distro for mock config preparation:\n%w", err) + } + + srcCfgPath := distroVerDef.MockConfigPath + if srcCfgPath == "" { + return "", errors.New("no mock config file configured for active distro version") + } + + cfg := env.Config() + if cfg == nil { + return "", errors.New("no project config loaded") + } + + repoNames := distroVerDef.Inputs.RpmBuild + + repos, err := resolveRepos(cfg.Resources.RpmRepos, repoNames) + if err != nil { + return "", err + } + + // Mock chroots only ever target the host architecture (mock-config-x86_64 vs + // mock-config-aarch64 is selected by runtime.GOARCH at config-load time). Apply + // the same arch filter here so a repo restricted to a different arch is silently + // omitted instead of forcing an unusable URL into the chroot. + mockArch := goArchToRPMArch(runtime.GOARCH) + + filtered := repos[:0:len(repos)] + + for _, repo := range repos { + if !repo.Repo.IsAvailableForArch(mockArch) { + slog.Warn("Skipping rpm-repo for rpm-build (arch mismatch)", + "repo", repo.Name, "mockArch", mockArch, "repoArches", repo.Repo.Arches) + + continue + } + + filtered = append(filtered, repo) + } + + repos = filtered + + if len(repoNames) == 0 { + // Stage1-style configs: hand-maintained mock template owns its own repos. + // We still stage the configdir + emit `azl_repos = []` so the template can + // rely on the variable existing if it ever decides to iterate it. + slog.Info( + "No TOML-driven rpm-build inputs configured; generated `azl_repos` will be empty " + + "(this is expected for hand-maintained mock templates).", + ) + } else if len(repos) == 0 { + slog.Warn("All TOML-driven rpm-build inputs were filtered out for the host arch", + "hostArch", mockArch) + } + + return stageMockConfigDir(env, srcCfgPath, repos) +} + +// goArchToRPMArch maps Go's GOARCH values to the rpm/mock arch naming convention used +// in [RpmRepoResource.Arches]. Unknown values are returned unchanged so tests/dev +// environments on unusual platforms still get some signal in log messages. +func goArchToRPMArch(goArch string) string { + switch goArch { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + default: + return goArch + } +} + +// namedRepo pairs an [projectconfig.RpmRepoResource] with the TOML map key it +// was registered under. The key is the canonical repo identifier used both for +// the dnf section name and for log messages. +type namedRepo struct { + Name string + Repo projectconfig.RpmRepoResource +} + +// resolveRepos looks up each name in `names` in the resources map. +// Returns the resolved repos in the order given. +func resolveRepos( + defs map[string]projectconfig.RpmRepoResource, + names []string, +) ([]namedRepo, error) { + resolved := make([]namedRepo, 0, len(names)) + + for _, name := range names { + repo, ok := defs[name] + if !ok { + return nil, fmt.Errorf( + "rpm repo %q referenced from inputs is not defined under [resources.rpm-repos]", + name, + ) + } + + resolved = append(resolved, namedRepo{Name: name, Repo: repo}) + } + + return resolved, nil +} + +// stageMockConfigDir allocates a temp configdir under env.WorkDir(), symlinks +// the source .cfg + .tpl files into it, generates site-defaults.cfg, and +// returns the path to the staged .cfg file. +func stageMockConfigDir( + env *azldev.Env, + srcCfgPath string, + repos []namedRepo, +) (string, error) { + envFS := env.FS() + + tempDir, err := fileutils.MkdirTemp(envFS, env.WorkDir(), "azldev-mock-config-") + if err != nil { + return "", fmt.Errorf("failed to create temp mock config dir:\n%w", err) + } + + srcDir := filepath.Dir(srcCfgPath) + + entries, err := fileutils.ReadDir(envFS, srcDir) + if err != nil { + return "", fmt.Errorf("failed to read source mock config dir %q:\n%w", srcDir, err) + } + + // Stage the chroot .cfg itself plus any .tpl files it might include(). + // We deliberately don't copy arbitrary files that may live in the same + // directory (the mock config dir might be co-located with project files + // in some layouts). + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if name == siteDefaultsFileName { + continue + } + + ext := filepath.Ext(name) + if ext != ".cfg" && ext != ".tpl" { + continue + } + + src := filepath.Join(srcDir, name) + dst := filepath.Join(tempDir, name) + + if symErr := fileutils.SymLinkOrCopy(env, envFS, src, dst, fileutils.CopyFileOptions{}); symErr != nil { + return "", fmt.Errorf("failed to stage mock config file %q:\n%w", name, symErr) + } + } + + siteDefaultsPath := filepath.Join(tempDir, siteDefaultsFileName) + + siteDefaultsContent := []byte(renderSiteDefaults(repos)) + if writeErr := fileutils.WriteFile(envFS, siteDefaultsPath, siteDefaultsContent, siteDefaultsPerm); writeErr != nil { + return "", fmt.Errorf("failed to write generated site-defaults.cfg:\n%w", writeErr) + } + + stagedCfgPath := filepath.Join(tempDir, filepath.Base(srcCfgPath)) + + return stagedCfgPath, nil +} + +const ( + siteDefaultsFileName = "site-defaults.cfg" + // siteDefaultsPerm is the file mode used for the generated site-defaults.cfg. + // 0644 = owner read/write, group/other read; matches the mock config files + // shipped by the mock package itself. + siteDefaultsPerm = 0o644 +) + +// renderSiteDefaults produces a Python-syntax mock config fragment that sets +// `config_opts['azl_repos']` to a list of dicts. It is consumed by a Jinja +// `{% for r in azl_repos %}` loop inside the `.tpl`'s `dnf.conf` body. +// +// The dict keys map directly to dnf .repo file directives (`baseurl`, +// `metalink`, `gpgkey`, `gpgcheck`). The repo's [namedRepo.Name] (= TOML map +// key) drives the dnf section header and is also used as `name=` since dnf's +// name field is just a UI label. +// +// We deliberately do NOT project the repo's `description` into dnf — it'd +// require single-line validation and offers no functional value (dnf treats +// `name=` as opaque). `description` stays as a TOML-only diagnostic field. +func renderSiteDefaults(repos []namedRepo) string { + var buf strings.Builder + + buf.WriteString("# GENERATED BY azldev — do not edit\n") + buf.WriteString("# Sets config_opts['azl_repos'] from the active project's TOML configuration.\n") + buf.WriteString("# The chroot template is expected to iterate this list to build dnf.conf.\n\n") + buf.WriteString("config_opts['azl_repos'] = [\n") + + for _, repo := range repos { + buf.WriteString(" {") + writeStrField(&buf, "name", repo.Name) + writeStrField(&buf, "baseurl", repo.Repo.BaseURI) + writeStrField(&buf, "metalink", repo.Repo.Metalink) + writeStrField(&buf, "gpgkey", repo.Repo.GPGKey) + // Note: TOML field is `disable-gpg-check`; dnf wants `gpgcheck=1/0`. + // Invert here so the dnf side stays normal-shaped. + writeBoolField(&buf, "gpgcheck", !repo.Repo.DisableGPGCheck) + buf.WriteString("},\n") + } + + buf.WriteString("]\n") + + return buf.String() +} + +func writeStrField(buf *strings.Builder, key, value string) { + if value == "" { + return + } + + fmt.Fprintf(buf, " %s: %s,", pyRepr(key), pyRepr(value)) +} + +func writeBoolField(buf *strings.Builder, key string, value bool) { + if value { + fmt.Fprintf(buf, " %s: True,", pyRepr(key)) + } else { + fmt.Fprintf(buf, " %s: False,", pyRepr(key)) + } +} + +// pyRepr formats a string as a Python string literal safe for embedding inside a mock +// .cfg file (which Python loads via exec()). We delegate to encoding/json: every JSON +// string literal is also a valid Python string literal (both accept double-quoted +// strings with the same set of \escape sequences). Crucially this handles edge cases +// the previous handwritten escaper missed: NUL bytes, U+2028/U+2029 line separators, +// and arbitrary control characters. +func pyRepr(s string) string { + encoded, err := json.Marshal(s) + if err != nil { + // json.Marshal of a string never fails in practice; log and fall back to a + // double-quoted empty string to keep the generated file syntactically valid. + slog.Error("json.Marshal failed for python string literal (treating as empty)", "error", err) + + return `""` + } + + // json.Marshal already %u-escapes U+2028/U+2029 in modern Go, but be explicit + // just in case its behavior changes. + out := string(encoded) + out = strings.ReplaceAll(out, "\u2028", `\u2028`) + out = strings.ReplaceAll(out, "\u2029", `\u2029`) + + return out +} diff --git a/internal/app/azldev/core/mockconfig/prepare_internal_test.go b/internal/app/azldev/core/mockconfig/prepare_internal_test.go new file mode 100644 index 00000000..8ca8af6b --- /dev/null +++ b/internal/app/azldev/core/mockconfig/prepare_internal_test.go @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package mockconfig + +import ( + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPyRepr(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {name: "plain", in: "hello", want: `"hello"`}, + {name: "double-quote", in: `it"s`, want: `"it\"s"`}, + {name: "single-quote-untouched", in: "it's", want: `"it's"`}, + {name: "backslash", in: `a\b`, want: `"a\\b"`}, + {name: "newline", in: "a\nb", want: `"a\nb"`}, + {name: "tab", in: "a\tb", want: `"a\tb"`}, + {name: "nul-byte", in: "a\x00b", want: `"a\u0000b"`}, + {name: "u2028-line-sep", in: "a\u2028b", want: `"a\u2028b"`}, + {name: "u2029-para-sep", in: "a\u2029b", want: `"a\u2029b"`}, + {name: "dollar-sign-untouched", in: "a/$basearch", want: `"a/$basearch"`}, + {name: "empty", in: "", want: `""`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, pyRepr(tc.in)) + }) + } +} + +func TestRenderSiteDefaults_Empty(t *testing.T) { + t.Parallel() + + got := renderSiteDefaults(nil) + + assert.Contains(t, got, "config_opts['azl_repos'] = [\n]\n") +} + +func TestRenderSiteDefaults_BaseURIRepo(t *testing.T) { + t.Parallel() + + repos := []namedRepo{{ + Name: "test-base", + Repo: projectconfig.RpmRepoResource{ + Description: "ignored", + BaseURI: "https://example.com/$basearch", + DisableGPGCheck: true, + }, + }} + + got := renderSiteDefaults(repos) + + assert.Contains(t, got, `"name": "test-base"`) + assert.Contains(t, got, `"baseurl": "https://example.com/$basearch"`) + assert.Contains(t, got, `"gpgcheck": False`) + // Description must NOT be projected into dnf. + assert.NotContains(t, got, "ignored") + // Removed fields must not appear. + assert.NotContains(t, got, "priority") + assert.NotContains(t, got, "description") +} + +func TestRenderSiteDefaults_GPGCheckEnabledByDefault(t *testing.T) { + t.Parallel() + + repos := []namedRepo{{ + Name: "signed", + Repo: projectconfig.RpmRepoResource{ + BaseURI: "https://example.com/repo", + GPGKey: "file:///etc/keys/example.gpg", + }, + }} + + got := renderSiteDefaults(repos) + + // DisableGPGCheck is the zero value -> dnf gpgcheck must be True. + assert.Contains(t, got, `"gpgcheck": True`) + assert.Contains(t, got, `"gpgkey": "file:///etc/keys/example.gpg"`) +} + +func TestRenderSiteDefaults_MetalinkRepo(t *testing.T) { + t.Parallel() + + repos := []namedRepo{{ + Name: "ml", + Repo: projectconfig.RpmRepoResource{ + Metalink: "https://mirrors.example.com/metalink?repo=foo", + DisableGPGCheck: true, + }, + }} + + got := renderSiteDefaults(repos) + + assert.Contains(t, got, `"metalink": "https://mirrors.example.com/metalink?repo=foo"`) + assert.NotContains(t, got, "baseurl") +} + +func TestPrepareForRPMBuild_StagesAndGenerates(t *testing.T) { + t.Parallel() + + testEnv := testutils.NewTestEnv(t) + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, "/work")) + + cfgPath, err := PrepareForRPMBuild(testEnv.Env) + require.NoError(t, err) + + // Returned cfg path lives under WorkDir, not at the source location. + assert.True(t, strings.HasPrefix(cfgPath, "/work/"), "expected staged cfg under work dir, got %q", cfgPath) + assert.True(t, strings.HasSuffix(cfgPath, "/mock.cfg")) + + // site-defaults.cfg must be present and contain azl_repos. + siteDefaultsPath := strings.TrimSuffix(cfgPath, "mock.cfg") + "site-defaults.cfg" + siteDefaults, readErr := fileutils.ReadFile(testEnv.TestFS, siteDefaultsPath) + require.NoError(t, readErr) + assert.Contains(t, string(siteDefaults), "config_opts['azl_repos']") + assert.Contains(t, string(siteDefaults), "test-repo") + assert.Contains(t, string(siteDefaults), "https://example.com/test-repo/$basearch") +} + +func TestPrepareForRPMBuild_NoInputs_StagesEmptyAndWarns(t *testing.T) { + t.Parallel() + + testEnv := testutils.NewTestEnv(t) + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, "/work")) + + // Drop the configured inputs. + dv := testEnv.Config.Distros["test-distro"].Versions["1.0"] + dv.Inputs.RpmBuild = nil + testEnv.Config.Distros["test-distro"].Versions["1.0"] = dv + + cfgPath, err := PrepareForRPMBuild(testEnv.Env) + require.NoError(t, err) + + // Even with no inputs, we still stage and generate an (empty) site-defaults.cfg. + assert.True(t, strings.HasPrefix(cfgPath, "/work/")) + + siteDefaultsPath := strings.TrimSuffix(cfgPath, "mock.cfg") + "site-defaults.cfg" + siteDefaults, readErr := fileutils.ReadFile(testEnv.TestFS, siteDefaultsPath) + require.NoError(t, readErr) + assert.Contains(t, string(siteDefaults), "config_opts['azl_repos'] = [\n]") +} + +func TestPrepareForRPMBuild_UndefinedRepoErrors(t *testing.T) { + t.Parallel() + + testEnv := testutils.NewTestEnv(t) + + dv := testEnv.Config.Distros["test-distro"].Versions["1.0"] + dv.Inputs.RpmBuild = []string{"does-not-exist"} + testEnv.Config.Distros["test-distro"].Versions["1.0"] = dv + + _, err := PrepareForRPMBuild(testEnv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "does-not-exist") + assert.Contains(t, err.Error(), "[resources.rpm-repos]") +} diff --git a/internal/app/azldev/core/testutils/testenv.go b/internal/app/azldev/core/testutils/testenv.go index 38df2959..0357b9fe 100644 --- a/internal/app/azldev/core/testutils/testenv.go +++ b/internal/app/azldev/core/testutils/testenv.go @@ -172,10 +172,19 @@ func constructProjectConfig(testMockConfigPath string) *projectconfig.ProjectCon MockConfigPath: testMockConfigPath, DistGitBranch: "main", ReleaseVer: "3.0", + Inputs: projectconfig.DistroVersionInputs{ + RpmBuild: []string{"test-repo"}, + }, } config.Distros["test-distro"] = distro + config.Resources.RpmRepos["test-repo"] = projectconfig.RpmRepoResource{ + Description: "Test repository for the in-memory test environment", + BaseURI: "https://example.com/test-repo/$basearch", + DisableGPGCheck: true, + } + return &config } diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 6eecd068..e19ad5a3 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -35,6 +35,10 @@ type ConfigFile struct { // Definitions of distros. Distros map[string]DistroDefinition `toml:"distros,omitempty" jsonschema:"title=Distros,description=Definitions of distros to build for or consume from"` + // Reusable resource definitions (e.g., RPM repositories) referenced from + // elsewhere in the configuration. + Resources *ResourcesConfig `toml:"resources,omitempty" jsonschema:"title=Resources,description=Reusable named resource definitions"` + // Definitions of component groups. ComponentGroups map[string]ComponentGroupConfig `toml:"component-groups,omitempty" validate:"dive" jsonschema:"title=Component groups,description=Definitions of component groups for this project"` diff --git a/internal/projectconfig/distro.go b/internal/projectconfig/distro.go index cd0aa85a..61bda4e4 100644 --- a/internal/projectconfig/distro.go +++ b/internal/projectconfig/distro.go @@ -85,6 +85,30 @@ type DistroVersionDefinition struct { MockConfigPath string `toml:"mock-config,omitempty" json:"mockConfig,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the mock config file for this version"` MockConfigPathX86_64 string `toml:"mock-config-x86_64,omitempty" json:"mockConfigX8664,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the x86_64 mock config file for this version"` MockConfigPathAarch64 string `toml:"mock-config-aarch64,omitempty" json:"mockConfigAarch64,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the aarch64 mock config file for this version"` + + // Inputs maps build use-cases ("rpm-build", "image-build") to ordered lists of + // [RpmRepoResource] names that should be made available to that use-case. Names + // must resolve to entries under [ResourcesConfig.RpmRepos]; load-time validation + // enforces this. List order is preserved when projecting into mock/kiwi config but + // is **not** treated as a priority hint — neither dnf nor kiwi guarantees ordering + // semantics across remote repos. Define explicit priorities/costs at the consumer + // level if/when needed. + Inputs DistroVersionInputs `toml:"inputs,omitempty" json:"inputs,omitempty" jsonschema:"title=Inputs,description=Per-use-case input repositories"` +} + +// DistroVersionInputs maps build use-cases to ordered lists of resource references. +// Each list is a sequence of names that must resolve to top-level +// [ResourcesConfig.RpmRepos] entries. +type DistroVersionInputs struct { + // RpmBuild is the ordered list of RPM repos made available when building RPMs + // (the mock/comp build path). Order is preserved on emission but not interpreted + // as priority by dnf. + RpmBuild []string `toml:"rpm-build,omitempty" json:"rpmBuild,omitempty" jsonschema:"title=RPM-build inputs,description=Repos made available to mock when building RPMs"` + + // ImageBuild is the ordered list of RPM repos made available when building images + // (the kiwi/image build path). Order is preserved on emission but not interpreted + // as priority by kiwi. + ImageBuild []string `toml:"image-build,omitempty" json:"imageBuild,omitempty" jsonschema:"title=Image-build inputs,description=Repos made available to kiwi when building images"` } // MergeUpdatesFrom mutates the distro definition, updating it with overrides present in other. diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index f38c50ef..d6acf2d3 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -136,6 +136,28 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeResources(resolvedCfg, loadedCfg); err != nil { + return err + } + + return nil +} + +// mergeResources merges the [ResourcesConfig] from a loaded config file into the resolved +// config. Map-keyed entries (e.g., rpm-repos by name) are overlaid: a duplicate name in +// a later-loaded file replaces the earlier definition (matching the policy applied to +// other top-level resource collections). +func mergeResources(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + if loadedCfg.Resources == nil { + return nil + } + + resolved := loadedCfg.Resources.WithAbsolutePaths(loadedCfg.dir) + + if err := resolvedCfg.Resources.MergeUpdatesFrom(resolved); err != nil { + return fmt.Errorf("failed to merge resources from %#q:\n%w", loadedCfg.SourcePath(), err) + } + return nil } diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index 85c37ff6..f1ecda41 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -23,6 +23,9 @@ type ProjectConfig struct { Images map[string]ImageConfig `toml:"images,omitempty" json:"images,omitempty" jsonschema:"title=Images,description=Mapping of image names to configurations"` // Definitions of distros. Distros map[string]DistroDefinition `toml:"distros,omitempty" json:"distros,omitempty" jsonschema:"title=Distros,description=Mapping of distro names to their definitions"` + // Reusable resource definitions (e.g., RPM repositories) referenced from + // elsewhere in the configuration. + Resources ResourcesConfig `toml:"resources,omitempty" json:"resources,omitempty" jsonschema:"title=Resources,description=Reusable named resource definitions"` // Configuration for tools used by azldev. Tools ToolsConfig `toml:"tools,omitempty" json:"tools,omitempty" jsonschema:"title=Tools configuration,description=Configuration for tools used by azldev"` @@ -56,6 +59,7 @@ func NewProjectConfig() ProjectConfig { Components: make(map[string]ComponentConfig), Images: make(map[string]ImageConfig), Distros: make(map[string]DistroDefinition), + Resources: ResourcesConfig{RpmRepos: make(map[string]RpmRepoResource)}, GroupsByComponent: make(map[string][]string), PackageGroups: make(map[string]PackageGroupConfig), TestSuites: make(map[string]TestSuiteConfig), @@ -77,6 +81,72 @@ func (cfg *ProjectConfig) Validate() error { return err } + if err := validateRpmRepos(cfg.Resources.RpmRepos); err != nil { + return err + } + + if err := validateDistroVersionInputs(cfg.Distros, cfg.Resources.RpmRepos); err != nil { + return err + } + + return nil +} + +// validateRpmRepos checks each RPM repo definition for structural validity. +func validateRpmRepos(repos map[string]RpmRepoResource) error { + for name, repo := range repos { + if err := validateRpmRepo(name, &repo); err != nil { + return err + } + } + + return nil +} + +// validateDistroVersionInputs verifies that every repo name referenced from a distro +// version's [DistroVersionDefinition.Inputs] resolves to an entry under +// [ResourcesConfig.RpmRepos]. Failing fast at load time produces a clear error rather +// than a confusing failure deep inside a build. +// +// It also rejects repos referenced by `inputs.rpm-build` whose `gpg-key` is a local +// filesystem path: mock evaluates the URI *inside* the chroot, where the host path is +// not visible. (Image builds are unaffected because kiwi runs on the host.) +func validateDistroVersionInputs( + distros map[string]DistroDefinition, repos map[string]RpmRepoResource, +) error { + for distroName, distro := range distros { + for versionName, version := range distro.Versions { + for _, name := range version.Inputs.RpmBuild { + repo, ok := repos[name] + if !ok { + return fmt.Errorf( + "distro %q version %q inputs.rpm-build references undefined rpm-repo %q", + distroName, versionName, name, + ) + } + + if repo.IsLocalGPGKey() { + return fmt.Errorf( + "distro %q version %q inputs.rpm-build references rpm-repo %q which has a local "+ + "`gpg-key` (%q); local keys are not yet supported for mock builds (mock would "+ + "evaluate the path inside the chroot) — use an http(s) URI, or only reference "+ + "this repo from inputs.image-build", + distroName, versionName, name, repo.GPGKey, + ) + } + } + + for _, name := range version.Inputs.ImageBuild { + if _, ok := repos[name]; !ok { + return fmt.Errorf( + "distro %q version %q inputs.image-build references undefined rpm-repo %q", + distroName, versionName, name, + ) + } + } + } + } + return nil } diff --git a/internal/projectconfig/resources.go b/internal/projectconfig/resources.go new file mode 100644 index 00000000..28e92f55 --- /dev/null +++ b/internal/projectconfig/resources.go @@ -0,0 +1,534 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "errors" + "fmt" + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/brunoga/deep" + "github.com/invopop/jsonschema" +) + +// RpmRepoType is the type discriminator for an [RpmRepoResource]. New types may be added +// in the future (e.g., "rpm-dir" for unindexed directories of RPMs, or "oci" for delta-RPM +// repos hosted in container registries). For now, only "rpm-md" is supported. +type RpmRepoType string + +const ( + // RpmRepoTypeRpmMd designates a standard rpm-md (dnf) repository accessed via a + // `base-uri` or `metalink`. This is the default when [RpmRepoResource.Type] is unset. + RpmRepoTypeRpmMd RpmRepoType = "rpm-md" +) + +// IsValid reports whether the given RpmRepoType is a value the loader currently understands. +func (t RpmRepoType) IsValid() bool { + switch t { + case "", RpmRepoTypeRpmMd: + return true + default: + return false + } +} + +// ResourcesConfig is a top-level container for reusable, named resource definitions +// referenced from elsewhere in the configuration (e.g., from a distro version's +// [DistroVersionDefinition.Inputs]). +// +// The container is intentionally namespaced (e.g., resources.rpm-repos rather than +// rpm-repos) so that future sibling resource types (container registries, signing +// keys, etc.) can live under the same top-level key without crowding the schema. +// +// JSONSchemaExtend uses a value receiver because the invopop/jsonschema library +// only invokes value-receiver methods when reflecting on the type; the rest of +// the methods use pointer receivers because they mutate. +// +//nolint:recvcheck // intentional mixed receivers (see comment above). +type ResourcesConfig struct { + // RpmRepos is the set of reusable RPM repository definitions, keyed by name. + RpmRepos map[string]RpmRepoResource `toml:"rpm-repos,omitempty" json:"rpmRepos,omitempty" jsonschema:"title=RPM repositories,description=Reusable named RPM repository definitions"` +} + +// IsEmpty reports whether the ResourcesConfig contains no entries. +func (r *ResourcesConfig) IsEmpty() bool { + return r == nil || len(r.RpmRepos) == 0 +} + +// JSONSchemaExtend tightens the generated schema for the rpm-repos map so editors +// can flag invalid repo names at edit time. The runtime validator +// ([validateRpmRepoName]) is the source of truth; this keeps the schema in sync. +func (ResourcesConfig) JSONSchemaExtend(schema *jsonschema.Schema) { + if schema.Properties == nil { + return + } + + repos, ok := schema.Properties.Get("rpm-repos") + if !ok || repos == nil { + return + } + + repos.PropertyNames = &jsonschema.Schema{ + Type: "string", + Pattern: rpmRepoNameRE.String(), + Description: "Repo name; projected verbatim into dnf section headers and kiwi --add-repo arguments.", + } +} + +// MergeUpdatesFrom mutates r, updating it with overrides present in other. Maps are +// merged by key with **wholesale replacement** at the entry level: a duplicate name +// in `other` fully replaces the existing entry, including any fields that happen to +// be the Go zero value in the new entry. This avoids subtle bugs where, e.g., a +// later config file intentionally setting `disable-gpg-check = false` (the zero +// value) would otherwise fail to override an earlier `true`. +func (r *ResourcesConfig) MergeUpdatesFrom(other *ResourcesConfig) error { + if other == nil { + return nil + } + + if len(other.RpmRepos) > 0 && r.RpmRepos == nil { + r.RpmRepos = make(map[string]RpmRepoResource, len(other.RpmRepos)) + } + + for name, repo := range other.RpmRepos { + r.RpmRepos[name] = repo + } + + return nil +} + +// RpmRepoResource describes a single reusable RPM repository. Per-distro-version +// configuration ([DistroVersionDefinition.Inputs]) selects which repos are made +// available to which build use-cases (rpm-build, image-build). +// +// dnf-side variables ($basearch, $releasever) in `base-uri`/`metalink`/`gpg-key` +// fields are passed through verbatim and expanded by the consuming tool (mock/dnf +// or kiwi). +// +// **GPG checking defaults to enabled** (the safe default). Set +// `disable-gpg-check = true` to opt out. A repo with GPG checking enabled and no +// `gpg-key` is invalid; load-time validation rejects it. +// +// JSONSchemaExtend uses a value receiver because the invopop/jsonschema library +// only invokes value-receiver methods when reflecting on the type; the rest of +// the methods use pointer receivers because they read shared state. +// +//nolint:recvcheck // intentional mixed receivers (see comment above). +type RpmRepoResource struct { + // Description is a human-readable description of the repository. Used only + // for `azldev config dump` and similar diagnostics; not projected into dnf + // or kiwi configuration (avoids newline/encoding pitfalls). + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable description (diagnostic only)"` + + // Type discriminates the repository's access protocol. Defaults to "rpm-md" when unset. + Type RpmRepoType `toml:"type,omitempty" json:"type,omitempty" jsonschema:"title=Type,description=Repository access protocol; defaults to rpm-md,enum=rpm-md"` + + // BaseURI is the repository base URI (dnf's `baseurl`). Mutually exclusive with + // [RpmRepoResource.Metalink]. Either BaseURI or Metalink must be set for type "rpm-md". + BaseURI string `toml:"base-uri,omitempty" json:"baseUri,omitempty" jsonschema:"format=uri,pattern=^https?://[^\\s]+$,title=Base URI,description=Repository base URI (dnf baseurl). Mutually exclusive with metalink. Must be an http(s) URL."` + + // Metalink is the repository metalink URL. Mutually exclusive with [RpmRepoResource.BaseURI]. + Metalink string `toml:"metalink,omitempty" json:"metalink,omitempty" jsonschema:"format=uri,pattern=^https?://[^\\s]+$,title=Metalink,description=Repository metalink URL. Mutually exclusive with base-uri. Must be an http(s) URL."` + + // DisableGPGCheck disables GPG signature verification for this repo. The zero + // value (false) means GPG checking is *enabled* — the safe default. Setting + // this to true is an explicit opt-out and load-time validation requires either + // `disable-gpg-check = true` or a non-empty `gpg-key`. + DisableGPGCheck bool `toml:"disable-gpg-check,omitempty" json:"disableGpgCheck,omitempty" jsonschema:"title=Disable GPG check,description=Opt out of GPG signature verification for this repo (zero value = checking enabled)"` + + // GPGKey is a path or URI to the GPG key used to verify signatures. + // + // Accepted forms: + // * `https://...` or `http://...` URI: passed through verbatim to both consumers. + // * `file:///absolute/path`: passed through verbatim. Note that mock evaluates this + // *inside* the chroot, while kiwi evaluates it on the host. Prefer http(s):// + // for portability across consumers. + // * Bare path: resolved at TOML-load time relative to the directory containing the + // defining TOML file, then emitted to consumers as a `file://` URI. + GPGKey string `toml:"gpg-key,omitempty" json:"gpgKey,omitempty" jsonschema:"pattern=^\\S+$,title=GPG key,description=Path or URI to the GPG key file. Accepted URI schemes: http\\, https\\, file. Bare paths are resolved relative to the defining TOML file."` + + // Arches optionally restricts the repository to a specific list of target architectures + // (e.g., ["x86_64"]). When empty, the repository is available for all architectures. + Arches []string `toml:"arches,omitempty" json:"arches,omitempty" jsonschema:"title=Arches,description=Restrict to specific target architectures; empty = all"` +} + +// EffectiveType returns the repository type, applying the rpm-md default when [RpmRepoResource.Type] +// is unset. +func (r *RpmRepoResource) EffectiveType() RpmRepoType { + if r.Type == "" { + return RpmRepoTypeRpmMd + } + + return r.Type +} + +// JSONSchemaExtend tightens the generated schema for an [RpmRepoResource] so that +// editors can flag invalid TOML at edit time. Mirrors the runtime constraints in +// [validateRpmRepo]: +// +// - exactly one of `base-uri` / `metalink` must be set; +// - `gpg-key` must be a bare path or an `http://` / `https://` / `file://` URI. +// +// The runtime validator is the source of truth; this keeps the schema in sync. +func (RpmRepoResource) JSONSchemaExtend(schema *jsonschema.Schema) { + // Exactly one of base-uri/metalink. Encoded as oneOf with `required` on each. + schema.OneOf = []*jsonschema.Schema{ + {Required: []string{"base-uri"}, Not: &jsonschema.Schema{Required: []string{"metalink"}}}, + {Required: []string{"metalink"}, Not: &jsonschema.Schema{Required: []string{"base-uri"}}}, + } + + // gpg-key: bare path OR http(s)/file URI. The struct tag pattern is just + // "no whitespace"; this tightens it to one of the supported shapes. + if schema.Properties != nil { + if gpg, ok := schema.Properties.Get("gpg-key"); ok && gpg != nil { + gpg.Pattern = `^((https?|file)://\S+|[^\s:]\S*)$` + } + } +} + +// IsAvailableForArch reports whether the repository is available for the given target +// architecture. A repository with no [RpmRepoResource.Arches] restriction is available +// for all architectures. +func (r *RpmRepoResource) IsAvailableForArch(arch string) bool { + if len(r.Arches) == 0 { + return true + } + + for _, a := range r.Arches { + if a == arch { + return true + } + } + + return false +} + +// validateRpmRepo checks the structural validity of an [RpmRepoResource] in isolation. +// Cross-resource and cross-section validation (e.g., that names referenced from +// [DistroVersionDefinition.Inputs] resolve, or that bare gpg-key paths are not used +// for rpm-build inputs) is performed elsewhere. +func validateRpmRepo(name string, repo *RpmRepoResource) error { + if err := validateRpmRepoName(name); err != nil { + return err + } + + if !repo.EffectiveType().IsValid() { + return fmt.Errorf("rpm-repo %#q has unsupported type %#q", name, repo.Type) + } + + if err := validateNoUnsafeChars("description", name, repo.Description); err != nil { + return err + } + + if err := validateRpmRepoSource(name, repo); err != nil { + return err + } + + return validateRpmRepoGPG(name, repo) +} + +// validateRpmRepoSource validates the source-defining fields of a repo (`base-uri` +// and `metalink`) for the active [RpmRepoResource.EffectiveType]. +func validateRpmRepoSource(name string, repo *RpmRepoResource) error { + if repo.EffectiveType() == RpmRepoTypeRpmMd { + if repo.BaseURI == "" && repo.Metalink == "" { + return fmt.Errorf("rpm-repo %#q must specify exactly one of `base-uri` or `metalink`", name) + } + + if repo.BaseURI != "" && repo.Metalink != "" { + return fmt.Errorf("rpm-repo %#q must not specify both `base-uri` and `metalink`", name) + } + } + + if repo.BaseURI != "" { + if err := validateRemoteURI("base-uri", name, repo.BaseURI); err != nil { + return err + } + } + + if repo.Metalink != "" { + if err := validateRemoteURI("metalink", name, repo.Metalink); err != nil { + return err + } + } + + return nil +} + +// validateRpmRepoGPG validates the GPG-related fields (`disable-gpg-check`, `gpg-key`). +func validateRpmRepoGPG(name string, repo *RpmRepoResource) error { + if !repo.DisableGPGCheck && repo.GPGKey == "" { + return fmt.Errorf( + "rpm-repo %#q has GPG checking enabled (the default) but no `gpg-key`; "+ + "either set `gpg-key = \"...\"` or opt out with `disable-gpg-check = true`", + name, + ) + } + + if repo.GPGKey != "" { + if err := validateGPGKey(name, repo.GPGKey); err != nil { + return err + } + } + + return nil +} + +// rpmRepoNameRE is the canonical grammar for repo IDs. Conservative: must start with an +// alphanumeric, then a mix of [A-Za-z0-9_.:-]. This is a strict subset of what dnf/kiwi +// accept, but keeps generated config files (dnf section headers, kiwi comma-delimited +// arguments) unambiguous and free of escaping concerns. +var rpmRepoNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`) + +// URI scheme constants used by the validators. +const ( + schemeHTTP = "http" + schemeHTTPS = "https" + schemeFile = "file" +) + +func validateRpmRepoName(name string) error { + if name == "" { + return errors.New("rpm-repo name must not be empty") + } + + if !rpmRepoNameRE.MatchString(name) { + return fmt.Errorf( + "rpm-repo name %#q is invalid; must match %s "+ + "(repo names are projected verbatim into dnf section headers and kiwi --add-repo arguments)", + name, rpmRepoNameRE.String(), + ) + } + + return nil +} + +// validateNoUnsafeChars rejects values containing characters that, if projected into +// generated dnf/kiwi/python config, would corrupt the surrounding syntax. This is a +// defense-in-depth check: even for trusted TOML sources, unsanitized newlines or NUL +// bytes here produce baffling downstream failures. +func validateNoUnsafeChars(field, repoName, value string) error { + for byteIndex, char := range value { + switch char { + case '\r', '\n': + return fmt.Errorf( + "rpm-repo %#q `%s` must be a single line (no embedded CR/LF at byte %d)", + repoName, field, byteIndex) + case 0: + return fmt.Errorf( + "rpm-repo %#q `%s` must not contain NUL bytes (at byte %d)", + repoName, field, byteIndex) + case '\u2028', '\u2029': + return fmt.Errorf( + "rpm-repo %#q `%s` must not contain Unicode line separators (at byte %d)", + repoName, field, byteIndex) + } + } + + return nil +} + +// validateRemoteURI ensures a base-uri/metalink value is a syntactically valid URI with +// an http or https scheme. Local schemes (file://) are deliberately disallowed for the +// repo source: kiwi.AddRemoteRepo only handles remote URIs, and supporting file:// here +// would require split-by-consumer staging that we don't do today. +// +// Also rejects opaque (non-hierarchical) URIs like `https:example.com` (no `//`) and +// requires a non-empty `Host`, so the value is always something downstream tools will +// recognize as `http(s)://...`. +func validateRemoteURI(field, repoName, raw string) error { + if err := validateNoUnsafeChars(field, repoName, raw); err != nil { + return err + } + + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("rpm-repo %#q `%s` is not a valid URI: %w", repoName, field, err) + } + + switch strings.ToLower(parsed.Scheme) { + case "": + return fmt.Errorf("rpm-repo %#q `%s` is missing a scheme (expected an http or https URL)", repoName, field) + case schemeHTTP, schemeHTTPS: + // fall through to hierarchical-form checks below. + default: + return fmt.Errorf( + "rpm-repo %#q `%s` uses unsupported scheme %q; only http and https are accepted", + repoName, field, parsed.Scheme, + ) + } + + if parsed.Opaque != "" { + return fmt.Errorf( + "rpm-repo %#q `%s` must use the `scheme://host/path` form (got opaque URI %q)", + repoName, field, raw, + ) + } + + if parsed.Host == "" { + return fmt.Errorf("rpm-repo %#q `%s` must include a host (got %q)", repoName, field, raw) + } + + return nil +} + +// validateGPGKey checks the in-isolation form of a `gpg-key` value. A bare path is +// allowed at this stage (resolved to an absolute file:// URI by [WithAbsolutePaths]); +// rejection of bare paths for rpm-build consumers happens in +// [validateDistroVersionInputs], not here. +// +// For URI-shaped values, the supported schemes are `http`, `https`, and `file`. We +// require hierarchical (`scheme://...`) forms — opaque URIs like `file:relative` or +// `https:example.com` are rejected since they wouldn't behave the way callers expect. +// `http(s)` requires a non-empty `Host`; `file` requires an empty `Host` and an absolute +// `Path` (so it really is a `file:///abs/path` reference rather than e.g. +// `file://server/share`). +func validateGPGKey(repoName, raw string) error { + if err := validateNoUnsafeChars("gpg-key", repoName, raw); err != nil { + return err + } + + if !hasURIScheme(raw) { + // Bare path; will be resolved relative to the defining TOML directory. + return nil + } + + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("rpm-repo %#q `gpg-key` is not a valid URI: %w", repoName, err) + } + + scheme := strings.ToLower(parsed.Scheme) + switch scheme { + case schemeHTTP, schemeHTTPS, schemeFile: + // fall through to per-scheme shape checks. + default: + return fmt.Errorf( + "rpm-repo %#q `gpg-key` uses unsupported scheme %q; expected http, https, or file", + repoName, parsed.Scheme, + ) + } + + if parsed.Opaque != "" { + return fmt.Errorf( + "rpm-repo %#q `gpg-key` must use the `scheme://...` form (got opaque URI %q)", + repoName, raw, + ) + } + + switch scheme { + case schemeHTTP, schemeHTTPS: + if parsed.Host == "" { + return fmt.Errorf("rpm-repo %#q `gpg-key` must include a host (got %q)", repoName, raw) + } + case schemeFile: + if parsed.Host != "" { + return fmt.Errorf( + "rpm-repo %#q `gpg-key` must be of the form `file:///absolute/path` (got host %q in %q)", + repoName, parsed.Host, raw, + ) + } + + if !filepath.IsAbs(parsed.Path) { + return fmt.Errorf( + "rpm-repo %#q `gpg-key` must be an absolute `file://` path (got %q)", + repoName, raw, + ) + } + } + + return nil +} + +// IsLocalGPGKey reports whether the repo's gpg-key (if any) refers to a local filesystem +// path (either bare or via a `file://` URI). This is the form that does NOT work for +// rpm-build (mock) consumers because mock evaluates the URI inside the chroot. +func (r *RpmRepoResource) IsLocalGPGKey() bool { + if r.GPGKey == "" { + return false + } + + if !hasURIScheme(r.GPGKey) { + return true + } + + parsed, err := url.Parse(r.GPGKey) + if err != nil { + return false + } + + return strings.EqualFold(parsed.Scheme, schemeFile) +} + +// WithAbsolutePaths returns a copy of the ResourcesConfig with relative path-shaped +// fields (currently only `gpg-key`) resolved relative to referenceDir and re-emitted +// as absolute `file://` URIs. URI-shaped fields are returned unchanged. +// +// This runs at TOML load time, so the consumers (mock / kiwi) only ever see absolute +// references — no working-directory ambiguity at run time. +func (r *ResourcesConfig) WithAbsolutePaths(referenceDir string) *ResourcesConfig { + if r == nil { + return nil + } + + result := deep.MustCopy(r) + + for name, repo := range result.RpmRepos { + if repo.GPGKey != "" { + repo.GPGKey = absolutizeKeyPath(repo.GPGKey, referenceDir) + result.RpmRepos[name] = repo + } + } + + return result +} + +// absolutizeKeyPath resolves a TOML-supplied gpg-key value to a portable form. +// URI-shaped values (with a scheme) are returned unchanged. Bare paths are joined +// against referenceDir (when not already absolute) and emitted as `file://` URIs. +func absolutizeKeyPath(key, referenceDir string) string { + if hasURIScheme(key) { + return key + } + + abs := key + if !filepath.IsAbs(abs) { + abs = filepath.Join(referenceDir, abs) + } + + abs = filepath.Clean(abs) + + return (&url.URL{Scheme: schemeFile, Path: abs}).String() +} + +// hasURIScheme reports whether s starts with "[+-.]*:". +// Cheap and dependency-free; we don't need full RFC 3986 here. +func hasURIScheme(s string) bool { + for i, char := range s { + if i == 0 { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') { + return false + } + + continue + } + + switch { + case char >= 'a' && char <= 'z', + char >= 'A' && char <= 'Z', + char >= '0' && char <= '9', + char == '+', char == '-', char == '.': + continue + case char == ':': + return true + default: + return false + } + } + + return false +} diff --git a/internal/projectconfig/resources_internal_test.go b/internal/projectconfig/resources_internal_test.go new file mode 100644 index 00000000..6b31b623 --- /dev/null +++ b/internal/projectconfig/resources_internal_test.go @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateRpmRepo(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + repo RpmRepoResource + wantErr string // substring; empty = expect no error + }{ + { + name: "ok base-uri with disable-gpg-check", + repo: RpmRepoResource{BaseURI: "https://x/", DisableGPGCheck: true}, + wantErr: "", + }, + { + name: "ok base-uri with gpg-key", + repo: RpmRepoResource{BaseURI: "https://x/", GPGKey: "https://x/key"}, + wantErr: "", + }, + { + name: "ok metalink", + repo: RpmRepoResource{Metalink: "https://x/ml", DisableGPGCheck: true}, + wantErr: "", + }, + { + name: "missing source", + repo: RpmRepoResource{DisableGPGCheck: true}, + wantErr: "exactly one of `base-uri` or `metalink`", + }, + { + name: "both base-uri and metalink", + repo: RpmRepoResource{BaseURI: "https://x/", Metalink: "https://x/ml", DisableGPGCheck: true}, + wantErr: "must not specify both", + }, + { + name: "gpg-check enabled with no key", + repo: RpmRepoResource{BaseURI: "https://x/"}, + wantErr: "GPG checking enabled", + }, + { + name: "newline in description", + repo: RpmRepoResource{BaseURI: "https://x/", DisableGPGCheck: true, Description: "a\nb"}, + wantErr: "single line", + }, + { + name: "unsupported type", + repo: RpmRepoResource{Type: "weird", BaseURI: "https://x/", DisableGPGCheck: true}, + wantErr: "unsupported type", + }, + { + name: "opaque base-uri rejected", + repo: RpmRepoResource{BaseURI: "https:example.com", DisableGPGCheck: true}, + wantErr: "opaque URI", + }, + { + name: "base-uri with empty host rejected", + repo: RpmRepoResource{BaseURI: "https:///path", DisableGPGCheck: true}, + wantErr: "must include a host", + }, + { + name: "opaque metalink rejected", + repo: RpmRepoResource{Metalink: "https:example.com/ml", DisableGPGCheck: true}, + wantErr: "opaque URI", + }, + { + name: "opaque gpg-key https rejected", + repo: RpmRepoResource{BaseURI: "https://x/", GPGKey: "https:example.com/key"}, + wantErr: "opaque URI", + }, + { + name: "opaque gpg-key file rejected", + repo: RpmRepoResource{BaseURI: "https://x/", GPGKey: "file:relative.gpg"}, + wantErr: "opaque URI", + }, + { + name: "file gpg-key with host rejected", + repo: RpmRepoResource{BaseURI: "https://x/", GPGKey: "file://server/share/key.gpg"}, + wantErr: "file:///absolute/path", + }, + { + name: "https gpg-key with no host rejected", + repo: RpmRepoResource{BaseURI: "https://x/", GPGKey: "https:///path/key.gpg"}, + wantErr: "must include a host", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := validateRpmRepo("test", &testCase.repo) + if testCase.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErr) + } + }) + } +} + +func TestWithAbsolutePaths_GPGKey(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {name: "https passthrough", in: "https://example.com/key", want: "https://example.com/key"}, + {name: "file uri passthrough", in: "file:///etc/pki/key", want: "file:///etc/pki/key"}, + {name: "bare relative", in: "keys/local.gpg", want: "file:///ref/keys/local.gpg"}, + {name: "bare absolute", in: "/etc/pki/key", want: "file:///etc/pki/key"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := &ResourcesConfig{ + RpmRepos: map[string]RpmRepoResource{ + "r": {BaseURI: "https://x/", GPGKey: testCase.in}, + }, + } + got := cfg.WithAbsolutePaths("/ref") + assert.Equal(t, testCase.want, got.RpmRepos["r"].GPGKey) + // Original must be untouched (deep copy semantics). + assert.Equal(t, testCase.in, cfg.RpmRepos["r"].GPGKey) + }) + } +} + +func TestMergeUpdatesFrom_WholesaleReplace(t *testing.T) { + t.Parallel() + + earlier := &ResourcesConfig{ + RpmRepos: map[string]RpmRepoResource{ + "r": {BaseURI: "https://old/", DisableGPGCheck: true, Description: "old"}, + }, + } + later := &ResourcesConfig{ + RpmRepos: map[string]RpmRepoResource{ + // disable-gpg-check is the zero value; must still take effect. + "r": {BaseURI: "https://new/", GPGKey: "https://new/key"}, + "r2": {BaseURI: "https://r2/", DisableGPGCheck: true}, + }, + } + + require.NoError(t, earlier.MergeUpdatesFrom(later)) + + got := earlier.RpmRepos["r"] + assert.Equal(t, "https://new/", got.BaseURI) + assert.False(t, got.DisableGPGCheck, "zero value must override true") + assert.Empty(t, got.Description, "wholesale replace must drop old description") + assert.Equal(t, "https://new/key", got.GPGKey) + // New entry preserved. + assert.Equal(t, "https://r2/", earlier.RpmRepos["r2"].BaseURI) +} + +func TestMergeUpdatesFrom_NilOther(t *testing.T) { + t.Parallel() + + cfg := &ResourcesConfig{} + require.NoError(t, cfg.MergeUpdatesFrom(nil)) + assert.Empty(t, cfg.RpmRepos) +} + +func TestRpmRepoResource_IsAvailableForArch(t *testing.T) { + t.Parallel() + + repo := RpmRepoResource{} + assert.True(t, repo.IsAvailableForArch("x86_64")) + assert.True(t, repo.IsAvailableForArch("aarch64")) + + repo.Arches = []string{"x86_64"} + assert.True(t, repo.IsAvailableForArch("x86_64")) + assert.False(t, repo.IsAvailableForArch("aarch64")) +} + +func TestHasURIScheme(t *testing.T) { + t.Parallel() + + cases := map[string]bool{ + "https://x": true, + "file:///x": true, + "foo+bar.baz:x": true, + "/abs/path": false, + "rel/path": false, + "": false, + ":nope": false, + "1abc:nope": false, // must start with alpha + } + + for in, want := range cases { + got := hasURIScheme(in) + assert.Equalf(t, want, got, "hasURIScheme(%q)", in) + } +} + +// Sanity-check that the rendered TOML field tags match documented schema names +// (these are what users type in TOML files). +func TestRpmRepoResource_TOMLFieldNames(t *testing.T) { + t.Parallel() + + typ := reflect.TypeOf(RpmRepoResource{}) + + cases := map[string]string{ + "BaseURI": "base-uri,omitempty", + "DisableGPGCheck": "disable-gpg-check,omitempty", + "GPGKey": "gpg-key,omitempty", + "Metalink": "metalink,omitempty", + } + + for fieldName, wantTag := range cases { + f, ok := typ.FieldByName(fieldName) + require.True(t, ok, "missing field %s", fieldName) + assert.Equal(t, wantTag, f.Tag.Get("toml"), "field %s", fieldName) + } +} diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index b6106561..e3d00e92 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -344,6 +344,11 @@ "title": "Distros", "description": "Definitions of distros to build for or consume from" }, + "resources": { + "$ref": "#/$defs/ResourcesConfig", + "title": "Resources", + "description": "Reusable named resource definitions" + }, "component-groups": { "additionalProperties": { "$ref": "#/$defs/ComponentGroupConfig" @@ -513,6 +518,11 @@ "type": "string", "title": "Mock config file", "description": "Path to the aarch64 mock config file for this version" + }, + "inputs": { + "$ref": "#/$defs/DistroVersionInputs", + "title": "Inputs", + "description": "Per-use-case input repositories" } }, "additionalProperties": false, @@ -521,6 +531,28 @@ "release-ver" ] }, + "DistroVersionInputs": { + "properties": { + "rpm-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "RPM-build inputs", + "description": "Repos made available to mock when building RPMs" + }, + "image-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image-build inputs", + "description": "Repos made available to kiwi when building images" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageCapabilities": { "properties": { "machine-bootable": { @@ -844,6 +876,77 @@ "additionalProperties": false, "type": "object" }, + "ResourcesConfig": { + "properties": { + "rpm-repos": { + "additionalProperties": { + "$ref": "#/$defs/RpmRepoResource" + }, + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + "description": "Repo name; projected verbatim into dnf section headers and kiwi --add-repo arguments." + }, + "type": "object", + "title": "RPM repositories", + "description": "Reusable named RPM repository definitions" + } + }, + "additionalProperties": false, + "type": "object" + }, + "RpmRepoResource": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description (diagnostic only)" + }, + "type": { + "type": "string", + "enum": [ + "rpm-md" + ], + "title": "Type", + "description": "Repository access protocol; defaults to rpm-md" + }, + "base-uri": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Base URI", + "description": "Repository base URI (dnf baseurl). Mutually exclusive with metalink. Must be an http(s) URL." + }, + "metalink": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Metalink", + "description": "Repository metalink URL. Mutually exclusive with base-uri. Must be an http(s) URL." + }, + "disable-gpg-check": { + "type": "boolean", + "title": "Disable GPG check", + "description": "Opt out of GPG signature verification for this repo (zero value = checking enabled)" + }, + "gpg-key": { + "type": "string", + "pattern": "^\\S+$", + "title": "GPG key", + "description": "Path or URI to the GPG key file. Bare paths are resolved relative to the defining TOML file. Accepted URI schemes: http" + }, + "arches": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Arches", + "description": "Restrict to specific target architectures; empty = all" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index b6106561..e3d00e92 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -344,6 +344,11 @@ "title": "Distros", "description": "Definitions of distros to build for or consume from" }, + "resources": { + "$ref": "#/$defs/ResourcesConfig", + "title": "Resources", + "description": "Reusable named resource definitions" + }, "component-groups": { "additionalProperties": { "$ref": "#/$defs/ComponentGroupConfig" @@ -513,6 +518,11 @@ "type": "string", "title": "Mock config file", "description": "Path to the aarch64 mock config file for this version" + }, + "inputs": { + "$ref": "#/$defs/DistroVersionInputs", + "title": "Inputs", + "description": "Per-use-case input repositories" } }, "additionalProperties": false, @@ -521,6 +531,28 @@ "release-ver" ] }, + "DistroVersionInputs": { + "properties": { + "rpm-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "RPM-build inputs", + "description": "Repos made available to mock when building RPMs" + }, + "image-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image-build inputs", + "description": "Repos made available to kiwi when building images" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageCapabilities": { "properties": { "machine-bootable": { @@ -844,6 +876,77 @@ "additionalProperties": false, "type": "object" }, + "ResourcesConfig": { + "properties": { + "rpm-repos": { + "additionalProperties": { + "$ref": "#/$defs/RpmRepoResource" + }, + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + "description": "Repo name; projected verbatim into dnf section headers and kiwi --add-repo arguments." + }, + "type": "object", + "title": "RPM repositories", + "description": "Reusable named RPM repository definitions" + } + }, + "additionalProperties": false, + "type": "object" + }, + "RpmRepoResource": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description (diagnostic only)" + }, + "type": { + "type": "string", + "enum": [ + "rpm-md" + ], + "title": "Type", + "description": "Repository access protocol; defaults to rpm-md" + }, + "base-uri": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Base URI", + "description": "Repository base URI (dnf baseurl). Mutually exclusive with metalink. Must be an http(s) URL." + }, + "metalink": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Metalink", + "description": "Repository metalink URL. Mutually exclusive with base-uri. Must be an http(s) URL." + }, + "disable-gpg-check": { + "type": "boolean", + "title": "Disable GPG check", + "description": "Opt out of GPG signature verification for this repo (zero value = checking enabled)" + }, + "gpg-key": { + "type": "string", + "pattern": "^\\S+$", + "title": "GPG key", + "description": "Path or URI to the GPG key file. Bare paths are resolved relative to the defining TOML file. Accepted URI schemes: http" + }, + "arches": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Arches", + "description": "Restrict to specific target architectures; empty = all" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index b6106561..ed551376 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -344,6 +344,11 @@ "title": "Distros", "description": "Definitions of distros to build for or consume from" }, + "resources": { + "$ref": "#/$defs/ResourcesConfig", + "title": "Resources", + "description": "Reusable named resource definitions" + }, "component-groups": { "additionalProperties": { "$ref": "#/$defs/ComponentGroupConfig" @@ -513,6 +518,11 @@ "type": "string", "title": "Mock config file", "description": "Path to the aarch64 mock config file for this version" + }, + "inputs": { + "$ref": "#/$defs/DistroVersionInputs", + "title": "Inputs", + "description": "Per-use-case input repositories" } }, "additionalProperties": false, @@ -521,6 +531,28 @@ "release-ver" ] }, + "DistroVersionInputs": { + "properties": { + "rpm-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "RPM-build inputs", + "description": "Repos made available to mock when building RPMs" + }, + "image-build": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Image-build inputs", + "description": "Repos made available to kiwi when building images" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageCapabilities": { "properties": { "machine-bootable": { @@ -844,6 +876,99 @@ "additionalProperties": false, "type": "object" }, + "ResourcesConfig": { + "properties": { + "rpm-repos": { + "additionalProperties": { + "$ref": "#/$defs/RpmRepoResource" + }, + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]*$", + "description": "Repo name; projected verbatim into dnf section headers and kiwi --add-repo arguments." + }, + "type": "object", + "title": "RPM repositories", + "description": "Reusable named RPM repository definitions" + } + }, + "additionalProperties": false, + "type": "object" + }, + "RpmRepoResource": { + "oneOf": [ + { + "not": { + "required": [ + "metalink" + ] + }, + "required": [ + "base-uri" + ] + }, + { + "not": { + "required": [ + "base-uri" + ] + }, + "required": [ + "metalink" + ] + } + ], + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description (diagnostic only)" + }, + "type": { + "type": "string", + "enum": [ + "rpm-md" + ], + "title": "Type", + "description": "Repository access protocol; defaults to rpm-md" + }, + "base-uri": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Base URI", + "description": "Repository base URI (dnf baseurl). Mutually exclusive with metalink. Must be an http(s) URL." + }, + "metalink": { + "type": "string", + "pattern": "^https?://[^\\s]+$", + "format": "uri", + "title": "Metalink", + "description": "Repository metalink URL. Mutually exclusive with base-uri. Must be an http(s) URL." + }, + "disable-gpg-check": { + "type": "boolean", + "title": "Disable GPG check", + "description": "Opt out of GPG signature verification for this repo (zero value = checking enabled)" + }, + "gpg-key": { + "type": "string", + "pattern": "^((https?|file)://\\S+|[^\\s:]\\S*)$", + "title": "GPG key", + "description": "Path or URI to the GPG key file. Accepted URI schemes: http, https, file. Bare paths are resolved relative to the defining TOML file." + }, + "arches": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Arches", + "description": "Restrict to specific target architectures; empty = all" + } + }, + "additionalProperties": false, + "type": "object" + }, "SourceFileReference": { "properties": { "filename": {