From e66582facabd6a54fe4ca68e47feb116b0790148 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 17 May 2026 19:27:30 +0000 Subject: [PATCH 1/6] Add k6 activity ramp benchmark --- .gitignore | 1 + Makefile | 37 ++- benchmarks/k6/README.md | 38 +++ benchmarks/k6/activity-ramp.ts | 468 +++++++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 benchmarks/k6/README.md create mode 100644 benchmarks/k6/activity-ramp.ts diff --git a/.gitignore b/.gitignore index 614d5996..245e706f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ scripts/utm/images/ # Build artifacts /api +.bench/ diff --git a/Makefile b/Makefile index 611bb11b..0c64785a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash -.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build build-linux test test-linux test-darwin test-guestmemory-linux test-guestmemory-vz install-tools gen-jwt download-ch-binaries download-firecracker-binaries download-ch-spec ensure-ch-binaries ensure-firecracker-binaries build-caddy-binaries build-caddy ensure-caddy-binaries release-prep clean build-embedded +.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build build-linux test test-linux test-darwin test-guestmemory-linux test-guestmemory-vz install-tools gen-jwt download-ch-binaries download-firecracker-binaries download-ch-spec ensure-ch-binaries ensure-firecracker-binaries build-caddy-binaries build-caddy ensure-caddy-binaries release-prep clean build-embedded bench-activity-ramp # Directory where local binaries will be installed BIN_DIR ?= $(CURDIR)/bin @@ -15,6 +15,14 @@ AIR ?= $(BIN_DIR)/air WIRE ?= $(BIN_DIR)/wire XCADDY ?= $(BIN_DIR)/xcaddy TEST_TIMEOUT ?= $(GO_TEST_TIMEOUT) +K6 ?= k6 +K6_OUT_DIR ?= .bench/k6 +HYPEMAN_BASE_URL ?= http://127.0.0.1:8080 +HYPEMAN_IMAGE ?= docker.io/library/nginx:alpine +HYPEMAN_BENCH_MAX_VUS ?= 16 +HYPEMAN_BENCH_VU_STEP ?= 1 +HYPEMAN_BENCH_STAGE_DURATION ?= 2m +HYPEMAN_INGRESS_HOST_PORT ?= 8081 # Install oapi-codegen (pinned to match committed generated code) $(OAPI_CODEGEN): | $(BIN_DIR) @@ -289,6 +297,33 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) ./...; \ fi +bench-activity-ramp: + @if ! command -v $(K6) >/dev/null 2>&1; then \ + echo "k6 not found; install k6 or run with K6=/path/to/k6"; \ + exit 1; \ + fi + @if [ -z "$$HYPEMAN_API_KEY" ]; then \ + echo "HYPEMAN_API_KEY is required"; \ + exit 1; \ + fi + @mkdir -p $(K6_OUT_DIR) + K6_WEB_DASHBOARD=true \ + K6_WEB_DASHBOARD_PORT=-1 \ + K6_WEB_DASHBOARD_PERIOD=1s \ + K6_WEB_DASHBOARD_EXPORT=$(K6_OUT_DIR)/activity-ramp.html \ + $(K6) run \ + --summary-mode=full \ + --summary-trend-stats="avg,med,p(90),p(95),p(99),min,max" \ + --summary-export=$(K6_OUT_DIR)/activity-ramp-summary.json \ + -e HYPEMAN_BASE_URL="$(HYPEMAN_BASE_URL)" \ + -e HYPEMAN_API_KEY="$$HYPEMAN_API_KEY" \ + -e HYPEMAN_IMAGE="$(HYPEMAN_IMAGE)" \ + -e HYPEMAN_BENCH_MAX_VUS="$(HYPEMAN_BENCH_MAX_VUS)" \ + -e HYPEMAN_BENCH_VU_STEP="$(HYPEMAN_BENCH_VU_STEP)" \ + -e HYPEMAN_BENCH_STAGE_DURATION="$(HYPEMAN_BENCH_STAGE_DURATION)" \ + -e HYPEMAN_INGRESS_HOST_PORT="$(HYPEMAN_INGRESS_HOST_PORT)" \ + benchmarks/k6/activity-ramp.ts + # 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). diff --git a/benchmarks/k6/README.md b/benchmarks/k6/README.md new file mode 100644 index 00000000..2fbbf6de --- /dev/null +++ b/benchmarks/k6/README.md @@ -0,0 +1,38 @@ +# k6 benchmarks + +This directory contains TypeScript k6 benchmarks for a running Hypeman API. + +## Activity ramp + +`activity-ramp.ts` runs a closed workload where each virtual user repeatedly: + +1. creates an instance from a ready image, +2. waits for it to reach `Running`, +3. sends an HTTP probe through a shared pattern ingress, +4. deletes the instance. + +The default ramp increases concurrency by one virtual user every two minutes up to 16 virtual users. Tune the run with environment variables: + +```sh +export HYPEMAN_API_KEY=... +make bench-activity-ramp \ + HYPEMAN_BASE_URL=http://127.0.0.1:8080 \ + HYPEMAN_IMAGE=docker.io/library/nginx:alpine \ + HYPEMAN_BENCH_MAX_VUS=16 +``` + +The Make target writes: + +- `.bench/k6/activity-ramp.html` +- `.bench/k6/activity-ramp-summary.json` + +The benchmark creates a shared ingress named `bench-activity-ramp` if one does not already exist. By default it listens on host port `8081`, matches `{instance}.hypeman-bench.local`, and targets port `80` on each instance. Override the probe path with: + +```sh +HYPEMAN_PROBE_URL=http://host:8081/ +HYPEMAN_PROBE_HOST_SUFFIX=.hypeman-bench.local +HYPEMAN_INGRESS_HOST_PORT=8081 +HYPEMAN_INGRESS_TARGET_PORT=80 +``` + +Capacity rejections from `POST /instances` are recorded as `hypeman_create_rejected` and `hypeman_create_rejections`. They are not treated as unexpected script errors because they identify the concurrency level where the server starts refusing new activity. diff --git a/benchmarks/k6/activity-ramp.ts b/benchmarks/k6/activity-ramp.ts new file mode 100644 index 00000000..ac79c1be --- /dev/null +++ b/benchmarks/k6/activity-ramp.ts @@ -0,0 +1,468 @@ +import { check, fail, sleep } from 'k6'; +import exec from 'k6/execution'; +import http, { RefinedResponse, ResponseType } from 'k6/http'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +type Tags = Record; + +interface Config { + baseUrl: string; + apiKey: string; + image: string; + runId: string; + startVUs: number; + maxVUs: number; + vuStep: number; + stageDuration: string; + gracefulRampDown: string; + instanceMemory: string; + instanceOverlaySize: string; + instanceVCPUs: number; + waitTimeoutSeconds: number; + probeUrl: string; + probePath: string; + probeHostSuffix: string; + probeAttempts: number; + probeIntervalSeconds: number; + ingressName: string; + ingressHostPattern: string; + ingressHostPort: number; + ingressTargetPort: number; + imageReadyTimeoutSeconds: number; +} + +const createMs = new Trend('hypeman_create_instance_ms', true); +const waitRunningMs = new Trend('hypeman_wait_running_ms', true); +const probeReadyMs = new Trend('hypeman_probe_ready_ms', true); +const probeHTTPMs = new Trend('hypeman_probe_http_ms', true); +const deleteMs = new Trend('hypeman_delete_instance_ms', true); +const activityMs = new Trend('hypeman_activity_total_ms', true); +const activityOk = new Rate('hypeman_activity_ok'); +const cleanupOk = new Rate('hypeman_cleanup_ok'); +const createRejected = new Rate('hypeman_create_rejected'); +const createRejections = new Counter('hypeman_create_rejections'); +const probeOk = new Rate('hypeman_probe_ok'); + +const config = loadConfig(); + +export const options = { + setupTimeout: '15m', + teardownTimeout: '10m', + scenarios: { + activity_ramp: { + executor: 'ramping-vus', + startVUs: config.startVUs, + stages: rampStages(config), + gracefulRampDown: config.gracefulRampDown, + }, + }, + thresholds: { + hypeman_cleanup_ok: ['rate>0.95'], + hypeman_probe_ok: ['rate>0.80'], + }, +}; + +export function setup() { + checkRequiredConfig(config); + ensureHealthy(); + ensureImageReady(config.image); + ensurePatternIngress(); + + return { + runId: config.runId, + }; +} + +export default function (data: { runId: string }) { + const iterationStart = Date.now(); + const instanceName = instanceNameFor(data.runId); + const tags: Tags = { + benchmark: 'activity-ramp', + run_id: data.runId, + instance: instanceName, + }; + let ok = false; + let created = false; + + try { + created = createInstance(instanceName, tags); + if (!created) { + return; + } + waitForRunning(instanceName, tags); + probeInstance(instanceName, tags); + ok = true; + } finally { + if (created) { + const deleted = deleteInstance(instanceName, tags); + cleanupOk.add(deleted, tags); + } + activityOk.add(ok, tags); + activityMs.add(Date.now() - iterationStart, tags); + } +} + +export function teardown(data: { runId: string }) { + cleanupRunInstances(data.runId); +} + +function loadConfig(): Config { + const baseUrl = trimRight(requiredEnv('HYPEMAN_BASE_URL', 'http://127.0.0.1:8080'), '/'); + const ingressHostPort = intEnv('HYPEMAN_INGRESS_HOST_PORT', 8081); + + return { + baseUrl, + apiKey: requiredEnv('HYPEMAN_API_KEY', ''), + image: requiredEnv('HYPEMAN_IMAGE', 'docker.io/library/nginx:alpine'), + runId: envString('HYPEMAN_BENCH_RUN_ID', defaultRunId()), + startVUs: intEnv('HYPEMAN_BENCH_START_VUS', 1), + maxVUs: intEnv('HYPEMAN_BENCH_MAX_VUS', 16), + vuStep: intEnv('HYPEMAN_BENCH_VU_STEP', 1), + stageDuration: envString('HYPEMAN_BENCH_STAGE_DURATION', '2m'), + gracefulRampDown: envString('HYPEMAN_BENCH_GRACEFUL_RAMP_DOWN', '10m'), + instanceMemory: envString('HYPEMAN_INSTANCE_MEMORY', '512MB'), + instanceOverlaySize: envString('HYPEMAN_INSTANCE_OVERLAY_SIZE', '2GB'), + instanceVCPUs: intEnv('HYPEMAN_INSTANCE_VCPUS', 1), + waitTimeoutSeconds: durationSeconds(envString('HYPEMAN_WAIT_TIMEOUT', '5m')), + probeUrl: trimRight(envString('HYPEMAN_PROBE_URL', probeURLFromBaseURL(baseUrl, ingressHostPort)), '/'), + probePath: envString('HYPEMAN_PROBE_PATH', '/'), + probeHostSuffix: envString('HYPEMAN_PROBE_HOST_SUFFIX', '.hypeman-bench.local'), + probeAttempts: intEnv('HYPEMAN_PROBE_ATTEMPTS', 30), + probeIntervalSeconds: floatEnv('HYPEMAN_PROBE_INTERVAL_SECONDS', 1), + ingressName: envString('HYPEMAN_INGRESS_NAME', 'bench-activity-ramp'), + ingressHostPattern: envString('HYPEMAN_INGRESS_HOST_PATTERN', '{instance}.hypeman-bench.local'), + ingressHostPort, + ingressTargetPort: intEnv('HYPEMAN_INGRESS_TARGET_PORT', 80), + imageReadyTimeoutSeconds: intEnv('HYPEMAN_IMAGE_READY_TIMEOUT_SECONDS', 600), + }; +} + +function rampStages(cfg: Config): Array<{ duration: string; target: number }> { + const stages: Array<{ duration: string; target: number }> = []; + for (let target = cfg.startVUs + cfg.vuStep; target <= cfg.maxVUs; target += cfg.vuStep) { + stages.push({ duration: cfg.stageDuration, target }); + } + if (stages.length === 0 || stages[stages.length - 1].target !== cfg.maxVUs) { + stages.push({ duration: cfg.stageDuration, target: cfg.maxVUs }); + } + stages.push({ duration: cfg.stageDuration, target: 0 }); + return stages; +} + +function checkRequiredConfig(cfg: Config) { + if (!cfg.apiKey) { + fail('HYPEMAN_API_KEY is required'); + } + if (cfg.maxVUs < cfg.startVUs) { + fail('HYPEMAN_BENCH_MAX_VUS must be greater than or equal to HYPEMAN_BENCH_START_VUS'); + } + if (cfg.vuStep < 1) { + fail('HYPEMAN_BENCH_VU_STEP must be at least 1'); + } +} + +function ensureHealthy() { + const res = apiGet('/health', { kind: 'setup', step: 'health' }); + assertStatus(res, [200], 'health check'); +} + +function ensureImageReady(image: string) { + const encoded = encodeURIComponent(image); + let res = apiGet(`/images/${encoded}`, { kind: 'setup', step: 'image-get' }); + if (res.status === 404) { + res = apiPost('/images', { name: image }, { kind: 'setup', step: 'image-create' }); + assertStatus(res, [202], 'create image'); + } else { + assertStatus(res, [200], 'get image'); + } + + const deadline = Date.now() + config.imageReadyTimeoutSeconds * 1000; + while (Date.now() < deadline) { + const imageRes = apiGet(`/images/${encoded}`, { kind: 'setup', step: 'image-poll' }); + assertStatus(imageRes, [200], 'poll image'); + const imageBody = imageRes.json() as { status?: string; error?: string }; + + if (imageBody.status === 'ready') { + return; + } + if (imageBody.status === 'failed') { + fail(`image ${image} failed to become ready: ${imageBody.error || 'unknown error'}`); + } + sleep(2); + } + + fail(`image ${image} did not become ready before ${config.imageReadyTimeoutSeconds}s`); +} + +function ensurePatternIngress() { + const encoded = encodeURIComponent(config.ingressName); + const existing = apiGet(`/ingresses/${encoded}`, { kind: 'setup', step: 'ingress-get' }); + if (existing.status === 200) { + return; + } + if (existing.status !== 404) { + assertStatus(existing, [200, 404], 'get ingress'); + } + + const created = apiPost('/ingresses', { + name: config.ingressName, + tags: { benchmark: 'activity-ramp' }, + rules: [{ + match: { + hostname: config.ingressHostPattern, + port: config.ingressHostPort, + }, + target: { + instance: '{instance}', + port: config.ingressTargetPort, + }, + tls: false, + redirect_http: false, + }], + }, { kind: 'setup', step: 'ingress-create' }); + + assertStatus(created, [201, 409], 'create ingress'); +} + +function createInstance(name: string, tags: Tags): boolean { + const started = Date.now(); + const res = apiPost('/instances', { + name, + image: config.image, + size: config.instanceMemory, + overlay_size: config.instanceOverlaySize, + vcpus: config.instanceVCPUs, + network: { enabled: true }, + tags: { + benchmark: 'activity-ramp', + run_id: config.runId, + }, + skip_kernel_headers: true, + }, tagStep(tags, 'create')); + + createMs.add(Date.now() - started, tags); + check(res, { + [`create instance ${name} accepted or capacity-rejected`]: (r) => r.status === 201 || r.status === 409, + }); + if (res.status === 201) { + createRejected.add(false, tags); + return true; + } + if (res.status === 409) { + createRejected.add(true, tags); + createRejections.add(1, tags); + return false; + } + + fail(`create instance ${name} failed with status ${res.status}: ${String(res.body).slice(0, 500)}`); +} + +function waitForRunning(name: string, tags: Tags) { + const started = Date.now(); + const path = `/instances/${encodeURIComponent(name)}`; + const deadline = started + config.waitTimeoutSeconds * 1000; + + while (Date.now() < deadline) { + const res = apiGet(path, tagStep(tags, 'wait-running')); + assertStatus(res, [200], `get instance ${name}`); + + const body = res.json() as { state?: string; state_error?: string | null }; + if (body.state === 'Running') { + waitRunningMs.add(Date.now() - started, tags); + return; + } + if (body.state === 'Stopped' || body.state === 'Standby' || body.state === 'Shutdown' || body.state === 'Unknown') { + fail(`instance ${name} reached terminal state while waiting: state=${body.state} error=${body.state_error || ''}`); + } + sleep(1); + } + + waitRunningMs.add(Date.now() - started, tags); + fail(`instance ${name} did not reach Running before ${config.waitTimeoutSeconds}s`); +} + +function probeInstance(name: string, tags: Tags) { + const started = Date.now(); + const probeURL = `${config.probeUrl}${config.probePath.startsWith('/') ? config.probePath : `/${config.probePath}`}`; + const host = `${name}${config.probeHostSuffix}`; + + for (let attempt = 1; attempt <= config.probeAttempts; attempt += 1) { + const res = http.get(probeURL, { + headers: { Host: host }, + timeout: '30s', + tags: tagStep(tags, 'probe'), + }); + probeHTTPMs.add(res.timings.duration, tags); + + if (res.status >= 200 && res.status < 500) { + probeReadyMs.add(Date.now() - started, tags); + probeOk.add(true, tags); + return; + } + + if (attempt < config.probeAttempts) { + sleep(config.probeIntervalSeconds); + } + } + + probeOk.add(false, tags); + fail(`instance ${name} did not answer HTTP probe after ${config.probeAttempts} attempts`); +} + +function deleteInstance(name: string, tags: Tags): boolean { + const started = Date.now(); + const res = apiDelete(`/instances/${encodeURIComponent(name)}`, tagStep(tags, 'delete')); + deleteMs.add(Date.now() - started, tags); + return res.status === 204 || res.status === 404; +} + +function cleanupRunInstances(runId: string) { + const query = `tags%5Bbenchmark%5D=activity-ramp&tags%5Brun_id%5D=${encodeURIComponent(runId)}`; + const res = apiGet(`/instances?${query}`, { kind: 'teardown', step: 'list-run-instances', run_id: runId }); + if (res.status !== 200) { + return; + } + + const instances = res.json() as Array<{ id?: string; name?: string }>; + for (const instance of instances) { + const ref = instance.name || instance.id; + if (!ref) { + continue; + } + const deleted = deleteInstance(ref, { + benchmark: 'activity-ramp', + run_id: runId, + instance: ref, + kind: 'teardown', + }); + cleanupOk.add(deleted, { + benchmark: 'activity-ramp', + run_id: runId, + instance: ref, + kind: 'teardown', + }); + } +} + +function apiGet(path: string, tags: Tags): RefinedResponse { + return http.get(`${config.baseUrl}${path}`, { + headers: authHeaders(), + timeout: '10m', + tags, + }); +} + +function apiPost(path: string, body: unknown, tags: Tags): RefinedResponse { + return http.post(`${config.baseUrl}${path}`, JSON.stringify(body), { + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + timeout: '10m', + tags, + }); +} + +function apiDelete(path: string, tags: Tags): RefinedResponse { + return http.del(`${config.baseUrl}${path}`, undefined, { + headers: authHeaders(), + timeout: '10m', + tags, + }); +} + +function authHeaders(): Record { + return { + Authorization: `Bearer ${config.apiKey}`, + }; +} + +function assertStatus(res: RefinedResponse, allowed: number[], label: string) { + const ok = check(res, { + [`${label} status ${allowed.join('/')}`]: (r) => allowed.includes(r.status), + }); + if (!ok) { + fail(`${label} failed with status ${res.status}: ${String(res.body).slice(0, 500)}`); + } +} + +function tagStep(tags: Tags, step: string): Tags { + return { ...tags, step }; +} + +function instanceNameFor(runId: string): string { + const vu = exec.vu.idInTest; + const iter = exec.scenario.iterationInTest; + return `hm-bench-${runId}-${vu}-${iter}`.slice(0, 63).replace(/-+$/, ''); +} + +function defaultRunId(): string { + return Math.floor(Date.now() / 1000).toString(36); +} + +function probeURLFromBaseURL(baseUrl: string, port: number): string { + const match = baseUrl.match(/^(https?):\/\/([^/:]+)(?::[0-9]+)?/); + if (!match) { + return `http://127.0.0.1:${port}`; + } + return `${match[1]}://${match[2]}:${port}`; +} + +function envString(name: string, fallback: string): string { + const value = __ENV[name]; + return value === undefined || value === '' ? fallback : value; +} + +function requiredEnv(name: string, fallback: string): string { + return envString(name, fallback); +} + +function intEnv(name: string, fallback: number): number { + const raw = __ENV[name]; + if (raw === undefined || raw === '') { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + fail(`${name} must be an integer`); + } + return parsed; +} + +function floatEnv(name: string, fallback: number): number { + const raw = __ENV[name]; + if (raw === undefined || raw === '') { + return fallback; + } + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) { + fail(`${name} must be a number`); + } + return parsed; +} + +function durationSeconds(value: string): number { + const match = value.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)?$/); + if (!match) { + fail(`duration must be a number with optional ms, s, m, or h suffix: ${value}`); + } + const amount = Number.parseFloat(match[1]); + const unit = match[2] || 's'; + switch (unit) { + case 'ms': + return amount / 1000; + case 's': + return amount; + case 'm': + return amount * 60; + case 'h': + return amount * 60 * 60; + default: + fail(`unsupported duration unit: ${unit}`); + } +} + +function trimRight(value: string, suffix: string): string { + let out = value; + while (out.endsWith(suffix)) { + out = out.slice(0, -suffix.length); + } + return out; +} From 859361f65c8ec92e540e36ab0acd283dd6db0dfa Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 17 May 2026 20:33:21 +0000 Subject: [PATCH 2/6] Add benchmark matrix configuration --- Makefile | 7 ++- benchmarks/k6/README.md | 11 ++-- benchmarks/k6/activity-ramp.ts | 23 +++++++- deploy/aws/cloudformation/template.yaml | 62 ++++++++++++++++++++++ deploy/aws/cloudformation/template_test.go | 40 ++++++++++++-- 5 files changed, 133 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 0c64785a..b5aeef5f 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,9 @@ HYPEMAN_IMAGE ?= docker.io/library/nginx:alpine HYPEMAN_BENCH_MAX_VUS ?= 16 HYPEMAN_BENCH_VU_STEP ?= 1 HYPEMAN_BENCH_STAGE_DURATION ?= 2m -HYPEMAN_INGRESS_HOST_PORT ?= 8081 +HYPEMAN_BENCH_DASHBOARD_PERIOD ?= 120s +HYPEMAN_HYPERVISOR ?= cloud-hypervisor +HYPEMAN_INGRESS_HOST_PORT ?= 80 # Install oapi-codegen (pinned to match committed generated code) $(OAPI_CODEGEN): | $(BIN_DIR) @@ -309,7 +311,7 @@ bench-activity-ramp: @mkdir -p $(K6_OUT_DIR) K6_WEB_DASHBOARD=true \ K6_WEB_DASHBOARD_PORT=-1 \ - K6_WEB_DASHBOARD_PERIOD=1s \ + K6_WEB_DASHBOARD_PERIOD=$(HYPEMAN_BENCH_DASHBOARD_PERIOD) \ K6_WEB_DASHBOARD_EXPORT=$(K6_OUT_DIR)/activity-ramp.html \ $(K6) run \ --summary-mode=full \ @@ -318,6 +320,7 @@ bench-activity-ramp: -e HYPEMAN_BASE_URL="$(HYPEMAN_BASE_URL)" \ -e HYPEMAN_API_KEY="$$HYPEMAN_API_KEY" \ -e HYPEMAN_IMAGE="$(HYPEMAN_IMAGE)" \ + -e HYPEMAN_HYPERVISOR="$(HYPEMAN_HYPERVISOR)" \ -e HYPEMAN_BENCH_MAX_VUS="$(HYPEMAN_BENCH_MAX_VUS)" \ -e HYPEMAN_BENCH_VU_STEP="$(HYPEMAN_BENCH_VU_STEP)" \ -e HYPEMAN_BENCH_STAGE_DURATION="$(HYPEMAN_BENCH_STAGE_DURATION)" \ diff --git a/benchmarks/k6/README.md b/benchmarks/k6/README.md index 2fbbf6de..2ef8a341 100644 --- a/benchmarks/k6/README.md +++ b/benchmarks/k6/README.md @@ -18,6 +18,7 @@ export HYPEMAN_API_KEY=... make bench-activity-ramp \ HYPEMAN_BASE_URL=http://127.0.0.1:8080 \ HYPEMAN_IMAGE=docker.io/library/nginx:alpine \ + HYPEMAN_HYPERVISOR=cloud-hypervisor \ HYPEMAN_BENCH_MAX_VUS=16 ``` @@ -26,13 +27,17 @@ The Make target writes: - `.bench/k6/activity-ramp.html` - `.bench/k6/activity-ramp-summary.json` -The benchmark creates a shared ingress named `bench-activity-ramp` if one does not already exist. By default it listens on host port `8081`, matches `{instance}.hypeman-bench.local`, and targets port `80` on each instance. Override the probe path with: +The Make target uses 120-second dashboard buckets by default so each HTML report point lines up with one default ramp window. Override that with `HYPEMAN_BENCH_DASHBOARD_PERIOD`. + +The benchmark creates a shared ingress named `bench-activity-ramp` if one does not already exist. By default it listens on host port `80`, matches `{instance}.hypeman-bench.local`, and targets port `80` on each instance. Override the probe path with: ```sh -HYPEMAN_PROBE_URL=http://host:8081/ +HYPEMAN_PROBE_URL=http://host/ HYPEMAN_PROBE_HOST_SUFFIX=.hypeman-bench.local -HYPEMAN_INGRESS_HOST_PORT=8081 +HYPEMAN_INGRESS_HOST_PORT=80 HYPEMAN_INGRESS_TARGET_PORT=80 ``` Capacity rejections from `POST /instances` are recorded as `hypeman_create_rejected` and `hypeman_create_rejections`. They are not treated as unexpected script errors because they identify the concurrency level where the server starts refusing new activity. + +Set `HYPEMAN_HYPERVISOR` to `cloud-hypervisor`, `firecracker`, or `qemu` to run the same activity loop against a specific hypervisor. The value is sent on instance creation and added as a metric tag. diff --git a/benchmarks/k6/activity-ramp.ts b/benchmarks/k6/activity-ramp.ts index ac79c1be..701436f0 100644 --- a/benchmarks/k6/activity-ramp.ts +++ b/benchmarks/k6/activity-ramp.ts @@ -9,6 +9,7 @@ interface Config { baseUrl: string; apiKey: string; image: string; + hypervisor: string; runId: string; startVUs: number; maxVUs: number; @@ -78,6 +79,7 @@ export default function (data: { runId: string }) { const instanceName = instanceNameFor(data.runId); const tags: Tags = { benchmark: 'activity-ramp', + hypervisor: config.hypervisor || 'server-default', run_id: data.runId, instance: instanceName, }; @@ -114,6 +116,7 @@ function loadConfig(): Config { baseUrl, apiKey: requiredEnv('HYPEMAN_API_KEY', ''), image: requiredEnv('HYPEMAN_IMAGE', 'docker.io/library/nginx:alpine'), + hypervisor: envString('HYPEMAN_HYPERVISOR', ''), runId: envString('HYPEMAN_BENCH_RUN_ID', defaultRunId()), startVUs: intEnv('HYPEMAN_BENCH_START_VUS', 1), maxVUs: intEnv('HYPEMAN_BENCH_MAX_VUS', 16), @@ -226,7 +229,17 @@ function ensurePatternIngress() { function createInstance(name: string, tags: Tags): boolean { const started = Date.now(); - const res = apiPost('/instances', { + const body: { + name: string; + image: string; + size: string; + overlay_size: string; + vcpus: number; + network: { enabled: boolean }; + tags: Tags; + skip_kernel_headers: boolean; + hypervisor?: string; + } = { name, image: config.image, size: config.instanceMemory, @@ -235,10 +248,16 @@ function createInstance(name: string, tags: Tags): boolean { network: { enabled: true }, tags: { benchmark: 'activity-ramp', + hypervisor: config.hypervisor || 'server-default', run_id: config.runId, }, skip_kernel_headers: true, - }, tagStep(tags, 'create')); + }; + if (config.hypervisor) { + body.hypervisor = config.hypervisor; + } + + const res = apiPost('/instances', body, tagStep(tags, 'create')); createMs.add(Date.now() - started, tags); check(res, { diff --git a/deploy/aws/cloudformation/template.yaml b/deploy/aws/cloudformation/template.yaml index 03d09dda..ebea8523 100644 --- a/deploy/aws/cloudformation/template.yaml +++ b/deploy/aws/cloudformation/template.yaml @@ -11,12 +11,17 @@ Metadata: - SubnetId - AllowedApiCidr - ApiPort + - EnableHttpIngress + - EnableHttpsIngress + - AllowedIngressCidr - Label: default: Instance Parameters: - InstanceType - RootVolumeSize - DataVolumeSize + - DataVolumeIops + - DataVolumeThroughput - AmiSsmParameter - Label: default: Access @@ -41,6 +46,12 @@ Metadata: default: Hypeman API access CIDR ApiPort: default: Hypeman API port + EnableHttpIngress: + default: Enable HTTP ingress + EnableHttpsIngress: + default: Enable HTTPS ingress + AllowedIngressCidr: + default: Hypeman ingress access CIDR EnableSSH: default: Enable SSH AllowedSshCidr: @@ -51,6 +62,10 @@ Metadata: default: Root volume size DataVolumeSize: default: Hypeman data volume size + DataVolumeIops: + default: Hypeman data volume IOPS + DataVolumeThroughput: + default: Hypeman data volume throughput HypemanVersion: default: Hypeman release HypemanBranch: @@ -83,6 +98,21 @@ Parameters: MinValue: 1 MaxValue: 65535 Description: Hypeman API port exposed to AllowedApiCidr. + EnableHttpIngress: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Open port 80 from AllowedIngressCidr for Hypeman HTTP ingress traffic. + EnableHttpsIngress: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Open port 443 from AllowedIngressCidr for Hypeman HTTPS ingress traffic. + AllowedIngressCidr: + Type: String + Default: 127.0.0.1/32 + Description: Client CIDR allowed to reach Hypeman ingress ports when enabled. Use your current public IP /32 or a trusted VPN CIDR; avoid 0.0.0.0/0. + AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" EnableSSH: Type: String Default: "false" @@ -109,6 +139,18 @@ Parameters: MinValue: 50 MaxValue: 16384 Description: Hypeman data EBS volume size in GiB. This volume is formatted as XFS and mounted at /var/lib/hypeman. + DataVolumeIops: + Type: Number + Default: 3000 + MinValue: 3000 + MaxValue: 80000 + Description: Provisioned IOPS for the Hypeman data gp3 EBS volume. High values may require a larger DataVolumeSize. + DataVolumeThroughput: + Type: Number + Default: 125 + MinValue: 125 + MaxValue: 2000 + Description: Provisioned throughput in MiB/s for the Hypeman data gp3 EBS volume. High values may require higher DataVolumeIops. HypemanVersion: Type: String Default: latest @@ -128,6 +170,8 @@ Parameters: Conditions: UseSSH: !Equals [!Ref EnableSSH, "true"] + UseHttpIngress: !Equals [!Ref EnableHttpIngress, "true"] + UseHttpsIngress: !Equals [!Ref EnableHttpsIngress, "true"] Resources: HypemanSecurityGroup: @@ -141,6 +185,22 @@ Resources: ToPort: !Ref ApiPort CidrIp: !Ref AllowedApiCidr Description: Hypeman API + - !If + - UseHttpIngress + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: !Ref AllowedIngressCidr + Description: Hypeman HTTP ingress + - !Ref AWS::NoValue + - !If + - UseHttpsIngress + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !Ref AllowedIngressCidr + Description: Hypeman HTTPS ingress + - !Ref AWS::NoValue - !If - UseSSH - IpProtocol: tcp @@ -362,6 +422,8 @@ Resources: Ebs: VolumeSize: !Ref DataVolumeSize VolumeType: gp3 + Iops: !Ref DataVolumeIops + Throughput: !Ref DataVolumeThroughput Encrypted: true DeleteOnTermination: true Tags: diff --git a/deploy/aws/cloudformation/template_test.go b/deploy/aws/cloudformation/template_test.go index b9473bb6..2f82aa8a 100644 --- a/deploy/aws/cloudformation/template_test.go +++ b/deploy/aws/cloudformation/template_test.go @@ -16,10 +16,15 @@ func TestQuickstartParameters(t *testing.T) { assertDefault(t, parameters, "InstanceType", "c8i.2xlarge") assertDefault(t, parameters, "AllowedApiCidr", "127.0.0.1/32") assertDefault(t, parameters, "ApiPort", "8080") + assertDefault(t, parameters, "EnableHttpIngress", "false") + assertDefault(t, parameters, "EnableHttpsIngress", "false") + assertDefault(t, parameters, "AllowedIngressCidr", "127.0.0.1/32") assertDefault(t, parameters, "EnableSSH", "false") assertDefault(t, parameters, "AllowedSshCidr", "127.0.0.1/32") assertDefault(t, parameters, "RootVolumeSize", "30") assertDefault(t, parameters, "DataVolumeSize", "100") + assertDefault(t, parameters, "DataVolumeIops", "3000") + assertDefault(t, parameters, "DataVolumeThroughput", "125") assertDefault(t, parameters, "HypemanVersion", "latest") assertDefault(t, parameters, "HypemanCliVersion", "latest") @@ -31,6 +36,10 @@ func TestQuickstartParameters(t *testing.T) { assertContains(t, scalar(t, apiCidr["Description"]), "current public IP /32") assertContains(t, scalar(t, apiCidr["Description"]), "avoid 0.0.0.0/0") + ingressCidr := requireMapping(t, parameters["AllowedIngressCidr"]) + assertContains(t, scalar(t, ingressCidr["Description"]), "current public IP /32") + assertContains(t, scalar(t, ingressCidr["Description"]), "avoid 0.0.0.0/0") + metadata := requireMapping(t, requireField(t, root, "Metadata")) cfnInterface := requireMapping(t, requireField(t, metadata, "AWS::CloudFormation::Interface")) groups := requireSequence(t, requireField(t, cfnInterface, "ParameterGroups")) @@ -54,8 +63,8 @@ func TestCloudFormationLaunchContract(t *testing.T) { securityGroup := requireMapping(t, requireField(t, resources, "HypemanSecurityGroup")) sgProperties := requireMapping(t, requireField(t, securityGroup, "Properties")) ingress := requireSequence(t, requireField(t, sgProperties, "SecurityGroupIngress")) - if len(ingress.Content) != 2 { - t.Fatalf("expected API ingress and conditional SSH ingress, got %d entries", len(ingress.Content)) + if len(ingress.Content) != 4 { + t.Fatalf("expected API ingress, HTTP ingress, HTTPS ingress, and SSH ingress, got %d entries", len(ingress.Content)) } apiIngress := requireMapping(t, ingress.Content[0]) @@ -63,7 +72,10 @@ func TestCloudFormationLaunchContract(t *testing.T) { assertRef(t, requireField(t, apiIngress, "ToPort"), "ApiPort") assertRef(t, requireField(t, apiIngress, "CidrIp"), "AllowedApiCidr") - sshIngress := ingress.Content[1] + assertConditionalIngress(t, ingress.Content[1], "UseHttpIngress", "80", "AllowedIngressCidr") + assertConditionalIngress(t, ingress.Content[2], "UseHttpsIngress", "443", "AllowedIngressCidr") + + sshIngress := ingress.Content[3] if sshIngress.Tag != "!If" { t.Fatalf("expected SSH ingress to be conditional !If, got %s", sshIngress.Tag) } @@ -107,6 +119,8 @@ func TestCloudFormationLaunchContract(t *testing.T) { } dataEBS := requireMapping(t, requireField(t, dataDevice, "Ebs")) assertRef(t, requireField(t, dataEBS, "VolumeSize"), "DataVolumeSize") + assertRef(t, requireField(t, dataEBS, "Iops"), "DataVolumeIops") + assertRef(t, requireField(t, dataEBS, "Throughput"), "DataVolumeThroughput") userData := nodeText(requireField(t, hostProperties, "UserData")) assertContains(t, userData, "curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash") @@ -144,6 +158,26 @@ func TestQuickstartOutputs(t *testing.T) { assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["CreateTokenCommand"]), "Value")), "hypeman-create-token") } +func assertConditionalIngress(t *testing.T, node *yaml.Node, condition, port, cidrRef string) { + t.Helper() + + if node.Tag != "!If" { + t.Fatalf("expected ingress to be conditional !If, got %s", node.Tag) + } + parts := requireSequence(t, node) + if got := scalar(t, parts.Content[0]); got != condition { + t.Fatalf("expected condition %q, got %q", condition, got) + } + rule := requireMapping(t, parts.Content[1]) + if got := scalar(t, requireField(t, rule, "FromPort")); got != port { + t.Fatalf("expected FromPort %s, got %q", port, got) + } + if got := scalar(t, requireField(t, rule, "ToPort")); got != port { + t.Fatalf("expected ToPort %s, got %q", port, got) + } + assertRef(t, requireField(t, rule, "CidrIp"), cidrRef) +} + func loadTemplate(t *testing.T) *yaml.Node { t.Helper() From 83287e5a45b2127b165c5d9f26c34a5d791f1775 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 18 May 2026 05:43:29 +0000 Subject: [PATCH 3/6] Fix benchmark matrix issues --- Makefile | 2 ++ benchmarks/k6/README.md | 1 + benchmarks/k6/activity-ramp.ts | 38 ++++++++++++++------ deploy/aws/cloudformation/template.yaml | 42 +++++++++++----------- deploy/aws/cloudformation/template_test.go | 21 +++++------ 5 files changed, 60 insertions(+), 44 deletions(-) diff --git a/Makefile b/Makefile index b5aeef5f..c1127d45 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ HYPEMAN_BENCH_STAGE_DURATION ?= 2m HYPEMAN_BENCH_DASHBOARD_PERIOD ?= 120s HYPEMAN_HYPERVISOR ?= cloud-hypervisor HYPEMAN_INGRESS_HOST_PORT ?= 80 +HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS ?= 1 # Install oapi-codegen (pinned to match committed generated code) $(OAPI_CODEGEN): | $(BIN_DIR) @@ -325,6 +326,7 @@ bench-activity-ramp: -e HYPEMAN_BENCH_VU_STEP="$(HYPEMAN_BENCH_VU_STEP)" \ -e HYPEMAN_BENCH_STAGE_DURATION="$(HYPEMAN_BENCH_STAGE_DURATION)" \ -e HYPEMAN_INGRESS_HOST_PORT="$(HYPEMAN_INGRESS_HOST_PORT)" \ + -e HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS="$(HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS)" \ benchmarks/k6/activity-ramp.ts # macOS tests (no sudo needed, adds e2fsprogs to PATH) diff --git a/benchmarks/k6/README.md b/benchmarks/k6/README.md index 2ef8a341..bad4ad37 100644 --- a/benchmarks/k6/README.md +++ b/benchmarks/k6/README.md @@ -39,5 +39,6 @@ HYPEMAN_INGRESS_TARGET_PORT=80 ``` Capacity rejections from `POST /instances` are recorded as `hypeman_create_rejected` and `hypeman_create_rejections`. They are not treated as unexpected script errors because they identify the concurrency level where the server starts refusing new activity. +Rejected creates back off for one second by default so a saturated server does not produce a tight 409 loop. Override that with `HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS`. Set `HYPEMAN_HYPERVISOR` to `cloud-hypervisor`, `firecracker`, or `qemu` to run the same activity loop against a specific hypervisor. The value is sent on instance creation and added as a metric tag. diff --git a/benchmarks/k6/activity-ramp.ts b/benchmarks/k6/activity-ramp.ts index 701436f0..9558ae22 100644 --- a/benchmarks/k6/activity-ramp.ts +++ b/benchmarks/k6/activity-ramp.ts @@ -19,6 +19,7 @@ interface Config { instanceMemory: string; instanceOverlaySize: string; instanceVCPUs: number; + createRejectedBackoffSeconds: number; waitTimeoutSeconds: number; probeUrl: string; probePath: string; @@ -126,6 +127,7 @@ function loadConfig(): Config { instanceMemory: envString('HYPEMAN_INSTANCE_MEMORY', '512MB'), instanceOverlaySize: envString('HYPEMAN_INSTANCE_OVERLAY_SIZE', '2GB'), instanceVCPUs: intEnv('HYPEMAN_INSTANCE_VCPUS', 1), + createRejectedBackoffSeconds: floatEnv('HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS', 1), waitTimeoutSeconds: durationSeconds(envString('HYPEMAN_WAIT_TIMEOUT', '5m')), probeUrl: trimRight(envString('HYPEMAN_PROBE_URL', probeURLFromBaseURL(baseUrl, ingressHostPort)), '/'), probePath: envString('HYPEMAN_PROBE_PATH', '/'), @@ -170,20 +172,19 @@ function ensureHealthy() { } function ensureImageReady(image: string) { - const encoded = encodeURIComponent(image); - let res = apiGet(`/images/${encoded}`, { kind: 'setup', step: 'image-get' }); - if (res.status === 404) { - res = apiPost('/images', { name: image }, { kind: 'setup', step: 'image-create' }); - assertStatus(res, [202], 'create image'); - } else { - assertStatus(res, [200], 'get image'); + let imageBody = findImage(image); + if (!imageBody) { + const res = apiPost('/images', { name: image }, { kind: 'setup', step: 'image-create' }); + assertStatus(res, [202, 409], 'create image'); } const deadline = Date.now() + config.imageReadyTimeoutSeconds * 1000; while (Date.now() < deadline) { - const imageRes = apiGet(`/images/${encoded}`, { kind: 'setup', step: 'image-poll' }); - assertStatus(imageRes, [200], 'poll image'); - const imageBody = imageRes.json() as { status?: string; error?: string }; + imageBody = findImage(image); + if (!imageBody) { + sleep(2); + continue; + } if (imageBody.status === 'ready') { return; @@ -197,6 +198,18 @@ function ensureImageReady(image: string) { fail(`image ${image} did not become ready before ${config.imageReadyTimeoutSeconds}s`); } +function findImage(image: string): { status?: string; error?: string } | null { + const res = apiGet('/images', { kind: 'setup', step: 'image-list' }); + assertStatus(res, [200], 'list images'); + const images = res.json() as Array<{ name?: string; status?: string; error?: string }>; + for (const candidate of images) { + if (candidate.name === image) { + return candidate; + } + } + return null; +} + function ensurePatternIngress() { const encoded = encodeURIComponent(config.ingressName); const existing = apiGet(`/ingresses/${encoded}`, { kind: 'setup', step: 'ingress-get' }); @@ -270,6 +283,7 @@ function createInstance(name: string, tags: Tags): boolean { if (res.status === 409) { createRejected.add(true, tags); createRejections.add(1, tags); + sleep(config.createRejectedBackoffSeconds); return false; } @@ -409,7 +423,9 @@ function tagStep(tags: Tags, step: string): Tags { function instanceNameFor(runId: string): string { const vu = exec.vu.idInTest; const iter = exec.scenario.iterationInTest; - return `hm-bench-${runId}-${vu}-${iter}`.slice(0, 63).replace(/-+$/, ''); + const suffix = `-${vu}-${iter}`; + const prefix = `hm-bench-${runId}`.slice(0, 63 - suffix.length).replace(/-+$/, ''); + return `${prefix}${suffix}`; } function defaultRunId(): string { diff --git a/deploy/aws/cloudformation/template.yaml b/deploy/aws/cloudformation/template.yaml index ebea8523..9af7b1cd 100644 --- a/deploy/aws/cloudformation/template.yaml +++ b/deploy/aws/cloudformation/template.yaml @@ -246,9 +246,10 @@ Resources: Roles: - !Ref HypemanInstanceRole - # CloudFormation's typed EC2 resources do not expose CpuOptions.NestedVirtualization yet. - # This helper creates only the launch template that carries that EC2 API option; - # the Hypeman EC2 instance itself remains a normal stack-managed resource. + # CloudFormation's typed EC2 instance block device mapping does not expose gp3 + # throughput, and EC2 resources do not expose CpuOptions.NestedVirtualization + # yet. This helper creates a minimal launch template for those fields; the + # Hypeman EC2 instance itself remains a normal stack-managed resource. NestedVirtualizationLaunchTemplateRole: Type: AWS::IAM::Role Properties: @@ -345,12 +346,24 @@ Resources: stack_uuid = event["StackId"].rsplit("/", 1)[-1] return f"{event['ResourceProperties']['NamePrefix']}-{stack_uuid}" - def create_launch_template(name): + def create_launch_template(name, props): payload = { "Action": "CreateLaunchTemplate", "Version": "2016-11-15", "LaunchTemplateName": name, "LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled", + "LaunchTemplateData.BlockDeviceMapping.1.DeviceName": "/dev/sda1", + "LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeSize": props["RootVolumeSize"], + "LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeType": "gp3", + "LaunchTemplateData.BlockDeviceMapping.1.Ebs.Encrypted": "true", + "LaunchTemplateData.BlockDeviceMapping.1.Ebs.DeleteOnTermination": "true", + "LaunchTemplateData.BlockDeviceMapping.2.DeviceName": "/dev/sdf", + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeSize": props["DataVolumeSize"], + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeType": "gp3", + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"], + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"], + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Encrypted": "true", + "LaunchTemplateData.BlockDeviceMapping.2.Ebs.DeleteOnTermination": "true", "TagSpecification.1.ResourceType": "launch-template", "TagSpecification.1.Tag.1.Key": "Name", "TagSpecification.1.Tag.1.Value": name, @@ -386,7 +399,7 @@ Resources: return if request_type == "Update": delete_launch_template(physical_id) - data = create_launch_template(launch_template_name(event)) + data = create_launch_template(launch_template_name(event), event["ResourceProperties"]) send(event, context, "SUCCESS", data, physical_id=data["LaunchTemplateId"]) except Exception as exc: traceback.print_exc() @@ -397,6 +410,10 @@ Resources: Properties: ServiceToken: !GetAtt NestedVirtualizationLaunchTemplateFunction.Arn NamePrefix: hypeman + RootVolumeSize: !Ref RootVolumeSize + DataVolumeSize: !Ref DataVolumeSize + DataVolumeIops: !Ref DataVolumeIops + DataVolumeThroughput: !Ref DataVolumeThroughput HypemanHost: Type: AWS::EC2::Instance @@ -411,21 +428,6 @@ Resources: - !Ref HypemanSecurityGroup IamInstanceProfile: !Ref HypemanInstanceProfile KeyName: !If [UseSSH, !Ref KeyName, !Ref AWS::NoValue] - BlockDeviceMappings: - - DeviceName: /dev/sda1 - Ebs: - VolumeSize: !Ref RootVolumeSize - VolumeType: gp3 - Encrypted: true - DeleteOnTermination: true - - DeviceName: /dev/sdf - Ebs: - VolumeSize: !Ref DataVolumeSize - VolumeType: gp3 - Iops: !Ref DataVolumeIops - Throughput: !Ref DataVolumeThroughput - Encrypted: true - DeleteOnTermination: true Tags: - Key: Name Value: !Sub ${AWS::StackName}-hypeman diff --git a/deploy/aws/cloudformation/template_test.go b/deploy/aws/cloudformation/template_test.go index 2f82aa8a..af304557 100644 --- a/deploy/aws/cloudformation/template_test.go +++ b/deploy/aws/cloudformation/template_test.go @@ -99,6 +99,14 @@ func TestCloudFormationLaunchContract(t *testing.T) { zipFile := scalar(t, requireField(t, code, "ZipFile")) assertContains(t, zipFile, `"Action": "CreateLaunchTemplate"`) assertContains(t, zipFile, `"LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled"`) + assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"]`) + assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"]`) + + launchTemplateProperties := requireMapping(t, requireField(t, launchTemplate, "Properties")) + assertRef(t, requireField(t, launchTemplateProperties, "RootVolumeSize"), "RootVolumeSize") + assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeSize"), "DataVolumeSize") + assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeIops"), "DataVolumeIops") + assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeThroughput"), "DataVolumeThroughput") host := requireMapping(t, requireField(t, resources, "HypemanHost")) if got := scalar(t, requireField(t, host, "Type")); got != "AWS::EC2::Instance" { @@ -109,19 +117,6 @@ func TestCloudFormationLaunchContract(t *testing.T) { assertGetAtt(t, requireField(t, hostLaunchTemplate, "LaunchTemplateId"), "NestedVirtualizationLaunchTemplate.LaunchTemplateId") assertGetAtt(t, requireField(t, hostLaunchTemplate, "Version"), "NestedVirtualizationLaunchTemplate.VersionNumber") - blockDeviceMappings := requireSequence(t, requireField(t, hostProperties, "BlockDeviceMappings")) - if len(blockDeviceMappings.Content) != 2 { - t.Fatalf("expected root and Hypeman data block device mappings, got %d", len(blockDeviceMappings.Content)) - } - dataDevice := requireMapping(t, blockDeviceMappings.Content[1]) - if got := scalar(t, requireField(t, dataDevice, "DeviceName")); got != "/dev/sdf" { - t.Fatalf("data device name = %q, want /dev/sdf", got) - } - dataEBS := requireMapping(t, requireField(t, dataDevice, "Ebs")) - assertRef(t, requireField(t, dataEBS, "VolumeSize"), "DataVolumeSize") - assertRef(t, requireField(t, dataEBS, "Iops"), "DataVolumeIops") - assertRef(t, requireField(t, dataEBS, "Throughput"), "DataVolumeThroughput") - userData := nodeText(requireField(t, hostProperties, "UserData")) assertContains(t, userData, "curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash") assertContains(t, userData, "xfsprogs") From 8ce8082f7761107ab5a4810f77fca502a0e81906 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 18 May 2026 13:25:54 +0000 Subject: [PATCH 4/6] Remove CloudFormation changes from benchmark PR --- deploy/aws/cloudformation/template.yaml | 100 ++++----------------- deploy/aws/cloudformation/template_test.go | 57 +++--------- 2 files changed, 32 insertions(+), 125 deletions(-) diff --git a/deploy/aws/cloudformation/template.yaml b/deploy/aws/cloudformation/template.yaml index 9af7b1cd..03d09dda 100644 --- a/deploy/aws/cloudformation/template.yaml +++ b/deploy/aws/cloudformation/template.yaml @@ -11,17 +11,12 @@ Metadata: - SubnetId - AllowedApiCidr - ApiPort - - EnableHttpIngress - - EnableHttpsIngress - - AllowedIngressCidr - Label: default: Instance Parameters: - InstanceType - RootVolumeSize - DataVolumeSize - - DataVolumeIops - - DataVolumeThroughput - AmiSsmParameter - Label: default: Access @@ -46,12 +41,6 @@ Metadata: default: Hypeman API access CIDR ApiPort: default: Hypeman API port - EnableHttpIngress: - default: Enable HTTP ingress - EnableHttpsIngress: - default: Enable HTTPS ingress - AllowedIngressCidr: - default: Hypeman ingress access CIDR EnableSSH: default: Enable SSH AllowedSshCidr: @@ -62,10 +51,6 @@ Metadata: default: Root volume size DataVolumeSize: default: Hypeman data volume size - DataVolumeIops: - default: Hypeman data volume IOPS - DataVolumeThroughput: - default: Hypeman data volume throughput HypemanVersion: default: Hypeman release HypemanBranch: @@ -98,21 +83,6 @@ Parameters: MinValue: 1 MaxValue: 65535 Description: Hypeman API port exposed to AllowedApiCidr. - EnableHttpIngress: - Type: String - Default: "false" - AllowedValues: ["true", "false"] - Description: Open port 80 from AllowedIngressCidr for Hypeman HTTP ingress traffic. - EnableHttpsIngress: - Type: String - Default: "false" - AllowedValues: ["true", "false"] - Description: Open port 443 from AllowedIngressCidr for Hypeman HTTPS ingress traffic. - AllowedIngressCidr: - Type: String - Default: 127.0.0.1/32 - Description: Client CIDR allowed to reach Hypeman ingress ports when enabled. Use your current public IP /32 or a trusted VPN CIDR; avoid 0.0.0.0/0. - AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" EnableSSH: Type: String Default: "false" @@ -139,18 +109,6 @@ Parameters: MinValue: 50 MaxValue: 16384 Description: Hypeman data EBS volume size in GiB. This volume is formatted as XFS and mounted at /var/lib/hypeman. - DataVolumeIops: - Type: Number - Default: 3000 - MinValue: 3000 - MaxValue: 80000 - Description: Provisioned IOPS for the Hypeman data gp3 EBS volume. High values may require a larger DataVolumeSize. - DataVolumeThroughput: - Type: Number - Default: 125 - MinValue: 125 - MaxValue: 2000 - Description: Provisioned throughput in MiB/s for the Hypeman data gp3 EBS volume. High values may require higher DataVolumeIops. HypemanVersion: Type: String Default: latest @@ -170,8 +128,6 @@ Parameters: Conditions: UseSSH: !Equals [!Ref EnableSSH, "true"] - UseHttpIngress: !Equals [!Ref EnableHttpIngress, "true"] - UseHttpsIngress: !Equals [!Ref EnableHttpsIngress, "true"] Resources: HypemanSecurityGroup: @@ -185,22 +141,6 @@ Resources: ToPort: !Ref ApiPort CidrIp: !Ref AllowedApiCidr Description: Hypeman API - - !If - - UseHttpIngress - - IpProtocol: tcp - FromPort: 80 - ToPort: 80 - CidrIp: !Ref AllowedIngressCidr - Description: Hypeman HTTP ingress - - !Ref AWS::NoValue - - !If - - UseHttpsIngress - - IpProtocol: tcp - FromPort: 443 - ToPort: 443 - CidrIp: !Ref AllowedIngressCidr - Description: Hypeman HTTPS ingress - - !Ref AWS::NoValue - !If - UseSSH - IpProtocol: tcp @@ -246,10 +186,9 @@ Resources: Roles: - !Ref HypemanInstanceRole - # CloudFormation's typed EC2 instance block device mapping does not expose gp3 - # throughput, and EC2 resources do not expose CpuOptions.NestedVirtualization - # yet. This helper creates a minimal launch template for those fields; the - # Hypeman EC2 instance itself remains a normal stack-managed resource. + # CloudFormation's typed EC2 resources do not expose CpuOptions.NestedVirtualization yet. + # This helper creates only the launch template that carries that EC2 API option; + # the Hypeman EC2 instance itself remains a normal stack-managed resource. NestedVirtualizationLaunchTemplateRole: Type: AWS::IAM::Role Properties: @@ -346,24 +285,12 @@ Resources: stack_uuid = event["StackId"].rsplit("/", 1)[-1] return f"{event['ResourceProperties']['NamePrefix']}-{stack_uuid}" - def create_launch_template(name, props): + def create_launch_template(name): payload = { "Action": "CreateLaunchTemplate", "Version": "2016-11-15", "LaunchTemplateName": name, "LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled", - "LaunchTemplateData.BlockDeviceMapping.1.DeviceName": "/dev/sda1", - "LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeSize": props["RootVolumeSize"], - "LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeType": "gp3", - "LaunchTemplateData.BlockDeviceMapping.1.Ebs.Encrypted": "true", - "LaunchTemplateData.BlockDeviceMapping.1.Ebs.DeleteOnTermination": "true", - "LaunchTemplateData.BlockDeviceMapping.2.DeviceName": "/dev/sdf", - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeSize": props["DataVolumeSize"], - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeType": "gp3", - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"], - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"], - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.Encrypted": "true", - "LaunchTemplateData.BlockDeviceMapping.2.Ebs.DeleteOnTermination": "true", "TagSpecification.1.ResourceType": "launch-template", "TagSpecification.1.Tag.1.Key": "Name", "TagSpecification.1.Tag.1.Value": name, @@ -399,7 +326,7 @@ Resources: return if request_type == "Update": delete_launch_template(physical_id) - data = create_launch_template(launch_template_name(event), event["ResourceProperties"]) + data = create_launch_template(launch_template_name(event)) send(event, context, "SUCCESS", data, physical_id=data["LaunchTemplateId"]) except Exception as exc: traceback.print_exc() @@ -410,10 +337,6 @@ Resources: Properties: ServiceToken: !GetAtt NestedVirtualizationLaunchTemplateFunction.Arn NamePrefix: hypeman - RootVolumeSize: !Ref RootVolumeSize - DataVolumeSize: !Ref DataVolumeSize - DataVolumeIops: !Ref DataVolumeIops - DataVolumeThroughput: !Ref DataVolumeThroughput HypemanHost: Type: AWS::EC2::Instance @@ -428,6 +351,19 @@ Resources: - !Ref HypemanSecurityGroup IamInstanceProfile: !Ref HypemanInstanceProfile KeyName: !If [UseSSH, !Ref KeyName, !Ref AWS::NoValue] + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref RootVolumeSize + VolumeType: gp3 + Encrypted: true + DeleteOnTermination: true + - DeviceName: /dev/sdf + Ebs: + VolumeSize: !Ref DataVolumeSize + VolumeType: gp3 + Encrypted: true + DeleteOnTermination: true Tags: - Key: Name Value: !Sub ${AWS::StackName}-hypeman diff --git a/deploy/aws/cloudformation/template_test.go b/deploy/aws/cloudformation/template_test.go index af304557..b9473bb6 100644 --- a/deploy/aws/cloudformation/template_test.go +++ b/deploy/aws/cloudformation/template_test.go @@ -16,15 +16,10 @@ func TestQuickstartParameters(t *testing.T) { assertDefault(t, parameters, "InstanceType", "c8i.2xlarge") assertDefault(t, parameters, "AllowedApiCidr", "127.0.0.1/32") assertDefault(t, parameters, "ApiPort", "8080") - assertDefault(t, parameters, "EnableHttpIngress", "false") - assertDefault(t, parameters, "EnableHttpsIngress", "false") - assertDefault(t, parameters, "AllowedIngressCidr", "127.0.0.1/32") assertDefault(t, parameters, "EnableSSH", "false") assertDefault(t, parameters, "AllowedSshCidr", "127.0.0.1/32") assertDefault(t, parameters, "RootVolumeSize", "30") assertDefault(t, parameters, "DataVolumeSize", "100") - assertDefault(t, parameters, "DataVolumeIops", "3000") - assertDefault(t, parameters, "DataVolumeThroughput", "125") assertDefault(t, parameters, "HypemanVersion", "latest") assertDefault(t, parameters, "HypemanCliVersion", "latest") @@ -36,10 +31,6 @@ func TestQuickstartParameters(t *testing.T) { assertContains(t, scalar(t, apiCidr["Description"]), "current public IP /32") assertContains(t, scalar(t, apiCidr["Description"]), "avoid 0.0.0.0/0") - ingressCidr := requireMapping(t, parameters["AllowedIngressCidr"]) - assertContains(t, scalar(t, ingressCidr["Description"]), "current public IP /32") - assertContains(t, scalar(t, ingressCidr["Description"]), "avoid 0.0.0.0/0") - metadata := requireMapping(t, requireField(t, root, "Metadata")) cfnInterface := requireMapping(t, requireField(t, metadata, "AWS::CloudFormation::Interface")) groups := requireSequence(t, requireField(t, cfnInterface, "ParameterGroups")) @@ -63,8 +54,8 @@ func TestCloudFormationLaunchContract(t *testing.T) { securityGroup := requireMapping(t, requireField(t, resources, "HypemanSecurityGroup")) sgProperties := requireMapping(t, requireField(t, securityGroup, "Properties")) ingress := requireSequence(t, requireField(t, sgProperties, "SecurityGroupIngress")) - if len(ingress.Content) != 4 { - t.Fatalf("expected API ingress, HTTP ingress, HTTPS ingress, and SSH ingress, got %d entries", len(ingress.Content)) + if len(ingress.Content) != 2 { + t.Fatalf("expected API ingress and conditional SSH ingress, got %d entries", len(ingress.Content)) } apiIngress := requireMapping(t, ingress.Content[0]) @@ -72,10 +63,7 @@ func TestCloudFormationLaunchContract(t *testing.T) { assertRef(t, requireField(t, apiIngress, "ToPort"), "ApiPort") assertRef(t, requireField(t, apiIngress, "CidrIp"), "AllowedApiCidr") - assertConditionalIngress(t, ingress.Content[1], "UseHttpIngress", "80", "AllowedIngressCidr") - assertConditionalIngress(t, ingress.Content[2], "UseHttpsIngress", "443", "AllowedIngressCidr") - - sshIngress := ingress.Content[3] + sshIngress := ingress.Content[1] if sshIngress.Tag != "!If" { t.Fatalf("expected SSH ingress to be conditional !If, got %s", sshIngress.Tag) } @@ -99,14 +87,6 @@ func TestCloudFormationLaunchContract(t *testing.T) { zipFile := scalar(t, requireField(t, code, "ZipFile")) assertContains(t, zipFile, `"Action": "CreateLaunchTemplate"`) assertContains(t, zipFile, `"LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled"`) - assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"]`) - assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"]`) - - launchTemplateProperties := requireMapping(t, requireField(t, launchTemplate, "Properties")) - assertRef(t, requireField(t, launchTemplateProperties, "RootVolumeSize"), "RootVolumeSize") - assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeSize"), "DataVolumeSize") - assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeIops"), "DataVolumeIops") - assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeThroughput"), "DataVolumeThroughput") host := requireMapping(t, requireField(t, resources, "HypemanHost")) if got := scalar(t, requireField(t, host, "Type")); got != "AWS::EC2::Instance" { @@ -117,6 +97,17 @@ func TestCloudFormationLaunchContract(t *testing.T) { assertGetAtt(t, requireField(t, hostLaunchTemplate, "LaunchTemplateId"), "NestedVirtualizationLaunchTemplate.LaunchTemplateId") assertGetAtt(t, requireField(t, hostLaunchTemplate, "Version"), "NestedVirtualizationLaunchTemplate.VersionNumber") + blockDeviceMappings := requireSequence(t, requireField(t, hostProperties, "BlockDeviceMappings")) + if len(blockDeviceMappings.Content) != 2 { + t.Fatalf("expected root and Hypeman data block device mappings, got %d", len(blockDeviceMappings.Content)) + } + dataDevice := requireMapping(t, blockDeviceMappings.Content[1]) + if got := scalar(t, requireField(t, dataDevice, "DeviceName")); got != "/dev/sdf" { + t.Fatalf("data device name = %q, want /dev/sdf", got) + } + dataEBS := requireMapping(t, requireField(t, dataDevice, "Ebs")) + assertRef(t, requireField(t, dataEBS, "VolumeSize"), "DataVolumeSize") + userData := nodeText(requireField(t, hostProperties, "UserData")) assertContains(t, userData, "curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash") assertContains(t, userData, "xfsprogs") @@ -153,26 +144,6 @@ func TestQuickstartOutputs(t *testing.T) { assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["CreateTokenCommand"]), "Value")), "hypeman-create-token") } -func assertConditionalIngress(t *testing.T, node *yaml.Node, condition, port, cidrRef string) { - t.Helper() - - if node.Tag != "!If" { - t.Fatalf("expected ingress to be conditional !If, got %s", node.Tag) - } - parts := requireSequence(t, node) - if got := scalar(t, parts.Content[0]); got != condition { - t.Fatalf("expected condition %q, got %q", condition, got) - } - rule := requireMapping(t, parts.Content[1]) - if got := scalar(t, requireField(t, rule, "FromPort")); got != port { - t.Fatalf("expected FromPort %s, got %q", port, got) - } - if got := scalar(t, requireField(t, rule, "ToPort")); got != port { - t.Fatalf("expected ToPort %s, got %q", port, got) - } - assertRef(t, requireField(t, rule, "CidrIp"), cidrRef) -} - func loadTemplate(t *testing.T) *yaml.Node { t.Helper() From 2109e964e52abfb60f54f8786aaa86fd77eb6dc3 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 18 May 2026 15:15:10 +0000 Subject: [PATCH 5/6] Move benchmark make target --- Makefile | 42 +------------------------------ benchmarks/Makefile | 46 ++++++++++++++++++++++++++++++++++ benchmarks/k6/README.md | 2 +- benchmarks/k6/activity-ramp.ts | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 benchmarks/Makefile diff --git a/Makefile b/Makefile index c1127d45..611bb11b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash -.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build build-linux test test-linux test-darwin test-guestmemory-linux test-guestmemory-vz install-tools gen-jwt download-ch-binaries download-firecracker-binaries download-ch-spec ensure-ch-binaries ensure-firecracker-binaries build-caddy-binaries build-caddy ensure-caddy-binaries release-prep clean build-embedded bench-activity-ramp +.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build build-linux test test-linux test-darwin test-guestmemory-linux test-guestmemory-vz install-tools gen-jwt download-ch-binaries download-firecracker-binaries download-ch-spec ensure-ch-binaries ensure-firecracker-binaries build-caddy-binaries build-caddy ensure-caddy-binaries release-prep clean build-embedded # Directory where local binaries will be installed BIN_DIR ?= $(CURDIR)/bin @@ -15,17 +15,6 @@ AIR ?= $(BIN_DIR)/air WIRE ?= $(BIN_DIR)/wire XCADDY ?= $(BIN_DIR)/xcaddy TEST_TIMEOUT ?= $(GO_TEST_TIMEOUT) -K6 ?= k6 -K6_OUT_DIR ?= .bench/k6 -HYPEMAN_BASE_URL ?= http://127.0.0.1:8080 -HYPEMAN_IMAGE ?= docker.io/library/nginx:alpine -HYPEMAN_BENCH_MAX_VUS ?= 16 -HYPEMAN_BENCH_VU_STEP ?= 1 -HYPEMAN_BENCH_STAGE_DURATION ?= 2m -HYPEMAN_BENCH_DASHBOARD_PERIOD ?= 120s -HYPEMAN_HYPERVISOR ?= cloud-hypervisor -HYPEMAN_INGRESS_HOST_PORT ?= 80 -HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS ?= 1 # Install oapi-codegen (pinned to match committed generated code) $(OAPI_CODEGEN): | $(BIN_DIR) @@ -300,35 +289,6 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) ./...; \ fi -bench-activity-ramp: - @if ! command -v $(K6) >/dev/null 2>&1; then \ - echo "k6 not found; install k6 or run with K6=/path/to/k6"; \ - exit 1; \ - fi - @if [ -z "$$HYPEMAN_API_KEY" ]; then \ - echo "HYPEMAN_API_KEY is required"; \ - exit 1; \ - fi - @mkdir -p $(K6_OUT_DIR) - K6_WEB_DASHBOARD=true \ - K6_WEB_DASHBOARD_PORT=-1 \ - K6_WEB_DASHBOARD_PERIOD=$(HYPEMAN_BENCH_DASHBOARD_PERIOD) \ - K6_WEB_DASHBOARD_EXPORT=$(K6_OUT_DIR)/activity-ramp.html \ - $(K6) run \ - --summary-mode=full \ - --summary-trend-stats="avg,med,p(90),p(95),p(99),min,max" \ - --summary-export=$(K6_OUT_DIR)/activity-ramp-summary.json \ - -e HYPEMAN_BASE_URL="$(HYPEMAN_BASE_URL)" \ - -e HYPEMAN_API_KEY="$$HYPEMAN_API_KEY" \ - -e HYPEMAN_IMAGE="$(HYPEMAN_IMAGE)" \ - -e HYPEMAN_HYPERVISOR="$(HYPEMAN_HYPERVISOR)" \ - -e HYPEMAN_BENCH_MAX_VUS="$(HYPEMAN_BENCH_MAX_VUS)" \ - -e HYPEMAN_BENCH_VU_STEP="$(HYPEMAN_BENCH_VU_STEP)" \ - -e HYPEMAN_BENCH_STAGE_DURATION="$(HYPEMAN_BENCH_STAGE_DURATION)" \ - -e HYPEMAN_INGRESS_HOST_PORT="$(HYPEMAN_INGRESS_HOST_PORT)" \ - -e HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS="$(HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS)" \ - benchmarks/k6/activity-ramp.ts - # 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). diff --git a/benchmarks/Makefile b/benchmarks/Makefile new file mode 100644 index 00000000..6004e884 --- /dev/null +++ b/benchmarks/Makefile @@ -0,0 +1,46 @@ +SHELL := /bin/bash + +.PHONY: bench-activity-ramp + +REPO_ROOT := $(abspath $(CURDIR)/..) + +K6 ?= k6 +K6_OUT_DIR ?= $(REPO_ROOT)/.bench/k6 +HYPEMAN_BASE_URL ?= http://127.0.0.1:8080 +HYPEMAN_IMAGE ?= docker.io/library/nginx:alpine +HYPEMAN_BENCH_MAX_VUS ?= 16 +HYPEMAN_BENCH_VU_STEP ?= 1 +HYPEMAN_BENCH_STAGE_DURATION ?= 2m +HYPEMAN_BENCH_DASHBOARD_PERIOD ?= 120s +HYPEMAN_HYPERVISOR ?= cloud-hypervisor +HYPEMAN_INGRESS_HOST_PORT ?= 80 +HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS ?= 1 + +bench-activity-ramp: + @if ! command -v $(K6) >/dev/null 2>&1; then \ + echo "k6 not found; install k6 or run with K6=/path/to/k6"; \ + exit 1; \ + fi + @if [ -z "$$HYPEMAN_API_KEY" ]; then \ + echo "HYPEMAN_API_KEY is required"; \ + exit 1; \ + fi + @mkdir -p $(K6_OUT_DIR) + K6_WEB_DASHBOARD=true \ + K6_WEB_DASHBOARD_PORT=-1 \ + K6_WEB_DASHBOARD_PERIOD=$(HYPEMAN_BENCH_DASHBOARD_PERIOD) \ + K6_WEB_DASHBOARD_EXPORT=$(K6_OUT_DIR)/activity-ramp.html \ + $(K6) run \ + --summary-mode=full \ + --summary-trend-stats="avg,med,p(90),p(95),p(99),min,max" \ + --summary-export=$(K6_OUT_DIR)/activity-ramp-summary.json \ + -e HYPEMAN_BASE_URL="$(HYPEMAN_BASE_URL)" \ + -e HYPEMAN_API_KEY="$$HYPEMAN_API_KEY" \ + -e HYPEMAN_IMAGE="$(HYPEMAN_IMAGE)" \ + -e HYPEMAN_HYPERVISOR="$(HYPEMAN_HYPERVISOR)" \ + -e HYPEMAN_BENCH_MAX_VUS="$(HYPEMAN_BENCH_MAX_VUS)" \ + -e HYPEMAN_BENCH_VU_STEP="$(HYPEMAN_BENCH_VU_STEP)" \ + -e HYPEMAN_BENCH_STAGE_DURATION="$(HYPEMAN_BENCH_STAGE_DURATION)" \ + -e HYPEMAN_INGRESS_HOST_PORT="$(HYPEMAN_INGRESS_HOST_PORT)" \ + -e HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS="$(HYPEMAN_CREATE_REJECTED_BACKOFF_SECONDS)" \ + k6/activity-ramp.ts diff --git a/benchmarks/k6/README.md b/benchmarks/k6/README.md index bad4ad37..fa14d278 100644 --- a/benchmarks/k6/README.md +++ b/benchmarks/k6/README.md @@ -15,7 +15,7 @@ The default ramp increases concurrency by one virtual user every two minutes up ```sh export HYPEMAN_API_KEY=... -make bench-activity-ramp \ +make -C benchmarks bench-activity-ramp \ HYPEMAN_BASE_URL=http://127.0.0.1:8080 \ HYPEMAN_IMAGE=docker.io/library/nginx:alpine \ HYPEMAN_HYPERVISOR=cloud-hypervisor \ diff --git a/benchmarks/k6/activity-ramp.ts b/benchmarks/k6/activity-ramp.ts index 9558ae22..4b786477 100644 --- a/benchmarks/k6/activity-ramp.ts +++ b/benchmarks/k6/activity-ramp.ts @@ -5,6 +5,16 @@ import { Counter, Rate, Trend } from 'k6/metrics'; type Tags = Record; +// k6 runs this file in a few phases: +// +// 1. The module top level runs once per virtual user (VU). Put metrics, +// options, and helper definitions here. +// 2. setup() runs once before load starts. We use it to verify the Hypeman API, +// ensure the image exists, and create the shared pattern ingress. +// 3. The default function is the workload. k6 calls it repeatedly in every VU +// while the ramping-vus scenario is active. +// 4. teardown() runs once after load stops. It removes any instances tagged +// with this benchmark run ID. interface Config { baseUrl: string; apiKey: string; @@ -33,12 +43,17 @@ interface Config { imageReadyTimeoutSeconds: number; } +// Trend metrics store latency distributions. Passing true tells k6 these values +// are durations in milliseconds, so summaries and dashboards format them as time. const createMs = new Trend('hypeman_create_instance_ms', true); const waitRunningMs = new Trend('hypeman_wait_running_ms', true); const probeReadyMs = new Trend('hypeman_probe_ready_ms', true); const probeHTTPMs = new Trend('hypeman_probe_http_ms', true); const deleteMs = new Trend('hypeman_delete_instance_ms', true); const activityMs = new Trend('hypeman_activity_total_ms', true); + +// Rate metrics track the fraction of samples that were true. Counter metrics +// track raw counts. These give us capacity signals alongside latency. const activityOk = new Rate('hypeman_activity_ok'); const cleanupOk = new Rate('hypeman_cleanup_ok'); const createRejected = new Rate('hypeman_create_rejected'); @@ -51,6 +66,8 @@ export const options = { setupTimeout: '15m', teardownTimeout: '10m', scenarios: { + // ramping-vus changes the number of concurrent virtual users over time. + // Each active VU loops through the activity until k6 lowers concurrency. activity_ramp: { executor: 'ramping-vus', startVUs: config.startVUs, @@ -59,12 +76,17 @@ export const options = { }, }, thresholds: { + // Thresholds mark the run failed if cleanup or probe success gets too low. + // Create rejections are measured separately because they are the capacity + // signal we are trying to find, not a script bug by themselves. hypeman_cleanup_ok: ['rate>0.95'], hypeman_probe_ok: ['rate>0.80'], }, }; export function setup() { + // setup() returns data that k6 passes into every default() iteration. + // The run ID is shared so all VUs use the same cleanup tag. checkRequiredConfig(config); ensureHealthy(); ensureImageReady(config.image); @@ -76,6 +98,9 @@ export function setup() { } export default function (data: { runId: string }) { + // One iteration is one full user-facing activity: + // create -> wait for Running -> send one HTTP probe -> delete. + // k6 repeats this loop in each VU for as long as that VU is scheduled. const iterationStart = Date.now(); const instanceName = instanceNameFor(data.runId); const tags: Tags = { @@ -90,6 +115,8 @@ export default function (data: { runId: string }) { try { created = createInstance(instanceName, tags); if (!created) { + // A false return means Hypeman rejected the create due to capacity. The + // rejection was already counted, so this VU ends the iteration quietly. return; } waitForRunning(instanceName, tags); @@ -106,6 +133,7 @@ export default function (data: { runId: string }) { } export function teardown(data: { runId: string }) { + // Best-effort cleanup handles interrupted iterations or a failed test run. cleanupRunInstances(data.runId); } @@ -143,6 +171,8 @@ function loadConfig(): Config { } function rampStages(cfg: Config): Array<{ duration: string; target: number }> { + // Stages are the k6 ramp plan. With the defaults this produces: + // 1 VU start, then 2, 3, 4, ... 16 VUs, spending 2 minutes at each target. const stages: Array<{ duration: string; target: number }> = []; for (let target = cfg.startVUs + cfg.vuStep; target <= cfg.maxVUs; target += cfg.vuStep) { stages.push({ duration: cfg.stageDuration, target }); @@ -172,6 +202,8 @@ function ensureHealthy() { } function ensureImageReady(image: string) { + // Hypeman imports images asynchronously. The benchmark should measure + // instance lifecycle under load, not image import time, so setup waits here. let imageBody = findImage(image); if (!imageBody) { const res = apiPost('/images', { name: image }, { kind: 'setup', step: 'image-create' }); @@ -211,6 +243,9 @@ function findImage(image: string): { status?: string; error?: string } | null { } function ensurePatternIngress() { + // The ingress uses a hostname pattern where {instance} is replaced by each + // instance name. That lets all iterations share one ingress instead of + // creating and deleting ingress resources inside the hot loop. const encoded = encodeURIComponent(config.ingressName); const existing = apiGet(`/ingresses/${encoded}`, { kind: 'setup', step: 'ingress-get' }); if (existing.status === 200) { @@ -281,6 +316,8 @@ function createInstance(name: string, tags: Tags): boolean { return true; } if (res.status === 409) { + // 409 is useful data: it means the server admitted that this concurrency + // level is beyond current capacity. Count it without failing the script. createRejected.add(true, tags); createRejections.add(1, tags); sleep(config.createRejectedBackoffSeconds); @@ -291,6 +328,7 @@ function createInstance(name: string, tags: Tags): boolean { } function waitForRunning(name: string, tags: Tags) { + // This measures control-plane latency from accepted create to Running state. const started = Date.now(); const path = `/instances/${encodeURIComponent(name)}`; const deadline = started + config.waitTimeoutSeconds * 1000; @@ -315,6 +353,9 @@ function waitForRunning(name: string, tags: Tags) { } function probeInstance(name: string, tags: Tags) { + // The probe goes through the shared ingress URL. The Host header selects the + // instance via the pattern ingress, so latency here reflects the data path + // through Hypeman into the guest workload. const started = Date.now(); const probeURL = `${config.probeUrl}${config.probePath.startsWith('/') ? config.probePath : `/${config.probePath}`}`; const host = `${name}${config.probeHostSuffix}`; @@ -350,6 +391,7 @@ function deleteInstance(name: string, tags: Tags): boolean { } function cleanupRunInstances(runId: string) { + // Query by benchmark tags so teardown only touches instances from this run. const query = `tags%5Bbenchmark%5D=activity-ramp&tags%5Brun_id%5D=${encodeURIComponent(runId)}`; const res = apiGet(`/instances?${query}`, { kind: 'teardown', step: 'list-run-instances', run_id: runId }); if (res.status !== 200) { @@ -417,10 +459,14 @@ function assertStatus(res: RefinedResponse, allowed: n } function tagStep(tags: Tags, step: string): Tags { + // Tags are attached to k6 metric samples. They make it possible to filter + // results by step, hypervisor, run ID, or instance in JSON outputs. return { ...tags, step }; } function instanceNameFor(runId: string): string { + // k6 exposes the current virtual user and iteration through k6/execution. + // Including both values keeps names unique even when many VUs run at once. const vu = exec.vu.idInTest; const iter = exec.scenario.iterationInTest; const suffix = `-${vu}-${iter}`; From 8336845c09d991bb13ad966c5876f6c7f2dcb06d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 18 May 2026 15:23:14 +0000 Subject: [PATCH 6/6] Fix activity benchmark defaults --- benchmarks/k6/activity-ramp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/k6/activity-ramp.ts b/benchmarks/k6/activity-ramp.ts index 4b786477..fe83f008 100644 --- a/benchmarks/k6/activity-ramp.ts +++ b/benchmarks/k6/activity-ramp.ts @@ -139,7 +139,7 @@ export function teardown(data: { runId: string }) { function loadConfig(): Config { const baseUrl = trimRight(requiredEnv('HYPEMAN_BASE_URL', 'http://127.0.0.1:8080'), '/'); - const ingressHostPort = intEnv('HYPEMAN_INGRESS_HOST_PORT', 8081); + const ingressHostPort = intEnv('HYPEMAN_INGRESS_HOST_PORT', 80); return { baseUrl, @@ -297,7 +297,7 @@ function createInstance(name: string, tags: Tags): boolean { tags: { benchmark: 'activity-ramp', hypervisor: config.hypervisor || 'server-default', - run_id: config.runId, + run_id: tags.run_id, }, skip_kernel_headers: true, };