From ae9492df6e14c368d5107c7569782ba4d9987fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 18 Mar 2026 14:31:19 +0100 Subject: [PATCH 1/3] gha/ci: Add rootless integration test job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `integration-rootless` job that creates a non-root user (`testuser`), adds it to the kvm group, and runs integration tests as that user. Signed-off-by: Paweł Gronowski --- .github/workflows/ci.yml | 104 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60eb939..b6c589c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,3 +229,107 @@ jobs: - name: Run integration tests run: go test -v ./integration/... + + # + # Integration tests (rootless) + # + integration-rootless: + name: Integration Tests (rootless) + needs: [setup, build-kernels] + if: | + always() && + (needs.build-kernels.result == 'success' || needs.build-kernels.result == 'skipped') + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + + steps: + - name: Create non-root user with KVM access + run: | + sudo useradd -m testuser + sudo usermod -aG kvm testuser + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Calculate kernel cache key + id: cache-key + run: | + # Hash the kernel config and patches to create a unique cache key + CONFIG_FILE="kernel/config-${{ needs.setup.outputs.kernel-version }}-${{ matrix.arch }}" + + if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: Kernel config file $CONFIG_FILE not found" + exit 1 + fi + + # Calculate hash of config file and all patches + CONFIG_HASH=$(sha256sum "$CONFIG_FILE" | cut -d' ' -f1) + PATCHES_HASH=$(find kernel/patches -type f -name "*.patch" -exec sha256sum {} \; | sort | sha256sum | cut -d' ' -f1) + + # Combine version, arch, config hash, and patches hash + CACHE_KEY="kernel-${{ needs.setup.outputs.kernel-version }}-${{ matrix.arch }}-${CONFIG_HASH:0:8}-${PATCHES_HASH:0:8}" + + echo "cache-key=${CACHE_KEY}" >> $GITHUB_OUTPUT + echo "Kernel cache key: ${CACHE_KEY}" + + - name: Restore cached kernel + id: cache-kernel + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: _output/nerdbox-kernel-${{ matrix.arch }} + key: ${{ steps.cache-key.outputs.cache-key }} + + - name: Verify kernel from cache + run: | + if [ "${{ steps.cache-kernel.outputs.cache-hit }}" = "true" ]; then + echo "✅ Kernel restored from cache" + else + echo "❌ Kernel not in cache - this should not happen after build-kernels-on-demand" + exit 1 + fi + ls -lh _output/nerdbox-kernel-${{ matrix.arch }} + file _output/nerdbox-kernel-${{ matrix.arch }} + + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build remaining artifacts (initrd and shim) + run: | + echo "Building host and guest binaries:" + docker buildx bake host-binaries guest-binaries + + - name: Verify all artifacts + run: | + echo "Verifying build artifacts:" + ls -lh _output/ + echo "" + echo "Kernel:" + file _output/nerdbox-kernel-${{ matrix.arch }} + echo "" + echo "Initrd:" + file _output/nerdbox-initrd + echo "" + echo "Shim:" + file _output/containerd-shim-nerdbox-v1 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: '.github/.tool-versions' + + - name: Compile integration test binary + run: go test -c -o _output/integration.test ./integration + + - name: Allow testuser to traverse to workspace + run: sudo chmod a+x /home/runner + + - name: Run integration tests as non-root + run: | + sudo -u testuser \ + env "PATH=$(pwd)/_output:$PATH" \ + "HOME=/home/testuser" \ + _output/integration.test -test.v From 2c07902cd53142e1c8a38357b49f1b866af79317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 18 Mar 2026 16:51:57 +0100 Subject: [PATCH 2/3] integration: Simplify TestMain PATH setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous logic prepended "../_output" and "." relative to the executable directory. This was fragile and only worked correctly when the binary was run from specific locations (test.sh from integration/). When run via "go test" the binary is in a temp dir where those relative paths are meaningless, and it only worked because PATH was already set externally via $GITHUB_PATH. Simplify to just prepend the executable's own directory to PATH. Since all artifacts (libkrun.so, kernel, initrd) are built into _output/ alongside the test binary, this handles all execution methods: - test.sh (binary at ../_output/ relative to integration/) - go test (relies on PATH already containing _output/) - pre-compiled binary run directly from _output/ Signed-off-by: Paweł Gronowski --- integration/main_test.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/integration/main_test.go b/integration/main_test.go index 68f2769..81406cd 100644 --- a/integration/main_test.go +++ b/integration/main_test.go @@ -32,25 +32,18 @@ func TestMain(m *testing.M) { if err != nil { log.Fatalf("Failed to get executable path: %v", err) } + + // Prepend the directory containing the test binary to PATH so that + // NewInstance can find libkrun.so, the kernel, and initrd located + // alongside the binary (the _output/ directory). exeDir := filepath.Dir(e) paths := filepath.SplitList(os.Getenv("PATH")) - for _, p := range []string{ - "../_output", - ".", - } { - absPath := filepath.Clean(filepath.Join(exeDir, p)) - // Prepend to slice - paths = append(paths, "") - copy(paths[1:], paths) - paths[0] = absPath - } + paths = append([]string{exeDir}, paths...) if err := os.Setenv("PATH", strings.Join(paths, string(filepath.ListSeparator))); err != nil { log.Fatalf("Failed to set PATH environment variable: %v", err) } - r := m.Run() - - os.Exit(r) + os.Exit(m.Run()) } func runWithVM(t *testing.T, runTest func(*testing.T, vm.Instance)) { From 35cccaba49c0631b1aef1d020a18b2f9c1b263cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 19 Mar 2026 11:54:10 +0100 Subject: [PATCH 3/3] integration: Add test for shim start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestShimStart that invokes the real containerd-shim-nerdbox-v1 binary with the "start" subcommand. This exercises the shim manager's Start() code path end-to-end, including mount namespace setup. Signed-off-by: Paweł Gronowski --- integration/shim_start_test.go | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 integration/shim_start_test.go diff --git a/integration/shim_start_test.go b/integration/shim_start_test.go new file mode 100644 index 0000000..25a3746 --- /dev/null +++ b/integration/shim_start_test.go @@ -0,0 +1,94 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package integration + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" +) + +const shimBinary = "containerd-shim-nerdbox-v1" + +// TestShimStart exercises the shim manager's Start() code path by invoking +// the real shim binary with the \"start\" subcommand. This is the same +// invocation containerd uses to launch a shim. +func TestShimStart(t *testing.T) { + shimPath, err := exec.LookPath(shimBinary) + if err != nil { + t.Skipf("%s not found on PATH: %v", shimBinary, err) + } + + bundleDir := t.TempDir() + socketDir := t.TempDir() + + // Minimal OCI config.json — Start() only reads annotations. + if err := os.WriteFile(filepath.Join(bundleDir, "config.json"), []byte(`{"annotations":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(shimPath, + "-namespace", "test", + "-id", "test-shim-start", + "-address", filepath.Join(socketDir, "containerd.sock"), + "start", + ) + cmd.Dir = bundleDir + cmd.Env = append(os.Environ(), + // The fork reads SHIM_SOCKET_DIR to set StartOpts.SocketDir so + // the shim creates its sockets in a writable directory instead + // of the default /run/containerd/s. + "SHIM_SOCKET_DIR="+socketDir, + ) + + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + t.Fatalf("shim start failed: %v\nstderr: %s", err, stderr) + } + + var params struct { + Version int `json:"version"` + Address string `json:"address"` + Protocol string `json:"protocol"` + } + if err := json.Unmarshal(out, ¶ms); err != nil { + t.Fatalf("failed to parse shim output: %v\nraw: %s", err, out) + } + if params.Address == "" { + t.Fatal("shim returned empty address") + } + t.Logf("shim started: version=%d protocol=%s address=%s", params.Version, params.Protocol, params.Address) + + // Clean up the child shim process that Start() spawned. + pidData, err := os.ReadFile(filepath.Join(bundleDir, "shim.pid")) + if err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))); err == nil { + syscall.Kill(pid, syscall.SIGKILL) + } + } +}