Skip to content

Commit d072646

Browse files
Copilotgh-aw-bot
andauthored
Add negative fixture tests and host preflight checks for cloud-hypervisor
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent 49972f5 commit d072646

3 files changed

Lines changed: 252 additions & 3 deletions

File tree

actions/setup/sh/cloud_hypervisor_host_preflight.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ set +o histexpand
88
# - GitHub-hosted runners only
99
# - Ubuntu Linux x86_64 only
1010
# - /dev/kvm must be present
11+
# - gh, rsync, and docker host tools must be present, with a usable Docker Engine
12+
# - a usable cgroup v2 hierarchy must be present
13+
# - the running kernel must report Landlock LSM support
1114

1215
set -euo pipefail
1316

@@ -43,5 +46,32 @@ if ! test -c /dev/kvm; then
4346
exit 1
4447
fi
4548

49+
for tool in gh rsync docker; do
50+
if ! command -v "${tool}" >/dev/null 2>&1; then
51+
echo "::error::required host tool is missing: ${tool}. AWF's cloud-hypervisor runtime needs gh (attested artifact verification), rsync (guest rootfs staging), and docker (infrastructure containers)."
52+
exit 1
53+
fi
54+
done
55+
56+
if ! docker info >/dev/null 2>&1; then
57+
echo "::error::a host-visible Docker Engine is required for cloud-hypervisor's infrastructure containers."
58+
exit 1
59+
fi
60+
61+
if [[ ! -r /sys/fs/cgroup/cgroup.controllers ]]; then
62+
echo "::error::a usable cgroup v2 hierarchy is required to bound the cloud-hypervisor process (/sys/fs/cgroup/cgroup.controllers is unreadable)."
63+
exit 1
64+
fi
65+
66+
if [[ -r /sys/kernel/security/lsm ]]; then
67+
if ! grep -Fq landlock /sys/kernel/security/lsm; then
68+
echo "::error::the running kernel does not report Landlock in /sys/kernel/security/lsm, which the cloud-hypervisor launcher requires for filesystem confinement."
69+
exit 1
70+
fi
71+
else
72+
echo "::error::/sys/kernel/security/lsm is unavailable; cannot confirm Landlock LSM support required by the cloud-hypervisor launcher."
73+
exit 1
74+
fi
75+
4676
echo "runner is eligible for cloud-hypervisor preview"
4777
echo "::endgroup::"

docs/src/content/docs/reference/agent-runtimes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,13 +283,13 @@ Preview scope is intentionally narrow:
283283

284284
- GitHub-hosted runners only (`RUNNER_ENVIRONMENT=github-hosted`).
285285
- Ubuntu Linux x86_64 only (`RUNNER_OS=Linux`, `RUNNER_ARCH=X64`, `ImageOS=ubuntu*`).
286-
- `/dev/kvm` must be present.
286+
- `/dev/kvm` must be present, and the host must have `gh`, `rsync`, and Docker (with a running Docker Engine), a usable cgroup v2 hierarchy, and a kernel that reports Landlock LSM support.
287287
- `runner.topology: arc-dind` is not supported.
288288
- `tools.github.mode: gh-proxy` and the `integrity-reactions` feature are not supported: the CLI proxy sidecar is not attached to the isolated topology.
289289
- `sandbox.agent.allow-host-ports` and GitHub Actions `services:` with published ports are not supported: host access requires `sandbox.agent.runtime: docker-sudo-iptables`.
290290
- `enclaves` configuration is not supported.
291291

