Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package melange for openSUSE:Factory checked in at 2026-01-26 11:06:45 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/melange (Old) and /work/SRC/openSUSE:Factory/.melange.new.1928 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "melange" Mon Jan 26 11:06:45 2026 rev:133 rq:1329121 version:0.40.0 Changes: -------- --- /work/SRC/openSUSE:Factory/melange/melange.changes 2026-01-21 14:18:44.678339525 +0100 +++ /work/SRC/openSUSE:Factory/.melange.new.1928/melange.changes 2026-01-26 11:07:34.923177800 +0100 @@ -1,0 +2,14 @@ +Mon Jan 26 06:21:57 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.40.0: + * build(deps): bump actions/checkout in the actions group (#2307) + * feat(config): point npm_config_cache to /var/cache/melange/npm + * feat(config): point COMPOSER_CACHE_DIR to + /var/cache/melange/composer + * docs(cache): document cache persistence behavior per runner + * feat(maven): enable caching for Maven dependencies + * feat(qemu): add virtiofs support for cache directory + * feat(config): point UV_CACHE_DIR and PIP_CACHE_DIR to + /var/cache/melange/ by default (#2305) + +------------------------------------------------------------------- Old: ---- melange-0.39.0.obscpio New: ---- melange-0.40.0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ melange.spec ++++++ --- /var/tmp/diff_new_pack.HrNQNK/_old 2026-01-26 11:07:35.959221038 +0100 +++ /var/tmp/diff_new_pack.HrNQNK/_new 2026-01-26 11:07:35.959221038 +0100 @@ -17,7 +17,7 @@ Name: melange -Version: 0.39.0 +Version: 0.40.0 Release: 0 Summary: Build APKs from source code License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.HrNQNK/_old 2026-01-26 11:07:35.999222707 +0100 +++ /var/tmp/diff_new_pack.HrNQNK/_new 2026-01-26 11:07:36.003222874 +0100 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/melange</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v0.39.0</param> + <param name="revision">v0.40.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.HrNQNK/_old 2026-01-26 11:07:36.031224042 +0100 +++ /var/tmp/diff_new_pack.HrNQNK/_new 2026-01-26 11:07:36.035224209 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/melange</param> - <param name="changesrevision">3942cd9a018273b6fbbebc4597fdba081c3d750c</param></service></servicedata> + <param name="changesrevision">2dc47d0666a4a13854174f673303460e7aaf5139</param></service></servicedata> (No newline at EOF) ++++++ melange-0.39.0.obscpio -> melange-0.40.0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/docs/BUILD-CACHE.md new/melange-0.40.0/docs/BUILD-CACHE.md --- old/melange-0.39.0/docs/BUILD-CACHE.md 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/docs/BUILD-CACHE.md 2026-01-23 16:30:52.000000000 +0100 @@ -11,9 +11,9 @@ ## How to use it -When you run `melange build`, you can specify a cache directory with the `--cache-dir` flag. The value you provide here should be a path to a directory on your local filesystem. This local directory will be mounted into the build workspace (e.g. a running container) at the path `/var/cache/melange` as a read/write volume. +When you run `melange build`, you can specify a cache directory with the `--cache-dir` flag. The value you provide here should be a path to a directory on your local filesystem. This local directory will be mounted into the build workspace (e.g. a running container) at the path `/var/cache/melange`. -This enables you to speed up builds by preloading data into the cache before you run `melange build`. You can additionally use this mounted directory to persist cache data generated by the build itself, taking advantage of the cache in subsequent builds. +This enables you to speed up builds by preloading data into the cache before you run `melange build`. Depending on the runner you use, you may also be able to persist cache data generated by the build itself for use in subsequent builds (see [Cache Persistence by Runner](#cache-persistence-by-runner) below). ### Example: Go Modules @@ -43,6 +43,220 @@ # ... ``` -Now you're all set! If you've already downloaded the Go modules you need for your Go project to your local filesystem, you'll no longer need to wait for Melange to download those Go modules during every build. This can significantly speed up builds! +Now you're all set! If you've already downloaded the Go modules you need for your Go project to your local filesystem, you'll no longer need to wait for Melange to download those Go modules during every build. This can significantly speed up builds! -Keep in mind that because the build cache is a read/write-able mount, modifications to data in this directory during a Melange build **will affect** your local filesystem. \ No newline at end of file +**Note:** Whether modifications to the cache during a build persist to your local filesystem depends on which runner you use. See [Cache Persistence by Runner](#cache-persistence-by-runner) for details. + +### Example: Python UV + +If you're using Melange to build a Python project with [uv](https://docs.astral.sh/uv/), you can take advantage of melange's built-in UV cache support to speed up your builds. + +Melange automatically sets the `UV_CACHE_DIR` environment variable to `/var/cache/melange/uv` by default. This means you can use the `--cache-dir` flag to mount a local directory that will be used as the UV cache: + +```shell +melange build --cache-dir /path/to/your/cache ... +``` + +When using a dedicated UV cache directory on your host, you can mount it directly: + +```shell +# Create a cache directory for UV +mkdir -p ~/.cache/melange/uv + +# Run melange with the cache directory +melange build --cache-dir ~/.cache/melange ... +``` + +The UV cache will be stored under `/var/cache/melange/uv` inside the build environment. If you want to customize this path, you can override it in your Melange config: + +```yaml +environment: + environment: + UV_CACHE_DIR: '/var/cache/melange/uv' # This is the default +``` + +Or set it within a pipeline step: + +```yaml +pipeline: + - runs: | + UV_CACHE_DIR="/var/cache/melange/uv" + uv pip install -r requirements.txt +``` + +This caching support helps significantly speed up Python builds that use UV by avoiding repeated downloads of packages across builds. + +### Example: Python pip + +If you're using Melange to build a Python project with the standard `pip` package manager, you can take advantage of melange's built-in pip cache support to speed up your builds. + +Melange automatically sets the `PIP_CACHE_DIR` environment variable to `/var/cache/melange/pip` by default. This means you can use the `--cache-dir` flag to mount a local directory that will be used as the pip cache: + +```shell +melange build --cache-dir /path/to/your/cache ... +``` + +When using a dedicated pip cache directory on your host, you can mount it directly: + +```shell +# Create a cache directory +mkdir -p ~/.cache/melange + +# Run melange with the cache directory +melange build --cache-dir ~/.cache/melange ... +``` + +The pip cache will be stored under `/var/cache/melange/pip` inside the build environment. If you want to customize this path, you can override it in your Melange config: + +```yaml +environment: + environment: + PIP_CACHE_DIR: '/var/cache/melange/pip' # This is the default +``` + +Or set it within a pipeline step: + +```yaml +pipeline: + - runs: | + PIP_CACHE_DIR="/var/cache/melange/pip" + pip install -r requirements.txt +``` + +This caching support helps significantly speed up Python builds by avoiding repeated downloads of packages across builds. + +### Example: PHP Composer + +If you're using Melange to build a PHP project with [Composer](https://getcomposer.org/), you can take advantage of melange's built-in Composer cache support to speed up your builds. + +Melange automatically sets the `COMPOSER_CACHE_DIR` environment variable to `/var/cache/melange/composer` by default. This means you can use the `--cache-dir` flag to mount a local directory that will be used as the Composer cache: + +```shell +melange build --cache-dir /path/to/your/cache ... +``` + +When using a dedicated Composer cache directory on your host, you can mount it directly: + +```shell +# Create a cache directory +mkdir -p ~/.cache/melange + +# Run melange with the cache directory +melange build --cache-dir ~/.cache/melange ... +``` + +The Composer cache will be stored under `/var/cache/melange/composer` inside the build environment. If you want to customize this path, you can override it in your Melange config: + +```yaml +environment: + environment: + COMPOSER_CACHE_DIR: '/var/cache/melange/composer' # This is the default +``` + +Or set it within a pipeline step: + +```yaml +pipeline: + - runs: | + COMPOSER_CACHE_DIR="/var/cache/melange/composer" + composer install +``` + +This caching support helps significantly speed up PHP builds by avoiding repeated downloads of packages across builds. + +### Example: npm + +If you're using Melange to build a JavaScript/Node.js project with [npm](https://www.npmjs.com/), you can take advantage of melange's built-in npm cache support to speed up your builds. + +Melange automatically sets the `npm_config_cache` environment variable to `/var/cache/melange/npm` by default. This means you can use the `--cache-dir` flag to mount a local directory that will be used as the npm cache: + +```shell +melange build --cache-dir /path/to/your/cache ... +``` + +When using a dedicated npm cache directory on your host, you can mount it directly: + +```shell +# Create a cache directory +mkdir -p ~/.cache/melange + +# Run melange with the cache directory +melange build --cache-dir ~/.cache/melange ... +``` + +The npm cache will be stored under `/var/cache/melange/npm` inside the build environment. If you want to customize this path, you can override it in your Melange config: + +```yaml +environment: + environment: + npm_config_cache: '/var/cache/melange/npm' # This is the default +``` + +Or set it within a pipeline step: + +```yaml +pipeline: + - runs: | + npm_config_cache="/var/cache/melange/npm" + npm install +``` + +This caching support helps significantly speed up Node.js builds by avoiding repeated downloads of packages across builds. + +### Example: Maven Dependencies + +Maven caching is automatically enabled when using the `maven/configure-mirror` or `maven/pombump` pipelines. When a cache directory is mounted at `/var/cache/melange`, the pipelines automatically configure Maven to use `/var/cache/melange/m2repository` as the local repository. + +To use Maven caching, simply provide a cache directory: + +```shell +melange build --cache-dir /path/to/my/cache ... +``` + +No additional configuration is required in your Melange config. The Maven pipelines detect the mounted cache directory and configure the local repository path automatically. This is useful for Java projects with many dependencies (e.g., apicurio-registry requires over 1 GB of dependencies). + +On subsequent builds, Maven will reuse the downloaded dependencies from the cache, avoiding redundant downloads. + +## Cache Persistence by Runner + +The cache directory mount behavior varies depending on which runner you use: + +| Runner | Mount Type | Writes Persist to Host | +|--------|------------|------------------------| +| Docker | Bind mount (read-write) | Yes | +| Bubblewrap | Bind mount (read-write) | Yes | +| QEMU (default) | 9p (read-only) + overlay | No | +| QEMU (virtiofs) | virtiofs (read-write) | Yes | + +### Docker and Bubblewrap + +Both Docker and Bubblewrap mount the cache directory as a standard read-write bind mount. Any modifications made to `/var/cache/melange` during the build **will directly affect** your local filesystem. This allows builds to populate the cache for use in subsequent builds. + +### QEMU (default, without virtiofs) + +By default, QEMU mounts the cache directory using the 9p protocol with a read-only flag. To allow builds to write to the cache, an overlay filesystem is layered on top: + +- **Lower layer:** Read-only 9p mount of your host cache directory +- **Upper layer:** Temporary writable directory inside the guest + +This means builds can read from your pre-populated cache, but any writes during the build go to the overlay's upper directory and **are discarded** when the build completes. To persist cache writes with QEMU, enable virtiofs (see below). + +## QEMU Runner: virtiofs for Cache Directory + +When using the QEMU runner, the default 9p mount does not persist cache writes to the host. To enable cache persistence (and improve I/O performance), you can use virtiofs instead. + +To enable virtiofs for the cache directory, set the `QEMU_USE_VIRTIOFS` environment variable: + +```shell +QEMU_USE_VIRTIOFS=1 melange build --runner qemu --cache-dir /path/to/cache ... +``` + +**Requirements:** +- The `virtiofsd` binary must be available on the host system (checked at `/usr/libexec/virtiofsd`, `/usr/lib/qemu/virtiofsd`, or in `$PATH`). Alternatively, set `QEMU_VIRTIOFS_PATH` to a directory containing the `virtiofsd` binary (useful for macOS/brew or non-standard installations). +- The host system must support virtiofs (Linux with appropriate kernel support) + +When virtiofs is enabled, the cache directory is mounted as a read-write virtiofs share, providing: +- **Cache persistence:** Writes during the build are saved to your host filesystem +- **Better I/O performance:** virtiofs offers improved performance compared to 9p + +If `QEMU_USE_VIRTIOFS=1` is set but `virtiofsd` is not found, melange will return an error. If the environment variable is not set or set to `0`, the default 9p+overlay mount is used and cache writes are not persisted. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/build/build_test.go new/melange-0.40.0/pkg/build/build_test.go --- old/melange-0.39.0/pkg/build/build_test.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/build/build_test.go 2026-01-23 16:30:52.000000000 +0100 @@ -203,9 +203,13 @@ }, Environment: apko_types.ImageConfiguration{ Environment: map[string]string{ - "GOMODCACHE": "/var/cache/melange/gomodcache", - "HOME": "/home/build/special-case", - "GOPATH": "/var/cache/melange/go", + "GOMODCACHE": "/var/cache/melange/gomodcache", + "HOME": "/home/build/special-case", + "GOPATH": "/var/cache/melange/go", + "UV_CACHE_DIR": "/var/cache/melange/uv", + "PIP_CACHE_DIR": "/var/cache/melange/pip", + "COMPOSER_CACHE_DIR": "/var/cache/melange/composer", + "npm_config_cache": "/var/cache/melange/npm", }, Accounts: apko_types.ImageAccounts{ Users: []apko_types.User{{UserName: buildUser, UID: 1000, GID: apko_types.GID(&gid1000)}}, @@ -294,9 +298,13 @@ Members: []string{buildUser}, }} expected.Environment.Environment = map[string]string{ - "HOME": "/home/build", - "GOPATH": "/home/build/.cache/go", - "GOMODCACHE": "/var/cache/melange/gomodcache", + "HOME": "/home/build", + "GOPATH": "/home/build/.cache/go", + "GOMODCACHE": "/var/cache/melange/gomodcache", + "UV_CACHE_DIR": "/var/cache/melange/uv", + "PIP_CACHE_DIR": "/var/cache/melange/pip", + "COMPOSER_CACHE_DIR": "/var/cache/melange/composer", + "npm_config_cache": "/var/cache/melange/npm", } f := filepath.Join(t.TempDir(), "config") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/build/pipelines/maven/configure-mirror.yaml new/melange-0.40.0/pkg/build/pipelines/maven/configure-mirror.yaml --- old/melange-0.39.0/pkg/build/pipelines/maven/configure-mirror.yaml 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/build/pipelines/maven/configure-mirror.yaml 2026-01-23 16:30:52.000000000 +0100 @@ -4,10 +4,19 @@ - busybox pipeline: - runs: | + # Use melange's build cache for downloaded dependencies if mounted from host + MAVEN_LOCAL_REPO="/var/cache/melange/m2repository" + LOCAL_REPO_CONFIG="" + if [ -d "/var/cache/melange" ]; then + mkdir -p "$MAVEN_LOCAL_REPO" + LOCAL_REPO_CONFIG="<localRepository>${MAVEN_LOCAL_REPO}</localRepository>" + fi + # Maven checks $USER/.m2, we set $HOME to /home/build but it hardcodes $USER somehow mkdir -p /root/.m2 cat > /root/.m2/settings.xml <<EOF <settings> + ${LOCAL_REPO_CONFIG} <mirrors> <mirror> <id>google-maven-central</id> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/build/pipelines/maven/pombump.yaml new/melange-0.40.0/pkg/build/pipelines/maven/pombump.yaml --- old/melange-0.39.0/pkg/build/pipelines/maven/pombump.yaml 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/build/pipelines/maven/pombump.yaml 2026-01-23 16:30:52.000000000 +0100 @@ -33,6 +33,18 @@ pipeline: - runs: | + # Use melange's build cache for downloaded dependencies if mounted from host + MAVEN_LOCAL_REPO="/var/cache/melange/m2repository" + if [ -d "/var/cache/melange" ]; then + mkdir -p "$MAVEN_LOCAL_REPO" + mkdir -p /root/.m2 + cat > /root/.m2/settings.xml <<EOF + <settings> + <localRepository>${MAVEN_LOCAL_REPO}</localRepository> + </settings> + EOF + fi + PATCH_FILE_FLAG="" PROPERTIES_FILE_FLAG="" DEPENDENCIES_FLAG="" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/config/config.go new/melange-0.40.0/pkg/config/config.go --- old/melange-0.39.0/pkg/config/config.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/config/config.go 2026-01-23 16:30:52.000000000 +0100 @@ -1691,9 +1691,13 @@ } const ( - defaultEnvVarHOME = "/home/build" - defaultEnvVarGOPATH = "/home/build/.cache/go" - defaultEnvVarGOMODCACHE = "/var/cache/melange/gomodcache" + defaultEnvVarHOME = "/home/build" + defaultEnvVarGOPATH = "/home/build/.cache/go" + defaultEnvVarGOMODCACHE = "/var/cache/melange/gomodcache" + defaultEnvVarUVCACHEDIR = "/var/cache/melange/uv" + defaultEnvVarPIPCACHEDIR = "/var/cache/melange/pip" + defaultEnvVarCOMPOSERCACHEDIR = "/var/cache/melange/composer" + defaultEnvVarNPMCACHE = "/var/cache/melange/npm" ) setIfEmpty := func(key, value string) { @@ -1705,6 +1709,10 @@ setIfEmpty("HOME", defaultEnvVarHOME) setIfEmpty("GOPATH", defaultEnvVarGOPATH) setIfEmpty("GOMODCACHE", defaultEnvVarGOMODCACHE) + setIfEmpty("UV_CACHE_DIR", defaultEnvVarUVCACHEDIR) + setIfEmpty("PIP_CACHE_DIR", defaultEnvVarPIPCACHEDIR) + setIfEmpty("COMPOSER_CACHE_DIR", defaultEnvVarCOMPOSERCACHEDIR) + setIfEmpty("npm_config_cache", defaultEnvVarNPMCACHE) if err := cfg.applySubstitutionsForProvides(); err != nil { return nil, err diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/config/config_test.go new/melange-0.40.0/pkg/config/config_test.go --- old/melange-0.39.0/pkg/config/config_test.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/config/config_test.go 2026-01-23 16:30:52.000000000 +0100 @@ -1913,6 +1913,133 @@ } } +func TestDefaultEnvironmentVariables(t *testing.T) { + ctx := slogtest.Context(t) + + tests := []struct { + name string + yaml string + expectedEnv map[string]string + unexpectedEnv []string + }{ + { + name: "defaults are set when no environment specified", + yaml: ` +package: + name: test-pkg + version: 1.0.0 + epoch: 0 +`, + expectedEnv: map[string]string{ + "HOME": "/home/build", + "GOPATH": "/home/build/.cache/go", + "GOMODCACHE": "/var/cache/melange/gomodcache", + "UV_CACHE_DIR": "/var/cache/melange/uv", + "PIP_CACHE_DIR": "/var/cache/melange/pip", + "COMPOSER_CACHE_DIR": "/var/cache/melange/composer", + "npm_config_cache": "/var/cache/melange/npm", + }, + }, + { + name: "UV_CACHE_DIR can be overridden", + yaml: ` +package: + name: test-pkg + version: 1.0.0 + epoch: 0 +environment: + environment: + UV_CACHE_DIR: '/custom/uv/cache' +`, + expectedEnv: map[string]string{ + "HOME": "/home/build", + "GOPATH": "/home/build/.cache/go", + "GOMODCACHE": "/var/cache/melange/gomodcache", + "UV_CACHE_DIR": "/custom/uv/cache", + "PIP_CACHE_DIR": "/var/cache/melange/pip", + "COMPOSER_CACHE_DIR": "/var/cache/melange/composer", + "npm_config_cache": "/var/cache/melange/npm", + }, + }, + { + name: "all cache env vars can be overridden", + yaml: ` +package: + name: test-pkg + version: 1.0.0 + epoch: 0 +environment: + environment: + HOME: '/custom/home' + GOPATH: '/custom/gopath' + GOMODCACHE: '/custom/gomodcache' + UV_CACHE_DIR: '/custom/uv' + PIP_CACHE_DIR: '/custom/pip' + COMPOSER_CACHE_DIR: '/custom/composer' + npm_config_cache: '/custom/npm' +`, + expectedEnv: map[string]string{ + "HOME": "/custom/home", + "GOPATH": "/custom/gopath", + "GOMODCACHE": "/custom/gomodcache", + "UV_CACHE_DIR": "/custom/uv", + "PIP_CACHE_DIR": "/custom/pip", + "COMPOSER_CACHE_DIR": "/custom/composer", + "npm_config_cache": "/custom/npm", + }, + }, + { + name: "additional env vars do not affect defaults", + yaml: ` +package: + name: test-pkg + version: 1.0.0 + epoch: 0 +environment: + environment: + MY_CUSTOM_VAR: 'custom_value' +`, + expectedEnv: map[string]string{ + "HOME": "/home/build", + "GOPATH": "/home/build/.cache/go", + "GOMODCACHE": "/var/cache/melange/gomodcache", + "UV_CACHE_DIR": "/var/cache/melange/uv", + "PIP_CACHE_DIR": "/var/cache/melange/pip", + "COMPOSER_CACHE_DIR": "/var/cache/melange/composer", + "npm_config_cache": "/var/cache/melange/npm", + "MY_CUSTOM_VAR": "custom_value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fp := filepath.Join(t.TempDir(), "test-config.yaml") + if err := os.WriteFile(fp, []byte(tt.yaml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := ParseConfiguration(ctx, fp) + require.NoError(t, err) + + for key, expectedValue := range tt.expectedEnv { + actualValue, exists := cfg.Environment.Environment[key] + if !exists { + t.Errorf("expected environment variable %q to be set", key) + continue + } + require.Equal(t, expectedValue, actualValue, "environment variable %q mismatch", key) + } + + for _, key := range tt.unexpectedEnv { + if _, exists := cfg.Environment.Environment[key]; exists { + t.Errorf("unexpected environment variable %q is set", key) + } + } + }) + } +} + func TestLicensingInfosWithValidation(t *testing.T) { // Create a temp directory with a license file tmpDir := t.TempDir() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/container/config.go new/melange-0.40.0/pkg/container/config.go --- old/melange-0.39.0/pkg/container/config.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/container/config.go 2026-01-23 16:30:52.000000000 +0100 @@ -75,4 +75,9 @@ SSHControlClient *ssh.Client // SSH client for unrestricted control environment, has privileges QemuPID int RunAsGID string + + // Virtiofs-related fields for cache directory + VirtiofsEnabled bool // Whether virtiofs is enabled for cache + VirtiofsdPID int // PID of virtiofsd daemon for cleanup + VirtiofsdSocketPath string // Path to Unix socket for virtiofsd } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/container/qemu_runner.go new/melange-0.40.0/pkg/container/qemu_runner.go --- old/melange-0.39.0/pkg/container/qemu_runner.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/container/qemu_runner.go 2026-01-23 16:30:52.000000000 +0100 @@ -390,6 +390,7 @@ defer os.Remove(cfg.Disk) defer os.Remove(cfg.SSHHostKey) defer secureDelete(ctx, cfg.InitramfsPath) + defer stopVirtiofsd(ctx, cfg) clog.FromContext(ctx).Info("qemu: sending shutdown signal") err := sendSSHCommand(ctx, @@ -596,6 +597,35 @@ return err } + // Set up virtiofs for cache directory if enabled and available + if cfg.CacheDir != "" { + // Ensure the cachedir exists + if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create shared cachedir: %w", err) + } + + var err error + cfg.VirtiofsEnabled, err = useVirtiofs() + if err != nil { + return err + } + + if cfg.VirtiofsEnabled { + // Generate a random socket path + var randBytes [8]byte + if _, err := rand.Read(randBytes[:]); err != nil { + return fmt.Errorf("failed to generate random bytes for socket path: %w", err) + } + cfg.VirtiofsdSocketPath = filepath.Join(os.TempDir(), fmt.Sprintf("melange-virtiofsd-%x.sock", randBytes)) + + virtiofsdCmd, err := startVirtiofsd(ctx, cfg) + if err != nil { + return fmt.Errorf("failed to start virtiofsd: %w", err) + } + cfg.VirtiofsdPID = virtiofsdCmd.Process.Pid + } + } + baseargs := []string{} bios := false useVM := false @@ -620,6 +650,12 @@ "-chardev", "stdio,id=charconsole0", "-device", "virtconsole,chardev=charconsole0,id=console0", } + // Helper to add memory-backend suffix for virtiofs shared memory + machineMemorySuffix := "" + if cfg.VirtiofsEnabled { + machineMemorySuffix = ",memory-backend=mem" + } + if useVM { // load microvm profile and bios, shave some milliseconds from boot // using this will make a complete boot->initrd (with working network) In ~700ms @@ -630,7 +666,7 @@ } { if _, err := os.Stat(p); err == nil && cfg.Arch.ToAPK() != "aarch64" { // only enable pcie for network, enable RTC for kernel, disable i8254PIT, i8259PIC and serial port - baseargs = append(baseargs, "-machine", "microvm,rtc=on,pcie=on,pit=off,pic=off,isa-serial=on") + baseargs = append(baseargs, "-machine", "microvm,rtc=on,pcie=on,pit=off,pic=off,isa-serial=on"+machineMemorySuffix) baseargs = append(baseargs, "-bios", p) // microvm in qemu any version tested will not send hvc0/virtconsole to stdout kernelConsole = "console=ttyS0" @@ -647,9 +683,9 @@ // if we're on x86 arch, but without microvm machine type, let's go to q35 switch cfg.Arch.ToAPK() { case "aarch64": - baseargs = append(baseargs, "-machine", "virt") + baseargs = append(baseargs, "-machine", "virt"+machineMemorySuffix) case "x86_64": - baseargs = append(baseargs, "-machine", "q35") + baseargs = append(baseargs, "-machine", "q35"+machineMemorySuffix) default: return fmt.Errorf("unknown architecture: %s", cfg.Arch.ToAPK()) } @@ -667,7 +703,15 @@ mem = memKb } } - baseargs = append(baseargs, "-m", fmt.Sprintf("%dk", mem)) + // Memory configuration - virtiofs requires shared memory backend + if cfg.VirtiofsEnabled { + // Round up memory to MiB boundary for memory-backend-memfd alignment + memMiB := (mem + 1023) / 1024 * 1024 + baseargs = append(baseargs, "-m", fmt.Sprintf("%dk", memMiB)) + baseargs = append(baseargs, "-object", fmt.Sprintf("memory-backend-memfd,id=mem,size=%dM,share=on", memMiB/1024)) + } else { + baseargs = append(baseargs, "-m", fmt.Sprintf("%dk", mem)) + } // default to use all CPUs, if a cpu limit is set, respect it. nproc := runtime.NumCPU() @@ -757,12 +801,19 @@ baseargs = append(baseargs, "-device", "virtio-9p-pci,id=fs100,fsdev=fsdev100,mount_tag=defaultshare") if cfg.CacheDir != "" { - baseargs = append(baseargs, "-fsdev", "local,security_model=mapped,id=fsdev101,readonly=on,path="+cfg.CacheDir) - baseargs = append(baseargs, "-device", "virtio-9p-pci,id=fs101,fsdev=fsdev101,mount_tag=melange_cache") - - // ensure the cachedir exists - if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil { - return fmt.Errorf("failed to create shared cachedir: %w", err) + if cfg.VirtiofsEnabled { + log.Info("qemu: using virtiofs for cache directory (read-write)") + // Chardev for socket communication + baseargs = append(baseargs, + "-chardev", + fmt.Sprintf("socket,id=char_cache,path=%s", cfg.VirtiofsdSocketPath)) + // vhost-user-fs-pci device + baseargs = append(baseargs, "-device", + "vhost-user-fs-pci,queue-size=1024,chardev=char_cache,tag=melange_cache") + } else { + log.Info("qemu: using 9p for cache directory (read-only with overlay)") + baseargs = append(baseargs, "-fsdev", "local,security_model=mapped,id=fsdev101,readonly=on,path="+cfg.CacheDir) + baseargs = append(baseargs, "-device", "virtio-9p-pci,id=fs101,fsdev=fsdev101,mount_tag=melange_cache") } } @@ -836,6 +887,7 @@ if err := qemuCmd.Start(); err != nil { defer os.Remove(cfg.ImgRef) defer os.Remove(cfg.Disk) + defer stopVirtiofsd(ctx, cfg) return fmt.Errorf("qemu: failed to start qemu command: %w", err) } @@ -908,10 +960,12 @@ case err := <-qemuExit: defer os.Remove(cfg.ImgRef) defer os.Remove(cfg.Disk) + defer stopVirtiofsd(ctx, cfg) return fmt.Errorf("qemu: VM exited unexpectedly: %w", err) case <-ctx.Done(): defer os.Remove(cfg.ImgRef) defer os.Remove(cfg.Disk) + defer stopVirtiofsd(ctx, cfg) return fmt.Errorf("qemu: context canceled while waiting for VM to start") } @@ -940,33 +994,52 @@ clog.FromContext(ctx).Infof("qemu: running kernel version: %s", kv) if cfg.CacheDir != "" { - clog.FromContext(ctx).Infof("qemu: setting up melange cachedir: %s", cfg.CacheDir) - setupMountCommand := fmt.Sprintf( - "mkdir -p %s %s /mount/upper /mount/work && "+ - "chmod 1777 /mount/upper && "+ - "mount -t 9p -o ro melange_cache %s && "+ - "mount -t overlay overlay -o lowerdir=%s,upperdir=/mount/upper,workdir=/mount/work %s", - DefaultCacheDir, - filepath.Join("/mount", DefaultCacheDir), - DefaultCacheDir, - DefaultCacheDir, - filepath.Join("/mount", DefaultCacheDir), - ) - if setupMountCommand != ": " { - err = sendSSHCommand(ctx, - cfg.SSHControlClient, - cfg, - nil, - stderr, - stdout, - false, - []string{"sh", "-c", setupMountCommand}, + var setupMountCommand string + + if cfg.VirtiofsEnabled { + // Virtiofs: read-write mount at the chroot path + // The build runs chrooted at /mount/, so we mount at /mount/var/cache/melange + // which appears as /var/cache/melange inside the chroot + chrootCacheDir := filepath.Join("/mount", DefaultCacheDir) + clog.FromContext(ctx).Infof("qemu: setting up virtiofs cache mount (read-write): %s -> %s", cfg.CacheDir, chrootCacheDir) + setupMountCommand = fmt.Sprintf( + "mkdir -p %s && mount -t virtiofs melange_cache %s", + chrootCacheDir, + chrootCacheDir, + ) + } else { + // 9p: readonly with overlay for write support + clog.FromContext(ctx).Infof("qemu: setting up 9p cache mount (read-only with overlay): %s", cfg.CacheDir) + setupMountCommand = fmt.Sprintf( + "mkdir -p %s %s /mount/upper /mount/work && "+ + "chmod 1777 /mount/upper && "+ + "mount -t 9p -o ro melange_cache %s && "+ + "mount -t overlay overlay -o lowerdir=%s,upperdir=/mount/upper,workdir=/mount/work %s", + DefaultCacheDir, + filepath.Join("/mount", DefaultCacheDir), + DefaultCacheDir, + DefaultCacheDir, + filepath.Join("/mount", DefaultCacheDir), ) + } + + err = sendSSHCommand(ctx, + cfg.SSHControlClient, + cfg, + nil, + stderr, + stdout, + false, + []string{"sh", "-c", setupMountCommand}, + ) + if err != nil { + // Clean up virtiofsd on failure + if cfg.VirtiofsEnabled { + stopVirtiofsd(ctx, cfg) + } + err = qemuCmd.Process.Kill() if err != nil { - err = qemuCmd.Process.Kill() - if err != nil { - return err - } + return err } } } @@ -1675,6 +1748,133 @@ return l.Addr().(*net.TCPAddr).Port, nil } +// virtiofsdSearchPaths defines where to look for the virtiofsd binary. +var virtiofsdSearchPaths = []string{ + "/usr/libexec/virtiofsd", // Fedora, RHEL + "/usr/lib/qemu/virtiofsd", // Debian, Ubuntu + "virtiofsd", // In PATH +} + +// isVirtiofsdAvailable checks if virtiofsd is installed and returns its path. +// If QEMU_VIRTIOFS_PATH is set, it will look for virtiofsd in that directory. +func isVirtiofsdAvailable() (string, bool) { + // Check for user-specified directory first (useful for macOS/brew or custom installs) + if customDir, ok := os.LookupEnv("QEMU_VIRTIOFS_PATH"); ok && customDir != "" { + customPath := filepath.Join(customDir, "virtiofsd") + if _, err := os.Stat(customPath); err == nil { + return customPath, true + } + } + for _, path := range virtiofsdSearchPaths { + if p, err := exec.LookPath(path); err == nil { + return p, true + } + } + return "", false +} + +// useVirtiofs checks if virtiofs should be used based on environment variable. +// Returns (true, nil) if virtiofs is enabled and available. +// Returns (false, nil) if virtiofs is not requested. +// Returns (false, error) if virtiofs is explicitly requested but not available. +func useVirtiofs() (bool, error) { + if envVal, ok := os.LookupEnv("QEMU_USE_VIRTIOFS"); ok { + if val, err := strconv.ParseBool(envVal); err == nil && val { + if _, available := isVirtiofsdAvailable(); available { + return true, nil + } + return false, fmt.Errorf("QEMU_USE_VIRTIOFS=1 but virtiofsd not found (checked %v, or set QEMU_VIRTIOFS_PATH)", virtiofsdSearchPaths) + } + } + return false, nil +} + +// startVirtiofsd starts the virtiofsd daemon and returns its process. +func startVirtiofsd(ctx context.Context, cfg *Config) (*exec.Cmd, error) { + log := clog.FromContext(ctx) + + virtiofsdPath, ok := isVirtiofsdAvailable() + if !ok { + return nil, fmt.Errorf("virtiofsd not found") + } + + // Remove stale socket if exists + os.Remove(cfg.VirtiofsdSocketPath) + + args := []string{ + "--socket-path=" + cfg.VirtiofsdSocketPath, + fmt.Sprintf("--thread-pool-size=%d", runtime.NumCPU()*2), // Parallel I/O + "-o", "source=" + cfg.CacheDir, + "-o", "cache=always", // Balance coherency and performance + "-o", "sandbox=namespace", // Use namespace sandbox (works without root) + "-o", "xattr", // Enable xattr support + "-o", "writeback", // Enable writeback caching for better write performance + "-o", "no_posix_lock", + } + + log.Debugf("starting virtiofsd: %s %v", virtiofsdPath, args) + + // #nosec G204 - virtiofsdPath is from known paths checked by isVirtiofsdAvailable + cmd := exec.CommandContext(ctx, virtiofsdPath, args...) + + // Capture stderr for debugging + errPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start virtiofsd: %w", err) + } + + // Log virtiofsd output in background + go func() { + scanner := bufio.NewScanner(errPipe) + for scanner.Scan() { + log.Debugf("virtiofsd: %s", scanner.Text()) + } + }() + + // Wait for socket to be created (max 5 seconds) + for range 50 { + if _, err := os.Stat(cfg.VirtiofsdSocketPath); err == nil { + log.Infof("virtiofsd started successfully, socket: %s", cfg.VirtiofsdSocketPath) + return cmd, nil + } + time.Sleep(100 * time.Millisecond) + } + + // Socket not created, kill process and return error + _ = cmd.Process.Kill() + return nil, fmt.Errorf("virtiofsd socket not created within timeout") +} + +// stopVirtiofsd gracefully stops the virtiofsd daemon. +func stopVirtiofsd(ctx context.Context, cfg *Config) { + log := clog.FromContext(ctx) + + if cfg.VirtiofsdPID > 0 { + log.Debugf("stopping virtiofsd (PID %d)", cfg.VirtiofsdPID) + + // Send SIGTERM for graceful shutdown + if err := syscall.Kill(cfg.VirtiofsdPID, syscall.SIGTERM); err != nil { + log.Warnf("failed to send SIGTERM to virtiofsd: %v", err) + // Force kill if SIGTERM fails + _ = syscall.Kill(cfg.VirtiofsdPID, syscall.SIGKILL) + } + + cfg.VirtiofsdPID = 0 + } + + // Clean up socket file + if cfg.VirtiofsdSocketPath != "" { + if err := os.Remove(cfg.VirtiofsdSocketPath); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove virtiofsd socket: %v", err) + } + cfg.VirtiofsdSocketPath = "" + } +} + // zeroSensitiveFields zeros out sensitive cryptographic material from memory after it's no longer needed. func zeroSensitiveFields(ctx context.Context, cfg *Config) { clog.FromContext(ctx).Debug("qemu: zeroing sensitive cryptographic material from memory") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.39.0/pkg/container/qemu_runner_test.go new/melange-0.40.0/pkg/container/qemu_runner_test.go --- old/melange-0.39.0/pkg/container/qemu_runner_test.go 2026-01-20 15:43:08.000000000 +0100 +++ new/melange-0.40.0/pkg/container/qemu_runner_test.go 2026-01-23 16:30:52.000000000 +0100 @@ -291,3 +291,265 @@ } } } + +func TestVirtiofsdSearchPaths(t *testing.T) { + // Verify search paths are defined and non-empty + if len(virtiofsdSearchPaths) == 0 { + t.Error("virtiofsdSearchPaths is empty") + } + + // Verify expected paths are present + expectedPaths := map[string]bool{ + "/usr/libexec/virtiofsd": false, + "/usr/lib/qemu/virtiofsd": false, + "virtiofsd": false, + } + + for _, path := range virtiofsdSearchPaths { + if _, ok := expectedPaths[path]; ok { + expectedPaths[path] = true + } + } + + for path, found := range expectedPaths { + if !found { + t.Errorf("expected path %q not found in virtiofsdSearchPaths", path) + } + } + + t.Logf("virtiofsdSearchPaths: %v", virtiofsdSearchPaths) +} + +func TestIsVirtiofsdAvailable(t *testing.T) { + path, available := isVirtiofsdAvailable() + + if available { + // If available, path should be non-empty + if path == "" { + t.Error("isVirtiofsdAvailable() returned available=true but empty path") + } + t.Logf("virtiofsd found at: %s", path) + } else { + // If not available, path should be empty + if path != "" { + t.Errorf("isVirtiofsdAvailable() returned available=false but non-empty path: %s", path) + } + t.Log("virtiofsd not found on this system") + } +} + +func TestUseVirtiofs(t *testing.T) { + // Save original env and restore after test + originalEnv, hadEnv := os.LookupEnv("QEMU_USE_VIRTIOFS") + defer func() { + if hadEnv { + os.Setenv("QEMU_USE_VIRTIOFS", originalEnv) + } else { + os.Unsetenv("QEMU_USE_VIRTIOFS") + } + }() + + tests := []struct { + name string + envValue string + envSet bool + expectUse bool + expectError bool + }{ + { + name: "env not set", + envSet: false, + expectUse: false, + expectError: false, + }, + { + name: "env set to false", + envValue: "false", + envSet: true, + expectUse: false, + expectError: false, + }, + { + name: "env set to 0", + envValue: "0", + envSet: true, + expectUse: false, + expectError: false, + }, + { + name: "env set to invalid value", + envValue: "invalid", + envSet: true, + expectUse: false, + expectError: false, + }, + { + name: "env set to empty string", + envValue: "", + envSet: true, + expectUse: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + os.Setenv("QEMU_USE_VIRTIOFS", tt.envValue) + } else { + os.Unsetenv("QEMU_USE_VIRTIOFS") + } + + use, err := useVirtiofs() + + if tt.expectError && err == nil { + t.Error("expected error but got nil") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + if use != tt.expectUse { + t.Errorf("useVirtiofs() = %v, expected %v", use, tt.expectUse) + } + }) + } +} + +func TestUseVirtiofs_EnabledWithAvailability(t *testing.T) { + // Save original env and restore after test + originalEnv, hadEnv := os.LookupEnv("QEMU_USE_VIRTIOFS") + defer func() { + if hadEnv { + os.Setenv("QEMU_USE_VIRTIOFS", originalEnv) + } else { + os.Unsetenv("QEMU_USE_VIRTIOFS") + } + }() + + // Test with QEMU_USE_VIRTIOFS=1 + os.Setenv("QEMU_USE_VIRTIOFS", "1") + + _, available := isVirtiofsdAvailable() + use, err := useVirtiofs() + + if available { + // virtiofsd is available, should return true with no error + if err != nil { + t.Errorf("unexpected error when virtiofsd is available: %v", err) + } + if !use { + t.Error("useVirtiofs() = false when virtiofsd is available and QEMU_USE_VIRTIOFS=1") + } + t.Log("virtiofsd available: useVirtiofs returned true") + } else { + // virtiofsd is not available, should return error + if err == nil { + t.Error("expected error when virtiofsd not available but QEMU_USE_VIRTIOFS=1") + } + if use { + t.Error("useVirtiofs() = true when virtiofsd is not available") + } + t.Logf("virtiofsd not available: useVirtiofs returned error: %v", err) + } +} + +func TestUseVirtiofs_ErrorMessageContainsPaths(t *testing.T) { + // Skip if virtiofsd is available (can't test error path) + if _, available := isVirtiofsdAvailable(); available { + t.Skip("virtiofsd is available, cannot test error message") + } + + // Save original env and restore after test + originalEnv, hadEnv := os.LookupEnv("QEMU_USE_VIRTIOFS") + defer func() { + if hadEnv { + os.Setenv("QEMU_USE_VIRTIOFS", originalEnv) + } else { + os.Unsetenv("QEMU_USE_VIRTIOFS") + } + }() + + os.Setenv("QEMU_USE_VIRTIOFS", "1") + _, err := useVirtiofs() + + if err == nil { + t.Fatal("expected error but got nil") + } + + errMsg := err.Error() + + // Error message should mention the search paths + for _, path := range virtiofsdSearchPaths { + if !contains(errMsg, path) { + t.Errorf("error message should contain path %q: %s", path, errMsg) + } + } + + t.Logf("error message: %s", errMsg) +} + +func TestStopVirtiofsd_NoOp(t *testing.T) { + ctx := clog.WithLogger(context.Background(), slogtest.TestLogger(t)) + + // Test that stopVirtiofsd doesn't panic with zero values + cfg := &Config{} + stopVirtiofsd(ctx, cfg) + + // Test with already-zeroed PID + cfg.VirtiofsdPID = 0 + cfg.VirtiofsdSocketPath = "" + stopVirtiofsd(ctx, cfg) + + // Should not panic or error + t.Log("stopVirtiofsd handled zero-value config correctly") +} + +func TestStopVirtiofsd_CleansUpSocket(t *testing.T) { + ctx := clog.WithLogger(context.Background(), slogtest.TestLogger(t)) + + // Create a temporary file to simulate a socket + tmpFile, err := os.CreateTemp("", "test-virtiofsd-*.sock") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + + // Verify file exists + if _, err := os.Stat(tmpPath); os.IsNotExist(err) { + t.Fatal("temp file should exist") + } + + cfg := &Config{ + VirtiofsdPID: 0, // No process to kill + VirtiofsdSocketPath: tmpPath, + } + + stopVirtiofsd(ctx, cfg) + + // Socket path should be cleared + if cfg.VirtiofsdSocketPath != "" { + t.Errorf("VirtiofsdSocketPath should be cleared, got %q", cfg.VirtiofsdSocketPath) + } + + // File should be removed + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Error("socket file should be removed") + os.Remove(tmpPath) // Clean up + } +} + +// contains checks if substr is in s +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} ++++++ melange.obsinfo ++++++ --- /var/tmp/diff_new_pack.HrNQNK/_old 2026-01-26 11:07:36.859258598 +0100 +++ /var/tmp/diff_new_pack.HrNQNK/_new 2026-01-26 11:07:36.863258765 +0100 @@ -1,5 +1,5 @@ name: melange -version: 0.39.0 -mtime: 1768920188 -commit: 3942cd9a018273b6fbbebc4597fdba081c3d750c +version: 0.40.0 +mtime: 1769182252 +commit: 2dc47d0666a4a13854174f673303460e7aaf5139 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/melange/vendor.tar.gz /work/SRC/openSUSE:Factory/.melange.new.1928/vendor.tar.gz differ: char 133, line 3
