Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ jobs:
run: |
export HYPEMAN_TEST_PREWARM_DIR="$HOME/.cache/hypeman-ci/darwin-arm64"
make test
- name: Run VZ builder integration test
env:
HYPEMAN_RUN_BUILDER_INTEGRATION_TEST: "1"
run: |
# Self-hosted runners retain Docker layers across jobs. Reclaim them so
# the builder image and two VZ builds have predictable disk headroom.
docker system prune --all --force --volumes
PATH="/opt/homebrew/opt/e2fsprogs/sbin:$PATH" \
go test -count=1 -tags containers_image_openpgp \
-run='^TestBuilderPersistentCacheReuse$' -timeout=20m -v ./integration
- name: Cleanup
if: always()
run: |
Expand All @@ -284,7 +294,25 @@ jobs:
go-version: '1.25.4'
cache: false
- name: Install dependencies
run: brew list caddy &>/dev/null || brew install caddy
run: |
brew list caddy &>/dev/null || brew install caddy
if ! docker info >/dev/null 2>&1; then
colima start || {
colima stop --force || true
colima start
}
fi
for attempt in {1..30}; do
if docker info >/dev/null 2>&1; then
break
fi
echo "waiting for Docker daemon (${attempt}/30)"
sleep 2
done
docker info >/dev/null
# Self-hosted runners retain Docker layers across jobs. Start the
# install E2E with deterministic headroom for its builder image.
docker system prune --all --force --volumes
- name: Run E2E install test
run: bash scripts/e2e-install-test.sh
- name: Run E2E CLI-only install test
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,8 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries

# macOS tests (no sudo needed, adds e2fsprogs to PATH)
# Uses 'go list' to discover compilable packages, then filters out packages
# whose test files reference Linux-only symbols (network, devices, system/init).
DARWIN_EXCLUDE_PKGS := /lib/network|/lib/devices|/lib/system/init|/cmd/vz-shim
# whose test files reference Linux-only symbols (devices, system/init).
DARWIN_EXCLUDE_PKGS := /lib/devices|/lib/system/init|/cmd/vz-shim
test-darwin: build-embedded sign-vz-shim
@VERBOSE_FLAG=""; \
if [ -n "$(VERBOSE)" ]; then VERBOSE_FLAG="-v"; fi; \
Expand Down
19 changes: 18 additions & 1 deletion cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ import (
"golang.org/x/sync/errgroup"
)

func timeoutNonStreamingRequests(timeout time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
timeoutHandler := middleware.Timeout(timeout)(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/logs") || strings.HasSuffix(r.URL.Path, "/events") {
next.ServeHTTP(w, r)
return
}
timeoutHandler.ServeHTTP(w, r)
})
}
}

func main() {
if err := run(); err != nil {
slog.Error("application terminated", "error", err)
Expand Down Expand Up @@ -481,7 +494,11 @@ func run() error {
})
}

r.Use(middleware.Timeout(60 * time.Second))
// Streaming endpoints can remain active for longer than the request timeout.
// In particular, cold builds routinely exceed 60 seconds while continuing
// to emit events; cancelling the SSE request makes the CLI report failure
// even though the build is still running.
r.Use(timeoutNonStreamingRequests(60 * time.Second))

// OpenAPI request validation with authentication
validatorOptions := &nethttpmiddleware.Options{
Expand Down
27 changes: 27 additions & 0 deletions cmd/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ import (

const testJWTSecret = "test-secret-key"

func TestRequestTimeoutSkipsStreamingEndpoints(t *testing.T) {
for _, path := range []string{"/instances/test/logs", "/builds/test/events"} {
t.Run(path, func(t *testing.T) {
handler := timeoutNonStreamingRequests(10 * time.Millisecond)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
return
case <-time.After(30 * time.Millisecond):
w.WriteHeader(http.StatusNoContent)
}
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusNoContent, recorder.Code)
})
}
}

func TestRequestTimeoutStillAppliesToRegularEndpoints(t *testing.T) {
handler := timeoutNonStreamingRequests(10 * time.Millisecond)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
assert.Equal(t, http.StatusGatewayTimeout, recorder.Code)
}