292-
The compiler grants only the runner user read/write access to `/dev/kvm`, then emits host preflight and release-asset provisioning steps before AWF runs. Provisioning downloads the Cloud Hypervisor bundle, `SHA256SUMS`, and `manifest.json` from the pinned `gh-aw-firewall` release, verifies checksums, and feeds AWF digest-pinned flags for the Cloud Hypervisor binary, `virtiofsd`, kernel, rootfs, and supervisor.
292+
The compiler grants only the runner user read/write access to `/dev/kvm`, then emits host preflight and release-asset provisioning steps before AWF runs. Host preflight fails closed unless `/dev/kvm`, `gh`, `rsync`, Docker, a usable cgroup v2 hierarchy, and Landlock LSM support are all present. Provisioning downloads the Cloud Hypervisor guest archive together with its attested `manifest.json` and Sigstore `manifest.sigstore.jsonl` bundle from the pinned `gh-aw-firewall` release, validates the release identity, artifact names, and bundle structure, and feeds AWF the manifest path, bundle path, and exact release tag so AWF can perform the authoritative offline Sigstore/provenance verification for the Cloud Hypervisor binary, `virtiofsd`, kernel, rootfs, and supervisor.
293293

294294
AWF launches with host privileges required to create the VM, but the runtime remains in strict network-isolation mode. The guest defaults to 2 vCPUs and 4096 MiB of memory. Its trusted topology attachment is limited to the MCP gateway on TCP 8080; the CLI proxy is not attached.
295295

pkg/workflow/cloud_hypervisor_test.go

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
package workflow
44

