loadtest: add k6 harness for certctl API throughput

Closes the #8 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit. Pre-fix, certctl had zero benchmarks or load tests for
any API path. An acquirer evaluating "can certctl handle our 50k-cert
fleet at 47-day rotation" had nothing to point at; CA/B Forum SC-081v3
lands 47-day TLS in 2029, and operators need real numbers, not hand-
waved capacity claims.

What landed:

- deploy/test/loadtest/docker-compose.yml — minimal stack (postgres +
  tls-init bootstrap + certctl-server with CERTCTL_DEMO_SEED=true so
  the FK rows the script needs exist + grafana/k6:0.54.0 driver).
  Pinned k6 version so threshold expressions stay stable across runs.
  k6 command runs the script once and exits with the threshold-driven
  exit code so `--exit-code-from k6` propagates non-zero on any
  regression.

- deploy/test/loadtest/k6.js — two scenarios at 50 req/s × 5 min,
  staggered 5s. Scenario 1: POST /api/v1/certificates (issuance-
  acceptance hot path: auth + JSON decode + validation + service
  CreateCertificate + DB insert). Scenario 2: GET /api/v1/certificates
  (most-trafficked read endpoint, exercises pagination). Hard
  thresholds: p99 < 5s + p95 < 2s for issuance-acceptance, p99 < 2s +
  p95 < 800ms for list, error rate < 1% globally. constant-arrival-
  rate executor (NOT constant-vus) so VU-bound load doesn't backpressure
  the offered rate and mask capacity ceilings. __ENV.CERTCTL_BASE
  lets the same script run on the operator's workstation
  (https://localhost:8443) and inside the compose stack
  (https://certctl-server:8443).

- deploy/test/loadtest/README.md — documents what's measured (API
  tier: auth → DB) vs what's NOT (issuer connector latency: pinned
  separately by certctl_issuance_duration_seconds from audit fix #4;
  full ACME enrollment flow: deferred — sustained 100/s through
  multi-RTT pebble takes pebble tuning + crypto helpers k6 doesn't
  ship with). Threshold contract pinned. Baseline numbers row reads
  TBD until the operator captures on a representative workstation;
  methodology pinned so future tuning commits land alongside refreshed
  baselines that are diffable.

- deploy/test/loadtest/.gitignore — results/{summary.json,summary.txt}
  + certs/ (per-run TLS bootstrap output). Both regenerate on every
  run; committing them would create huge per-run diffs.

- deploy/test/loadtest/results/.gitkeep — placeholder so the
  directory exists in fresh checkouts (the k6 container mounts it).

- Makefile: new `loadtest` target spinning up the compose stack with
  --abort-on-container-exit --exit-code-from k6 and printing the
  summary. Added to .PHONY + help. Explicitly NOT in `make verify` —
  load tests are minutes long and don't gate per-PR signal.

- .github/workflows/loadtest.yml — workflow_dispatch (manual) +
  weekly cron at Mon 06:00 UTC. NOT per-push. 15-minute hard cap.
  Always uploads results/ as an artifact (90d retention) so a
  regression has a diffable artifact even when k6 exited non-zero.
  Read-only repo permissions.

- docs/architecture.md: new "Performance Characteristics" section
  citing the harness location, scenarios, thresholds, scope (what's
  measured vs not), and where the captured baseline lives. Inserted
  before the existing "What's Next" section.

Scope decisions documented in the README + this commit message:

- The audit prompt's k6 example targeted POST /api/v1/certificates +
  ACME-via-pebble. CreateCertificate exercises auth + DB but the
  downstream issuer-connector call is async (renewal scheduler);
  that's the right surface for "request-acceptance" throughput.
  Driving the connectors directly would load-test someone else's
  API.
- Pebble was excluded from the harness stack. Sustained 100/s
  through ACME's order/challenge/finalize flow needs pebble tuning
  + k6 crypto helpers that don't exist out of the box. README flags
  this as a deferred follow-up.