func generateValidJWT(userID string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
Expand Down
2 changes: 1 addition & 1 deletion cmd/api/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 12 additions & 7 deletions config.example.darwin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# Key differences from Linux (see config.example.yaml):
# - hypervisor.default: Use "vz" (Virtualization.framework) instead of cloud-hypervisor/qemu
# - data_dir: Uses macOS conventions (~/Library/Application Support)
# - Network settings: network.bridge_name, subnet_cidr, etc. are IGNORED (vz uses NAT)
# - Network settings: network.bridge_name, subnet_cidr, etc. are IGNORED (Hypeman targets VZ's default 192.168.64.0/24 shared NAT)
# - Rate limiting: Not supported on macOS (no tc/HTB equivalent)
# - GPU passthrough: Not supported on macOS
# =============================================================================
Expand Down Expand Up @@ -53,10 +53,14 @@ hypervisor:
# =============================================================================
# Network Configuration (DIFFERENT ON MACOS)
# =============================================================================
# On macOS with vz, network is handled automatically via NAT:
# - VMs get IP addresses from 192.168.64.0/24 via DHCP
# - No TAP devices, bridges, or iptables needed
# - The following settings are IGNORED on macOS:
# On macOS with VZ, Hypeman targets Virtualization.framework's default shared NAT:
# - Hypeman statically allocates VM addresses from 192.168.64.0/24
# - The expected host gateway is 192.168.64.1
# - A host-level vmnet Shared_Net_Address override is not currently supported
# - Other vmnet clients can receive DHCP leases in the same subnet; avoid running
# overlapping VM workloads when static-address collisions are possible
# - No TAP devices, user-created bridges, or iptables rules are needed
# - The following settings are IGNORED on macOS because VZ owns the network:
# network.bridge_name, subnet_cidr, subnet_gateway, uplink_interface
network:
dns_server: 8.8.8.8
Expand Down Expand Up @@ -98,8 +102,9 @@ caddy:
# =============================================================================
# Build System Configuration
# =============================================================================
# For builds on macOS with vz, the registry URL needs to be accessible from
# NAT VMs. Since vz uses 192.168.64.0/24 for NAT, the host is at 192.168.64.1.
# For builds on macOS with VZ, the registry URL needs to be accessible from
# NAT VMs. On the default shared NAT targeted by Hypeman, the host is at
# 192.168.64.1.
#
# IMPORTANT: "host.docker.internal" does NOT work in vz VMs - that's a Docker
# Desktop-specific hostname. Use the NAT gateway IP instead.
Expand Down
93 changes: 93 additions & 0 deletions integration/builder_cache_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//go:build darwin

package integration

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/kernel/hypeman/cmd/api/config"
"github.com/kernel/hypeman/lib/hypervisor"
)

func requireBuilderIntegrationHost(t *testing.T) {
t.Helper()
if os.Getenv("HYPEMAN_RUN_BUILDER_INTEGRATION_TEST") != "1" {
t.Skip("set HYPEMAN_RUN_BUILDER_INTEGRATION_TEST=1 to run the VZ builder integration test")
}
if runtime.GOARCH != "arm64" {
t.Skip("VZ builder integration test requires Apple Silicon")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("builder integration test requires Docker")
}
if err := exec.Command("docker", "info").Run(); err != nil {
t.Skip("builder integration test requires a running Docker daemon")
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

func builderIntegrationDataDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "hb-")
if err != nil {
t.Fatalf("create short builder integration data directory: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}

func builderIntegrationPlatformConfig(t *testing.T) (config.NetworkConfig, hypervisor.Type) {
t.Helper()
// Deliberately supply the Linux-shaped defaults from issue #358. VZ must
// still use its platform-effective shared NAT for the builder VM and registry.
return config.NetworkConfig{
BridgeName: "vmbr0",
SubnetCIDR: "10.100.0.0/16",
SubnetGateway: "10.100.0.1",
DNSServer: "8.8.8.8",
}, hypervisor.TypeVZ
}

func builderIntegrationDockerSocket(t *testing.T) string {
t.Helper()
if socket := unixDockerSocket(os.Getenv("DOCKER_HOST")); socket != "" {
return socket
}
if output, err := exec.Command("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output(); err == nil {
if socket := unixDockerSocket(strings.TrimSpace(string(output))); socket != "" {
return socket
}
}
home, _ := os.UserHomeDir()
for _, candidate := range []string{
"/var/run/docker.sock",
filepath.Join(home, ".colima", "default", "docker.sock"),
filepath.Join(home, ".docker", "run", "docker.sock"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
t.Fatal("builder integration test requires a local Docker Unix socket")
return ""
}

func unixDockerSocket(host string) string {
if strings.HasPrefix(host, "unix://") {
return strings.TrimPrefix(host, "unix://")
}
if strings.HasPrefix(host, "/") {
return host
}
return ""
}

func prepareBuilderIntegrationRegistryAccess(t *testing.T, bridge string) {
t.Helper()
// VZ's shared NAT can reach host listeners through its gateway without a
// host firewall rule managed by Hypeman.
}
Loading
Loading