55
import (
6+
"archive/tar"
7+
"bytes"
8+
"compress/gzip"
9+
"fmt"
610
"os"
11+
"os/exec"
712
"path/filepath"
813
"strings"
914
"testing"
@@ -440,7 +445,7 @@ func TestCloudHypervisorShellScriptContent(t *testing.T) {
440445
},
441446
{
442447
script: "cloud_hypervisor_host_preflight.sh",
443-
contains: []string{"RUNNER_ENVIRONMENT", "github-hosted", "ImageOS", "/dev/kvm", "test -c /dev/kvm", "cloud-hypervisor preview"},
448+
contains: []string{"RUNNER_ENVIRONMENT", "github-hosted", "ImageOS", "/dev/kvm", "test -c /dev/kvm", "cloud-hypervisor preview", "gh rsync docker", "docker info", "/sys/fs/cgroup/cgroup.controllers", "landlock", "/sys/kernel/security/lsm"},
444449
},
445450
{
446451
script: "cloud_hypervisor_setup_bundle.sh",
@@ -458,3 +463,217 @@ func TestCloudHypervisorShellScriptContent(t *testing.T) {
458463
})
459464
}
460465
}
466+
467+
const cloudHypervisorFixtureReleaseTag = "v9.9.9"
468+
469+
// cloudHypervisorFixtureManifest returns a manifest JSON document that
470+
// satisfies the jq contract enforced by cloud_hypervisor_setup_bundle.sh,
471+
// for the given release tag.
472+
func cloudHypervisorFixtureManifest(releaseTag string) string {
473+
sha := strings.Repeat("b", 64)
474+
return fmt.Sprintf(`{
475+
"schemaVersion": 1,
476+
"release": {
477+
"repository": "github/gh-aw-firewall",
478+
"workflow": "github/gh-aw-firewall/.github/workflows/release.yml",
479+
"tag": %q,
480+
"sourceCommit": "%s"
481+
},
482+
"architecture": "x86_64",
483+
"artifacts": {
484+
"cloudHypervisor": {"file": "cloud-hypervisor", "sha256": "%s"},
485+
"virtiofsd": {"file": "virtiofsd", "sha256": "%s"},
486+
"kernel": {"file": "vmlinux.bin", "sha256": "%s"},
487+
"rootfs": {"file": "rootfs.ext4", "sha256": "%s"},
488+
"supervisor": {"file": "awf-supervisor", "sha256": "%s"}
489+
}
490+
}`, releaseTag, strings.Repeat("a", 40), sha, sha, sha, sha, sha)
491+
}
492+
493+
const cloudHypervisorFixtureBundle = `{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{}}` + "\n"
494+
495+
// buildCloudHypervisorFixtureArchive builds a valid tar.gz guest archive
496+
// containing the five artifact files cloud_hypervisor_setup_bundle.sh
497+
// requires after extraction.
498+
func buildCloudHypervisorFixtureArchive(t *testing.T) []byte {
499+
t.Helper()
500+
var buf bytes.Buffer
501+
gz := gzip.NewWriter(&buf)
502+
tw := tar.NewWriter(gz)
503+
for _, name := range []string{"cloud-hypervisor", "virtiofsd", "vmlinux.bin", "rootfs.ext4", "awf-supervisor"} {
504+
content := []byte("fixture-content-" + name)
505+
require.NoError(t, tw.WriteHeader(&tar.Header{
506+
Name: name,
507+
Mode: 0o755,
508+
Size: int64(len(content)),
509+
}))
510+
_, err := tw.Write(content)
511+
require.NoError(t, err)
512+
}
513+
require.NoError(t, tw.Close())
514+
require.NoError(t, gz.Close())
515+
return buf.Bytes()
516+
}
517+
518+
// writeCloudHypervisorCurlShim installs a fake curl on PATH that copies
519+
// files out of fixtureDir instead of performing a network request, keyed by
520+
// the requested URL's basename. A "<basename>.symlink-target" sidecar file
521+
// makes the shim create a symlink pointing at its contents instead of
522+
// copying a regular file, simulating a substituted/tampered artifact. A
523+
// missing fixture makes the shim fail closed like a real curl 404.
524+
func writeCloudHypervisorCurlShim(t *testing.T, fixtureDir string) string {
525+
t.Helper()
526+
binDir := t.TempDir()
527+
script := "#!/usr/bin/env bash\n" +
528+
"set -euo pipefail\n" +
529+
"outfile=\"\"\n" +
530+
"prev=\"\"\n" +
531+
"for arg in \"$@\"; do\n" +
532+
" if [[ \"$prev\" == \"-o\" ]]; then\n" +
533+
" outfile=\"$arg\"\n" +
534+
" fi\n" +
535+
" prev=\"$arg\"\n" +
536+
"done\n" +
537+
"url=\"${!#}\"\n" +
538+
"name=\"$(basename \"$url\")\"\n" +
539+
"src=\"" + fixtureDir + "/${name}\"\n" +
540+
"if [[ -f \"${src}.symlink-target\" ]]; then\n" +
541+
" ln -sf \"$(cat \"${src}.symlink-target\")\" \"$outfile\"\n" +
542+
" exit 0\n" +
543+
"fi\n" +
544+
"if [[ ! -e \"$src\" ]]; then\n" +
545+
" exit 22\n" +
546+
"fi\n" +
547+
"cp \"$src\" \"$outfile\"\n"
548+
shimPath := filepath.Join(binDir, "curl")
549+
require.NoError(t, os.WriteFile(shimPath, []byte(script), 0o755))
550+
return binDir
551+
}
552+
553+
// runCloudHypervisorSetupBundleScript executes the real
554+
// cloud_hypervisor_setup_bundle.sh script against fixtureDir via the curl
555+
// shim, so the script's own validation logic runs end-to-end.
556+
func runCloudHypervisorSetupBundleScript(t *testing.T, fixtureDir string) (string, error) {
557+
t.Helper()
558+
wd, err := os.Getwd()
559+
require.NoError(t, err)
560+
scriptPath, err := filepath.Abs(filepath.Join(wd, "..", "..", "actions", "setup", "sh", "cloud_hypervisor_setup_bundle.sh"))
561+
require.NoError(t, err)
562+
563+
binDir := writeCloudHypervisorCurlShim(t, fixtureDir)
564+
runnerTemp := t.TempDir()
565+
566+
cmd := exec.Command("bash", scriptPath)
567+
cmd.Env = append(os.Environ(),
568+
"RUNNER_TEMP="+runnerTemp,
569+
"GH_AW_AWF_VERSION="+cloudHypervisorFixtureReleaseTag,
570+
"PATH="+binDir+":"+os.Getenv("PATH"),
571+
)
572+
out, err := cmd.CombinedOutput()
573+
return string(out), err
574+
}
575+
576+
func TestCloudHypervisorSetupBundleScriptExecutesAgainstFixtures(t *testing.T) {
577+
validManifest := cloudHypervisorFixtureManifest(cloudHypervisorFixtureReleaseTag)
578+
validArchive := buildCloudHypervisorFixtureArchive(t)
579+
archiveName := "cloud-hypervisor-test-x86_64.tar.gz"
580+
manifestName := "cloud-hypervisor-test-x86_64.manifest.json"
581+
bundleName := "cloud-hypervisor-test-x86_64.manifest.sigstore.jsonl"
582+
583+
t.Run("valid fixtures succeed", func(t *testing.T) {
584+
dir := t.TempDir()
585+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
586+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
587+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
588+
589+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
590+
require.NoError(t, err, out)
591+
assert.Contains(t, out, "cloud-hypervisor bundle prepared")
592+
})
593+
594+
t.Run("missing manifest fails closed", func(t *testing.T) {
595+
dir := t.TempDir()
596+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
597+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
598+
599+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
600+
require.Error(t, err, out)
601+
})
602+
603+
t.Run("missing bundle fails closed", func(t *testing.T) {
604+
dir := t.TempDir()
605+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
606+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
607+
608+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
609+
require.Error(t, err, out)
610+
})
611+
612+
t.Run("malformed manifest json rejected", func(t *testing.T) {
613+
dir := t.TempDir()
614+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
615+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(`{"schemaVersion": 1, "not": "a valid manifest"}`), 0o644))
616+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
617+
618+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
619+
require.Error(t, err, out)
620+
assert.Contains(t, out, "does not match the cloud-hypervisor release bundle contract")
621+
})
622+
623+
t.Run("release tag mismatch rejected", func(t *testing.T) {
624+
dir := t.TempDir()
625+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
626+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(cloudHypervisorFixtureManifest("v1.0.0")), 0o644))
627+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
628+
629+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
630+
require.Error(t, err, out)
631+
assert.Contains(t, out, "does not match the cloud-hypervisor release bundle contract")
632+
})
633+
634+
t.Run("substituted manifest symlink rejected", func(t *testing.T) {
635+
dir := t.TempDir()
636+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
637+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
638+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName+".symlink-target"), []byte("/etc/passwd"), 0o644))
639+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
640+
641+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
642+
require.Error(t, err, out)
643+
assert.Contains(t, out, "does not match the cloud-hypervisor release bundle contract")
644+
})
645+
646+
t.Run("malformed bundle jsonl rejected", func(t *testing.T) {
647+
dir := t.TempDir()
648+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
649+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
650+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte("not-json\n"), 0o644))
651+
652+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
653+
require.Error(t, err, out)
654+
assert.Contains(t, out, "is missing or malformed")
655+
})
656+
657+
t.Run("empty bundle rejected", func(t *testing.T) {
658+
dir := t.TempDir()
659+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
660+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
661+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(""), 0o644))
662+
663+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
664+
require.Error(t, err, out)
665+
assert.Contains(t, out, "is missing or malformed")
666+
})
667+
668+
t.Run("substituted bundle symlink rejected", func(t *testing.T) {
669+
dir := t.TempDir()
670+
require.NoError(t, os.WriteFile(filepath.Join(dir, archiveName), validArchive, 0o644))
671+
require.NoError(t, os.WriteFile(filepath.Join(dir, manifestName), []byte(validManifest), 0o644))
672+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName), []byte(cloudHypervisorFixtureBundle), 0o644))
673+
require.NoError(t, os.WriteFile(filepath.Join(dir, bundleName+".symlink-target"), []byte("/etc/passwd"), 0o644))
674+
675+
out, err := runCloudHypervisorSetupBundleScript(t, dir)
676+
require.Error(t, err, out)
677+
assert.Contains(t, out, "is missing or malformed")
678+
})
679+
}

0 commit comments

Comments
 (0)