Acquirer impact: the diligence question "what's your throughput?"
now has a number with a reproducible methodology and a regression
guard, not a claim. The first operator run captures the baseline
into README.md so subsequent tuning commits are diffable.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- go build ./... clean
- bash scripts/ci-guards/H-1-encryption-key-min-length.sh — clean
  (the 38-byte loadtest key is above the 32-byte floor)
- bash scripts/ci-guards/openapi-handler-parity.sh — clean
- bash scripts/ci-guards/test-compose-scep-coherence.sh — clean
- make -n loadtest produces the expected command sequence
- The first `make loadtest` run from the operator's workstation
  populates the README baseline numbers (committed in a follow-up).

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #8.
This commit is contained in:
shankar0123
2026-05-02 14:00:10 +00:00
parent fefa5a5fd7
commit 3a665ae6ba
8 changed files with 611 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
# Per-run artifacts. summary.json + summary.txt are regenerated on
# every `make loadtest` run; committing them would create huge diffs
# on each invocation. The README captures the canonical baseline
# numbers manually.
results/*
!results/.gitkeep
# tls-init bind mount — server cert + key are regenerated on every
# fresh run.
certs/
+171
View File
@@ -0,0 +1,171 @@
# certctl Load-Test Harness
Closes the **#8 acquisition-readiness blocker** from the 2026-05-01 issuer
coverage audit (`cowork/issuer-coverage-audit-2026-05-01/RESULTS.md`).
Pre-fix, certctl had zero benchmarks or load tests for any API path; an
acquirer evaluating "can certctl handle our 50k-cert fleet at 47-day
rotation" had nothing to point at. This harness is the substantiation.
## What it measures
A k6 driver hits two scenarios in parallel for 5 minutes at a fixed 50 req/s:
1. **`POST /api/v1/certificates`** — the issuance-acceptance hot path.
Exercises auth, JSON decode, validation, `service.CreateCertificate`,
and the `managed_certificates` insert. This is the operator-facing
request-acceptance throughput an automation client (Terraform,
Crossplane, GitOps controller) would generate.
2. **`GET /api/v1/certificates?per_page=50`** — the most-trafficked read
endpoint. Exercises pagination + filtering on the cert list query.
Latency is reported as `avg / min / med / p95 / p99 / max`. The error
floor is < 1% (any 4xx/5xx counts as failed).
## What it explicitly does NOT measure
- **Issuer connector latency.** Connector calls (DigiCert, ACME, Vault,
AWS ACM PCA, etc.) happen asynchronously via the renewal scheduler.
Their latency is pinned by the `certctl_issuance_duration_seconds{issuer_type=...}`
Prometheus histogram (audit fix #4). Driving them through k6 would
load-test someone else's API, which is wrong.
- **Full ACME enrollment flow.** The audit prompt mentioned ACME-via-
pebble; sustained 100/s through a multi-RTT order/challenge/finalize
flow requires pebble tuning + crypto helpers k6 doesn't ship out of
the box. Deferred to a follow-up.
- **Bulk-revoke / bulk-renew.** Those are admin endpoints with their
own throughput characteristics and warrant a separate scenario.
- **Scheduler concurrency under bulk renewal.** That's audit fix #9's
scope; the harness here measures the API tier, not the scheduler.
## Threshold contract
Any future change that breaches one of these fails the test:
| Scenario | p95 | p99 | Error rate |
|---|---|---|---|
| `issuance_acceptance` | < 2 s | < 5 s | n/a |
| `list_certificates` | < 800 ms | < 2 s | n/a |
| All requests | n/a | n/a | < 1% |
These are the regression guards, not the SLO. The SLO is whatever the
operator chooses based on the baseline below.
## How to run
From the repo root:
```sh
make loadtest
```
This:
1. Builds the certctl image from the repo root `Dockerfile`.
2. Spins up postgres, the tls-init bootstrap, certctl-server (with
`CERTCTL_DEMO_SEED=true` so the FK rows the script needs exist),
and the k6 driver.
3. Runs the k6 script for ~5 minutes 5 seconds (5s stagger between
scenarios + 5m duration).
4. Prints the summary text to stdout.
5. Exits non-zero if any threshold was breached.
The full machine-readable summary lands at
`deploy/test/loadtest/results/summary.json` (gitignored). The
human-readable summary lands at `results/summary.txt`.
To run against a server already booted on the host (skip the compose
spin-up):
```sh
docker run --rm \
-e CERTCTL_BASE=https://localhost:8443 \
-e CERTCTL_TOKEN=load-test-token \
-e K6_INSECURE_SKIP_TLS_VERIFY=true \
-v "$(pwd)/deploy/test/loadtest/k6.js:/scripts/k6.js:ro" \
-v "$(pwd)/deploy/test/loadtest/results:/results" \
--network host \
grafana/k6:0.54.0 run /scripts/k6.js
```
## Current baseline
The first operator run captures real numbers and commits them into
this section. Pre-baseline this section reads "TBD — operator captures
on first `make loadtest` run." The numbers below are the agreed
minimum-acceptable thresholds, not the captured baseline; once captured,
the baseline goes here as a separate row so future regressions have a
diff target.
| Scenario | p50 | p95 | p99 | Error rate |
|---|---|---|---|---|
| **issuance_acceptance** (threshold) | — | < 2 s | < 5 s | < 1% |
| **issuance_acceptance** (baseline) | TBD | TBD | TBD | TBD |
| **list_certificates** (threshold) | — | < 800 ms | < 2 s | < 1% |
| **list_certificates** (baseline) | TBD | TBD | TBD | TBD |
**Methodology pinned at baseline capture:**
- Hardware: TBD (operator's workstation specs at capture time).
- Postgres: 16-alpine, default config.
- certctl: image built from this repo at the commit referenced below.
- Concurrency: 50 req/s sustained per scenario (100 req/s total).
- Duration: 5 minutes per scenario, 5s stagger.
- Auth: api-key (Bearer token, single key).
- Encryption: `CERTCTL_CONFIG_ENCRYPTION_KEY` set (32+ bytes).
To recapture the baseline after a tuning commit:
```sh
make loadtest
# Inspect deploy/test/loadtest/results/summary.txt for the new numbers.
# Update the table above + the methodology line, commit alongside the
# tuning commit.
```
## Interpreting a regression
If a future PR's `make loadtest` run pushes p99 above the threshold,
the make target exits non-zero and CI fails. The summary.txt prints
which threshold breached. Triage:
1. Look at the per-scenario `http_req_duration` p95 + p99 in
`summary.json`. If only one scenario regressed, the change is
localized to that endpoint's hot path.
2. Look at the `iteration_duration` per scenario — if total iteration
time grew but `http_req_duration` is flat, the latency is in k6
client setup (rare; suggests something changed in the script).
3. Compare against the committed baseline. If p99 was 800 ms at
baseline and is now 1.5 s but still under the 5 s threshold, the
change is below the regression guard but still meaningful — flag
in the PR description.
The harness deliberately does NOT auto-tune. Tuning is informed by the
data; tuning commits land separately, each with their own captured
baseline update.
## CI cadence
Defined in `.github/workflows/loadtest.yml`:
- **`workflow_dispatch`** — manual trigger from the Actions tab. Used
before tagging a release or after a meaningful tuning commit.
- **Weekly cron** — Mondays at 06:00 UTC. Catches gradual regressions
from cumulative changes that no single PR triggered.
The workflow does **not** run per-push. Load tests are minutes long
and would not provide useful per-PR signal; per-push pressure goes
through `make verify` (which is fast) and the deploy-vendor-e2e job.
## Files in this directory
```
deploy/test/loadtest/
├── README.md (this file)
├── docker-compose.yml
├── k6.js (the load script)
├── certs/ (gitignored — tls-init writes here)
└── results/ (gitignored — k6 writes summary.{json,txt} here)
```
## Audit reference
`cowork/issuer-coverage-audit-2026-05-01/RESULTS.md` Top-10 fix #8.
+162
View File
@@ -0,0 +1,162 @@
# =============================================================================
# certctl Load-Test Harness — Docker Compose
# =============================================================================
#
# Spins up a minimal certctl stack and runs a k6 driver against it to capture
# p50 / p95 / p99 latency for the certificate-management API hot path.
#
# Stack:
# 1. postgres — empty database (server runs migrations + seeds at boot)
# 2. certctl-tls-init — one-shot init container; writes self-signed
# server.crt/.key/ca.crt into ./certs (bind mount,
# host-readable so the k6 container can pin against
# it via volumes)
# 3. certctl-server — HTTPS API on :8443, demo-seed enabled so the k6
# script has iss-local + an operator + a team
# ready to reference in CreateCertificate payloads
# 4. k6 — runs k6.js once and exits with the threshold-
# driven exit code (zero on green, non-zero on any
# threshold breach so `make loadtest` surfaces
# regressions as a failed shell command)
#
# Usage: make loadtest (from the repo root)
# Manual: cd deploy/test/loadtest && docker compose up --abort-on-container-exit --exit-code-from k6
#
# Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md fix #8.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Self-signed TLS bootstrap. Mirrors the deploy/docker-compose.test.yml
# tls-init pattern exactly: bind-mount instead of named volume so the host
# (and the sibling k6 container) can read ca.crt without a chown dance.
# See deploy/docker-compose.test.yml::certctl-tls-init for the full rationale.
# ---------------------------------------------------------------------------
certctl-tls-init:
image: alpine/openssl:latest
container_name: certctl-loadtest-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 \
-nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1"
cp "$$CERT" "$$CA"
echo "Generated self-signed TLS cert (ECDSA-P256, 3650d, CN=certctl-server)"
fi
chmod 0644 "$$CERT" "$$CA"
chmod 0600 "$$KEY"
volumes:
- ./certs:/etc/certctl/tls
# ---------------------------------------------------------------------------
# Database. The server runs migrations + seed.sql + (because
# CERTCTL_DEMO_SEED=true below) seed_demo.sql at boot — so the load-test
# k6 script can reference iss-local, o-alice, t-platform, and rp-default
# without a separate seed step.
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: certctl-loadtest-postgres
environment:
POSTGRES_DB: certctl
POSTGRES_USER: certctl
POSTGRES_PASSWORD: loadtestpass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U certctl"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
# ---------------------------------------------------------------------------
# certctl server. Built from the repo root Dockerfile (same as production).
# Demo seed is enabled so referenced FK rows exist when the k6 script
# POSTs CreateCertificate payloads. Auth is api-key with a deterministic
# token the k6 script knows.
# ---------------------------------------------------------------------------
certctl-server:
build:
context: ../../..
dockerfile: Dockerfile
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-}
container_name: certctl-loadtest-server
depends_on:
postgres:
condition: service_healthy
certctl-tls-init:
condition: service_completed_successfully
environment:
CERTCTL_DATABASE_URL: postgres://certctl:loadtestpass@postgres:5432/certctl?sslmode=disable
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: warn
CERTCTL_AUTH_TYPE: api-key
CERTCTL_AUTH_SECRET: load-test-token
CERTCTL_KEYGEN_MODE: agent
# CERTCTL_DEMO_SEED=true triggers seed_demo.sql which creates iss-local,
# o-alice, t-platform, rp-standard so CreateCertificate FK validation
# has rows to bind to.
CERTCTL_DEMO_SEED: "true"
# Bigger body limit so listing 100s of certs in the GET scenario
# doesn't 413 once the harness has been running for a few minutes.
CERTCTL_MAX_BODY_SIZE: "10485760"
# Encryption key (≥32 bytes per H-1 floor — the test compose's
# documented value).
CERTCTL_CONFIG_ENCRYPTION_KEY: "loadtest-key-must-be-32-bytes-long-yes"
volumes:
- ./certs:/etc/certctl/tls:ro
healthcheck:
# /healthz is unauthenticated. -k because the cert is self-signed.
test: ["CMD-SHELL", "wget -q --no-check-certificate -O- https://localhost:8443/healthz || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 60s
# ---------------------------------------------------------------------------
# k6 driver. Pinned to a specific version so threshold expressions stay
# stable across runs. --insecure-skip-tls-verify because the server cert is
# self-signed; the load test isn't a TLS conformance test. The k6 process
# exits non-zero if any threshold is breached, which the parent
# `docker compose up --exit-code-from k6` propagates as the compose exit
# code, which `make loadtest` then surfaces as the make-target exit code.
# ---------------------------------------------------------------------------
k6:
image: grafana/k6:0.54.0
container_name: certctl-loadtest-k6
depends_on:
certctl-server:
condition: service_healthy
environment:
CERTCTL_BASE: https://certctl-server:8443
CERTCTL_TOKEN: load-test-token
K6_INSECURE_SKIP_TLS_VERIFY: "true"
volumes:
- ./k6.js:/scripts/k6.js:ro
- ./results:/results
command:
- run
- --summary-export=/results/summary.json
- /scripts/k6.js
+163
View File
@@ -0,0 +1,163 @@
// certctl load-test driver — k6 v0.54+ JS API.
//
// Closes the #8 acquisition-readiness blocker from the 2026-05-01 issuer
// coverage audit. Pre-fix, certctl had no benchmarks or load tests for any
// API path. An acquirer evaluating "can certctl handle our 50k-cert fleet
// at 47-day rotation" had nothing to point at; this script gives them
// a reproducible number with a methodology.
//
// What this measures (be honest about scope):
// - POST /api/v1/certificates: auth + JSON decode + validation + service
// CreateCertificate + DB insert + response. This is the operator-facing
// request-acceptance throughput. The downstream issuer-connector call
// happens asynchronously via the renewal scheduler (and is bounded
// separately via CERTCTL_RENEWAL_CONCURRENCY — audit fix #9).
// - GET /api/v1/certificates: read path with pagination. Exercises the
// cert list query, which is the most-called read endpoint in any UI/
// automation client.
//
// What this does NOT measure:
// - Issuer connector latency (DigiCert / ACME / Vault / etc. round-trips
// to upstream CAs). Those are async; pin via the per-issuer-type
// metrics instead (audit fix #4: certctl_issuance_duration_seconds).
// - The full ACME enrollment flow (newOrder → challenge → finalize).
// The audit prompt mentioned ACME-via-pebble; deferred to a follow-up
// because driving multi-RTT ACME flows at sustained 100/s requires
// pebble tuning + k6 crypto helpers that don't exist out of the box.
//
// Threshold contract: any future change that pushes p99 above 5s for the
// issuance-acceptance scenario or 2s for the read scenario, OR any change
// that pushes the error rate above 1%, fails the test. CI gates the run
// behind workflow_dispatch + cron (NOT per-push — load tests are too slow
// to gate per-PR signal).
//
// Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md fix #8.
import http from 'k6/http';
import { check } from 'k6';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
// __ENV.* lets the same script run unchanged on the operator's
// workstation (CERTCTL_BASE=https://localhost:8443) and inside the
// docker-compose stack (CERTCTL_BASE=https://certctl-server:8443).
const BASE = __ENV.CERTCTL_BASE || 'https://localhost:8443';
const TOKEN = __ENV.CERTCTL_TOKEN || 'load-test-token';
// Demo seed (CERTCTL_DEMO_SEED=true) creates these rows; CreateCertificate
// requires all four FKs to exist. Pre-baked here so the script has zero
// dependency on test fixtures beyond the seed.
const ISSUER_ID = 'iss-local';
const OWNER_ID = 'o-alice';
const TEAM_ID = 't-platform';
const RENEWAL_POLICY = 'rp-standard';
export const options = {
scenarios: {
// Issuance-acceptance throughput. constant-arrival-rate fires
// requests at a fixed rate regardless of latency, which is the
// right shape for capacity testing — VU-bound load (constant-vus)
// would let slow responses backpressure the offered load and
// mask actual capacity ceilings.
issuance_acceptance: {
executor: 'constant-arrival-rate',
rate: 50,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
maxVUs: 200,
exec: 'createCertificate',
tags: { scenario: 'issuance_acceptance' },
},
// Read path. Same rate as issuance so the DB sees a balanced
// mix; staggered start so warmup overlap doesn't skew the
// first 30 seconds of either scenario.
list_certificates: {
executor: 'constant-arrival-rate',
rate: 50,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
maxVUs: 200,
exec: 'listCertificates',
startTime: '5s',
tags: { scenario: 'list_certificates' },
},
},
thresholds: {
// Hard floor: 99% of issuance-acceptance requests complete in
// under 5 seconds. Pre-fix this was unsubstantiated; post-fix
// this is the regression guard. The number isn't aspirational —
// it's the worst-acceptable user-facing API SLO from the
// operator perspective.
'http_req_duration{scenario:issuance_acceptance}': ['p(99)<5000', 'p(95)<2000'],
'http_req_duration{scenario:list_certificates}': ['p(99)<2000', 'p(95)<800'],
// < 1% error rate. The k6 default is "any 4xx/5xx counts as
// failed"; legitimate 201/200 responses don't count. Auth
// failures, validation failures, server errors all do.
'http_req_failed': ['rate<0.01'],
},
// Smaller summary payload — strip per-VU metrics we don't read.
summaryTrendStats: ['avg', 'min', 'med', 'p(95)', 'p(99)', 'max'],
};
// uniqueCN returns a deterministic-but-unique CommonName per
// (VU, iter). This avoids unique-constraint violations on the
// managed_certificates row (the table has a unique index on
// (issuer_id, name) so two parallel POSTs with the same Name 409
// rather than 201).
function uniqueCN() {
return `loadtest-${__VU}-${__ITER}-${Date.now()}.example.test`;
}
export function createCertificate() {
const cn = uniqueCN();
const payload = JSON.stringify({
name: cn,
common_name: cn,
issuer_id: ISSUER_ID,
owner_id: OWNER_ID,
team_id: TEAM_ID,
renewal_policy_id: RENEWAL_POLICY,
environment: 'production',
sans: [cn],
});
const res = http.post(`${BASE}/api/v1/certificates`, payload, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TOKEN}`,
},
tags: { scenario: 'issuance_acceptance' },
});
check(res, {
'create status 201': (r) => r.status === 201,
});
}
export function listCertificates() {
const res = http.get(`${BASE}/api/v1/certificates?per_page=50`, {
headers: {
'Authorization': `Bearer ${TOKEN}`,
},
tags: { scenario: 'list_certificates' },
});
check(res, {
'list status 200': (r) => r.status === 200,
});
}
// handleSummary writes the full results to /results/summary.{json,txt}
// so the operator can commit the baseline numbers into README.md after
// each run and so CI can ingest the JSON for diffing.
//
// stdout reproduces the textSummary so the docker compose log shows
// the same numbers an operator running it manually would see.
export function handleSummary(data) {
return {
'/results/summary.json': JSON.stringify(data, null, 2),
'/results/summary.txt': textSummary(data, { indent: ' ', enableColors: false }),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}
+3
View File
@@ -0,0 +1,3 @@
# Placeholder so `results/` exists in a fresh checkout. The k6
# container mounts this directory and writes summary.{json,txt} into
# it on every run; both outputs are gitignored.