Declarative load-test plans on top of Nighthawk.
Nighthawk is an excellent load generator and a poor test harness. It takes one set of options, runs one benchmark, and prints one result. sortie adds the layer around it that a benchmarking campaign actually needs: a plan file describing several scenarios, rate shapes over time, fan-out across a pool of Nighthawk instances, and thresholds that turn a run into a pass or a fail with an exit code CI can read.
It drives Nighthawk's existing gRPC control plane — NighthawkService and, for
fan-out, NighthawkDistributor — so the load generation itself is entirely
Nighthawk's, unmodified.
version: v1
pools:
- name: local
services: ["127.0.0.1:8443"]
defaults:
pool: local
target: http://127.0.0.1:8080/
concurrency: "2"
thresholds:
- "counter:benchmark.http_5xx == 0"
scenarios:
- name: ramp
executor:
type: ramping-rate
rate: 500
ramp_time: 30s
duration: 120s
thresholds:
- "latency_2xx.p95 < 50ms"
- "latency_2xx.p99 < 200ms"$ sortie run plan.yaml
ok ramp (ramp to 500 rps over 30s, then hold for 90s, pool "local", 2m1s)
127.0.0.1:8443: 52500 requests in 2m0s
ok counter:benchmark.http_5xx == 0 actual 0 (pool)
ok latency_2xx.p95 < 50ms actual 2.598527ms (per-backend)
ok latency_2xx.p99 < 200ms actual 8.114688ms (per-backend)
PASS 1/1 executions passedbazel build //:sortie
bazel test //...Bazel is the only supported build. The engine's protos are generated by Bazel
from engine/api into packages under github.com/bpalermo/sortie/engine/api/...,
which no Go module publishes, so plain go build cannot resolve them. go.mod
exists for Gazelle and editor tooling and lists the third-party modules only.
Change dependencies through rules_go's SDK — bazel run @rules_go//go -- get <module>@<version> — then bazel mod tidy. Not go mod tidy, which cannot
resolve the Bazel-only proto packages.
bazel run //:gazelle regenerates BUILD files after adding or renaming a Go
file. CI fails if it leaves a diff.
The engine's Envoy dependency is pinned by commit; bazel/bump-envoy.sh <commit>
moves the pin and everything that has to move with it (see AGENTS.md).
| Command | What it does |
|---|---|
sortie run <plan.yaml> |
Run the plan and report a verdict. --json for CI, -o FILE to write the report to a file (stdout then gets the text summary), --results-stream FILE to append each execution's result to a file as it finishes, --progress 5s to narrate each backend's run on stderr. |
sortie validate <plan.yaml> |
Check the plan without running anything. |
sortie compile <plan.yaml> |
Print the CommandLineOptions it would send to each backend. |
Exit code is 0 when every threshold held, 1 when one failed or an execution
errored, and 2 when the plan or command line was invalid.
A pool names the Nighthawk backends a scenario runs on.
pools:
# sortie drives each instance itself and merges the results.
- name: local
services: ["10.0.0.11:8443", "10.0.0.12:8443"]
# A nighthawk_distributor fans the request out on sortie's behalf.
- name: fleet
distributor: "10.0.0.1:8442"
targets: ["10.0.0.11:8443", "10.0.0.12:8443"]The distributor forwards one ExecutionRequest unchanged to every target, so
sortie sends it the per-target share rather than the aggregate. rate therefore
means the same thing on both paths — what the target receives — but on this one
it must divide exactly by targets x concurrency, because a single request
leaves nowhere to put a remainder.
Nighthawk ships no distributor binary. NighthawkDistributor exists in its
sources as a library and is marked experimental (envoyproxy/nighthawk#369), but
no released binary hosts it and nighthawk_service does not. Using a
distributor pool means building a host for that service yourself. The direct
services: pool is the path that works out of the box.
pools:
- name: nodes
dns: nightly-sortie-engine-nodes.loadtest.svc.cluster.local:8443sortie run resolves the host's A and AAAA records once, when it starts, and
drives every address it got at that port for the whole run. A Kubernetes
headless Service answers with one address per ready pod, so a DaemonSet of
engines behind one is a pool of one backend per node that needs no editing
when nodes come and go.
The addresses are sorted, so the backend order is a property of the addresses
and not of the order the name server answered in, and the report says what the
name resolved to: it is the only record of which nodes a run drove. A name that
answers with nothing is retried for up to 30 seconds, because a headless
Service lists a pod only once it is ready; nothing after that is an error
before any load is generated, with exit code 2. With several such pools each
name has its own 30 seconds, and one that has answered is not asked again. A node that joins during a run
gets no load, and one that leaves fails the execution visibly rather than
silently shrinking the pool, without taking the other nodes' results with it. validate and compile accept the pool
without resolving it.
| Type | Shape |
|---|---|
constant-rate |
A fixed aggregate rate for duration. |
ramping-rate |
Linear from zero to rate over ramp_time, then holds rate for the rest of duration. Nighthawk's nighthawk.linear-ramping-rate-limiter-plugin. |
staircase |
One constant-rate step per entry in stages. |
open_loop: true selects Nighthawk's open-loop mode, where the rate limiter
never compensates for a client that cannot keep pace, so saturation shows up as
pool overflow rather than as latency.
A worker thread that has nothing to send waits until the next request is due.
That is sortie's choice, not the engine's: left to itself the engine spins,
which releases each request closest to its time and costs a whole core per
worker at any rate. Measured on one worker sending 60 rps, the engine's idle
strategies cost SPIN 970 millicores, POLL 390, SLEEP 150 and WAIT 26,
and sortie asks for WAIT.
The reason is what the alternative does to the numbers. A load generator runs
beside the thing it measures, usually at tens or hundreds of requests per
second, and often under a CPU limit. Spinning there gets the engine throttled,
and it then reports its own scheduling delay as the target's latency. A user
running 8 targets at 60 rps per engine measured 1.7 cores per engine with
SLEEP and 0.11 with WAIT, with every request sent on both and the same
latencies.
Three things to know about it. The cost still grows with the number of
workers: each wakes at least every 5 ms to see whether the run should end,
about 11 millicores per idle worker. A worker that is woken late ends the run
without the requests that came due while it slept, so a run may send fewer
than rate x duration, and never more. What has been seen is one request
short, 999 of 1000, in about one short run in four on a busy machine, and
none short in a user's two 15-minute runs; that is an observation, not a
bound, since nothing limits how late an overloaded machine wakes a worker.
Write a threshold on a total count as a range with some slack, not an
equality. And waiting has only been measured at low rates.
For a very high rate on a machine with cores to spare, where a worker is
rarely idle and a late wake-up would show, ask for the engine's default:
scenarios:
- name: flood
executor: {type: constant-rate, rate: 50000, duration: 1m}
nighthawk_template:
sequencer_idle_strategy: {value: SPIN}A strategy named in the template is always left alone.
rate is what the target receives. Nighthawk's own --rps is per worker
thread, so sortie divides by backends x concurrency before sending it.
Two things follow, both deliberate:
- A rate no integer per-worker
--rpscan express is an error, not a rounding.rate: 101withconcurrency: "2"is refused. A load generator that silently produces a different rate than the plan asked for is worse than one that refuses. concurrency: "auto"cannot be combined with a rate, because the worker count is decided on the backend and sortie cannot divide by a number it does not know.
executor: {type: constant-rate, rate: 60, duration: 8h, per_backend: true}With per_backend: true, rate (and each stage's rate) is what every backend
generates rather than the pool's total, so the load scales with the pool
instead of being spread over it. It is written for a DNS pool of one engine
per node: 60 rps per node stays 60 rps per node however many nodes the name
resolves to. The divisibility rule then concerns rate / concurrency alone,
and the report labels the rate rps per backend. On a distributor pool it is
what every target receives anyway, since the distributor forwards one request
unchanged to each.
Nighthawk cannot change the rate of a run already in flight — the UpdateRequest
RPC exists in api/client/service.proto but the service rejects it. Each stage
is therefore its own execution: connections are re-established at every
boundary, and each stage is reported and judged separately. A staircase of
hundreds of stages is a run of hours, so each stage's verdict is printed, and
with --results-stream recorded, when the stage ends rather than when the
run does: see a verdict as each execution finishes.
A stage that fails does not stop the ones after it, with one exception: when
a stage is refused because an engine is at its execution cap (see Weighted
targets), the scenario's remaining stages are not attempted. They are listed
in the report as SKIP ("not_run": true in the JSON) with the stage that
was refused, and the plan's other scenarios still run. The refused stage
itself has "refused": "execution_cap" in the JSON, on each of its executions
and on the entries of backend_errors for the engines that refused: those
backends were not lost, and none of this needs the error text to be read.
A scenario can drive several targets at once, each with a share of its rate. This is the shape of a soak that draws a weighted target per request.
scenarios:
- name: mesh
executor: {type: constant-rate, rate: 60, duration: 8h, open_loop: true}
targets:
- {name: orders, url: "http://orders.shop.svc:8080/", weight: 6}
- {name: catalog, url: "http://catalog.shop.svc:8080/", weight: 3}
- {name: search, url: "http://search.shop.svc:8080/", weight: 1}
thresholds:
- "counter:benchmark.http_5xx == 0"Each entry becomes its own execution, labelled <scenario>/<name>, with its
own connection pool, rate limiter, counters and latency histograms. They start
together on every backend in the pool and are reported and judged one by one,
so a threshold on the scenario has to hold for every target, and a failing
target is named. targets replaces target; every other field of the scenario
applies to each target alike, except the request a target describes for itself
(below). defaults may carry either form; a scenario that names a target or a
list of its own takes neither from it.
A target may carry its own method, headers, body or body_file, so reads
and writes run side by side on the same schedule:
targets:
- {name: orders, url: "http://orders.shop.svc:8080/", weight: 6}
- name: submit
url: "http://orders.shop.svc:8080/submit"
weight: 1
method: POST
headers: ["content-type: application/json"]
body: '{"sku": 1}'Each of the four that a target sets replaces the scenario's (after defaults),
and one it leaves out is the scenario's: headers replaces the whole list
rather than adding to it, and body and body_file replace whichever of the
two the scenario had. A target cannot clear what the scenario sets, so what
only some targets need belongs on those targets. The other targets are sent
exactly what they were sent before: a target that describes no request compiles
to the same engine options whatever its neighbours say. The fields are held to
the rules of the target's scheme, so a udp:// target takes a body alone and a
tcp:// one no method or headers; with the method on the HTTP target rather
than on the scenario, one scenario can drive an HTTP, a TCP and a UDP target.
Two things differ from a per-request weighted draw, both deliberate:
- Each target is paced on its own. A target's rate is a fixed
rate x weight / sum(weights), kept by its own rate limiter, not a random share that only averages out to it. Every target gets its rate throughout the run, and one slow target cannot starve the others of their slots. A window short enough to hold a handful of requests can still contain some targets and not others. - Shares are rounded. A share is rounded to the nearest whole request per
second and is never less than 1, so the shares may not sum to
rate: 100 split 1:1:1 is 33 + 33 + 33. The report shows the rate each target ran at. Each share is then divided over backends and workers by the usual rule, so it must be a multiple ofconcurrency.
The backend has to accept one execution per target at a time:
nighthawk_service --max-concurrent-executions N, default 1. The chart sets
engine.maxConcurrentExecutions (default 16). A backend at its limit refuses
the start, and the scenario does not run on part of its slots: as soon as one
start is refused, the executions that did start are cancelled on every backend,
and the scenario fails at once with an error that names the backend, its limit
and the setting to raise. The slots are not reserved ahead of the start, so
for that moment the targets that got one do send load. Behind a distributor
the refusal is recognised and reported the same way, but only once the
distributor answers, and the executions that started there cannot be stopped:
a distributor forwards no cancellation. Every execution has its
own worker threads, so a ten-target scenario at concurrency: "2" runs twenty.
An engine
that allows more than one execution logs at its own level throughout: the log
level is one setting per process, so a verbosity passed through
nighthawk_template is not applied there. Concurrent executions are tested
over HTTP/1; the other protocols and modes have not been run concurrently.
A staircase scenario with targets runs its stages in order, with every target's execution of a stage running together.
A scenario with a grpc block generates gRPC load instead of plain HTTP. It
implies protocol: http2 and method: POST; the request message is the
scenario's body or, for a serialized protobuf, body_file (a path relative to
the plan file).
scenarios:
- name: unary
target: http://grpc.example.test:8080/helloworld.Greeter/SayHello
body_file: hello_request.bin
grpc: {mode: unary}
executor: {type: constant-rate, rate: 1000, duration: 60s}
thresholds:
- "counter:benchmark.grpc_error == 0"
- "latency_grpc_ok.p99 < 20ms"
- name: streaming
target: http://grpc.example.test:8080/echo.Echo/Chat
body_file: chat_message.bin
concurrency: "2"
grpc: {mode: bidi-stream, streams: 20, max_inflight_per_stream: 256}
executor: {type: constant-rate, rate: 4000, duration: 60s}
thresholds:
- "counter:benchmark.stream_deferred == 0"
- "message_latency.p99 < 10ms"| Mode | What it does |
|---|---|
unary |
One gRPC call per request, scored by grpc-status: a failed RPC never counts as http_2xx. Counters benchmark.grpc_error and benchmark.grpc_status.<code>; statistic latency_grpc_ok. |
bidi-stream |
streams long-lived bidirectional streams per backend; the message is sent round-robin at the executor's rate and each one is timed against its echo (benchmark_stream.message_latency). The server must echo one message per message, in order. A stream holding max_inflight_per_stream unanswered messages drops the next scheduled send into benchmark.stream_deferred -- the saturation signal. |
In bidi-stream the engine spreads a backend's share of the rate over its own
workers, so sortie sends each backend its aggregate rather than a per-worker
rate. The split is still made in per-worker units -- the plan's rate must be
a multiple of the scenario's concurrency, which has to be a number, as must
streams -- so 300 rps over two backends with four workers is 152 and 148.
sortie compile shows the result.
scenarios:
- name: chat
target: http://gateway.example.test/ws # the upgrade request's URL, not ws://
body: '{"type":"ping"}'
concurrency: "2"
websocket:
streams: 40 # connections per backend; default 20
max_inflight_per_stream: 16 # default 256
binary: false # text frames unless true
executor: {type: constant-rate, rate: 2000, duration: 60s}
thresholds:
- "benchmark_stream.message_latency.p99 < 50ms"
- "counter:benchmark.stream_deferred == 0"Each backend upgrades streams HTTP/1.1 connections (through Envoy's own
upgrade path, so an Envoy in front with upgrade_configs: [{upgrade_type: websocket}] is exactly what gets tested) and sends the message on them
round-robin at its share of the executor's rate -- the rate is pool-wide and
divided between backends, as always -- each timed against its echo. The server is
expected to echo what it receives; sortie prefixes every message with a
sequence number to match echoes, so an endpoint that answers something else
counts in benchmark.stream_unexpected_message. Everything said about
bidi-stream applies: the rate is a backend's aggregate, streams and the
rate must be multiples of concurrency, a full connection drops the next send
into benchmark.stream_deferred, and the counters and statistic carry the same
names (benchmark.stream_*, benchmark_stream.message_latency), plus
stream_upgrade_rejected and stream_protocol_errors. Every stream is a
connection: sortie sets the engine's connection cap to the streams per worker
unless the plan sets connections. Implies protocol: http1 and method: GET; mutually exclusive with grpc. nighthawk_test_server's
websocket-echo filter is the target the e2e test uses.
An https:// target needs nothing more than that: the engine verifies
nothing and presents no certificate, so a self-signed target works out of the
box. tls turns on what a real one needs:
defaults:
target: https://gateway.example.test/
tls:
ca_file: certs/ca.pem # verify the target against these roots
cert_file: certs/client.pem # present this certificate when asked (mTLS)
key_file: certs/client-key.pemPaths are relative to the plan file. sortie reads the files and sends them to
the backends inline, so a backend needs no access to them -- which also means
the plaintext channel to a backend (see Pools) carries the client key. What
sortie prints (sortie compile) and what the engine logs replace the key
with a note of its size; the options proto itself carries it. SNI
follows the target's host, or a Host header; ALPN follows protocol.
Anything beyond this -- cipher suites, a SPIFFE validator, a different
transport socket altogether -- goes through nighthawk_template: its
tls_context gets the same SNI and ALPN treatment, its transport_socket
replaces the whole socket.
scenarios:
- name: raw
target: tcp://edge.example.test:9000 # tcps:// for a TLS-terminating listener
body: "ping\n"
concurrency: "2"
tcp:
connections: 4 # per worker; default 1
expect_echo: true # default; false for a sink that answers nothing
max_messages_per_connection: 600 # a new connection every 600 messages; default 0, never
executor: {type: constant-rate, rate: 2000, duration: 60s}
thresholds:
- "benchmark_tcp.message_latency.p99 < 20ms"
- "counter:benchmark.tcp_deferred == 0"
- "counter:benchmark.tcp_connection_closed == 0"
- "counter:benchmark.tcp_echo_mismatch == 0"For Envoy's tcp_proxy and TLS-terminating listeners in front of non-HTTP
services. The target's scheme selects the mode (an explicit port is
required); tcp tunes it. Each worker keeps connections connections open
and writes the message, as is, on them round-robin at the executor's rate --
per worker, as for HTTP, so the rate is divided by backends x workers as
usual. With expect_echo a write completes when those bytes come back on the
same connection -- matched in order, which is what a TCP connection gives --
and that round trip is the latency (benchmark_tcp.message_latency). Without
it the write completes at once and nothing is read or timed: a write only
queues bytes locally. tls works with tcps://.
method, headers, protocol, grpc and websocket are errors with a tcp
target.
expect_echo needs an exact echo: the message's bytes, nothing before,
between or after them. There is no framing to tell replies apart, so a target
that prefixes or rewrites its reply cannot be matched -- and cut into
message-sized pieces, such a stream lines up with the message every so often
by chance, which would count as an echo and be timed against a send it has
nothing to do with. So the first message's worth of bytes on a connection
that is not the message (or that arrives with nothing outstanding) ends the
matching on that connection: it counts in benchmark.tcp_echo_mismatch, the
connection's outstanding messages are tcp_inflight_lost and are not timed,
and the connection is closed and reopened as above. A latency is therefore
only ever recorded for an echo before which everything its connection
returned was exactly what was sent; against a target that is not an echo
benchmark_tcp.message_latency stays empty rather than filling with numbers
that look real, tcp_messages_received stays at zero, and most of the run is
tcp_unavailable, since each reopened connection lasts one reply and the
backoff grows to a second. The engine logs an error naming the cause, once
per worker, and the text report puts a warning under the backend's line.
Mismatches do not fail an execution by themselves: add
"counter:benchmark.tcp_echo_mismatch == 0" to the thresholds for that, or
set expect_echo: false for a target that is not meant to echo.
A connection that closes is reopened. Whether the peer closed or reset it
or a connect failed, the worker connects again after a backoff: 10 ms,
doubling with each consecutive retry up to 1 s, and back to 10 ms once the
connection has gone 2 s without needing one. So a target that is rolled is
picked up again within a second of coming back, and a dead one is tried once a
second per connection, not in a loop. The run stays open-loop meanwhile:
messages that come due while a connection is away are dropped and counted in
benchmark.tcp_unavailable, not held back and sent in a burst, and messages
unanswered on the connection when it closed are benchmark.tcp_inflight_lost.
tcp_connection_closed counts the closes, tcp_reconnects the times a
connection came back.
max_messages_per_connection makes a run keep setting connections up:
once a connection has sent that many messages it is replaced. The replacement
is opened while the old connection goes on sending, and takes over when it has
connected, so rotating neither drops nor delays a message -- and a connection
carries the configured number plus whatever came due while its replacement
connected (none, or a few). The old connection is sent nothing more, is given
up to the scenario's timeout for its outstanding echoes (still missing then:
tcp_inflight_lost), and is closed. A replacement that fails to connect is
retried with the same backoff while the old connection keeps serving. At 100
messages a second per connection, 600 is a new connection every 6 s. There
is no time-based lifetime; at a constant rate the message count is one.
Every successful connect -- the first ones, reconnects, rotations -- is timed
from connect() to connected, TLS handshake included, in
benchmark_tcp.connect_latency. To read connection setup from the counters:
tcp_connections_opened is every connect that succeeded (the workers' first
connections + tcp_reconnects + tcp_connections_rotated) and
tcp_connect_failures every attempt that did not (refused, reset, or not
connected within timeout), so the success rate is opened / (opened +
failures).
Counters: benchmark.tcp_connections_opened, tcp_connect_failures,
tcp_reconnects, tcp_connections_rotated, tcp_messages_sent,
tcp_messages_received, tcp_deferred, tcp_unavailable,
tcp_connection_closed, tcp_echo_mismatch, tcp_inflight_lost,
tcp_drain_incomplete, tcp_write_blocked. Envoy's echo network filter on
nighthawk_test_server is the target the e2e test uses.
scenarios:
- name: dns-like
target: udp://edge.example.test:5300
body_file: query.bin
concurrency: "2"
udp:
max_inflight: 64 # default 256
timeout: 500ms # a datagram unanswered this long is lost; default 1s
executor: {type: constant-rate, rate: 4000, duration: 60s}
thresholds:
- "benchmark_udp.message_latency.p99 < 10ms"
- "rate:benchmark.udp_lost < 1"For Envoy's udp_proxy. One datagram socket per worker, connected to the
target; each scheduled request sends the message as one datagram -- per
worker, as for HTTP -- prefixed with a sequence number, and completes when a
datagram carrying that number comes back (datagrams reorder and vanish, so the
match is by number, not order), with the round trip as
benchmark_udp.message_latency. A datagram unanswered for timeout is lost
and counted in benchmark.udp_lost -- that, not a connection error, is what a
proxy drops. method, headers, protocol, grpc, websocket and tcp
are errors with a udp target; a body is required. Counters:
benchmark.udp_datagrams_sent, udp_datagrams_received, udp_lost,
udp_unexpected (a duplicate, a late echo, or not an echo), udp_send_errors,
udp_deferred, udp_unavailable. nighthawk_test_server's udp-echo
listener filter is the target the e2e test uses.
nighthawk_template on a scenario carries Nighthawk options straight through.
sortie overlays only the fields it owns — requests_per_second, duration,
execution_id, the rate-limiter plugin, and whatever the scenario's own fields
set — so everything else reaches the backend as written.
scenarios:
- name: passthrough
executor: {type: constant-rate, rate: 100, duration: 30s}
nighthawk_template:
max_requests_per_connection: 1000
burst_size: 5This mirrors how upstream Nighthawk's adaptive load controller (not carried in engine/) takes a
nighthawk_traffic_template, and it means sortie does not have to grow a field
for every Nighthawk flag — transport sockets, request-source plugins,
tunnelling and user-defined output plugins are all reachable without one.
The report arrives when a run ends. To watch a run while it is running, give
the plan a stats block and the engine flushes its metrics to a statsd server:
stats:
flush_interval: 5s
statsd:
address: "10.96.14.7:8125" # an OpenTelemetry collector's statsd receiverA stats block can sit at three levels, and the most specific one wins
whole: the top of the plan, then defaults.stats, then a scenario's own. Each
replaces the one before it rather than merging with it, so a scenario with no
block gets defaults.stats when there is one and the plan's only when there is
not. The report at the end is unaffected.
What goes out is everything the engine keeps in its Envoy stats store: the
benchmark.* counters thresholds are written against, Envoy's own cluster
counters (upstream_cx_total, upstream_rq_total, and so on), and every
latency sample as a statsd timer in milliseconds. Names are
<prefix>.<scenario>.<backend>.cluster.<worker>.benchmark.http_2xx counter
<prefix>.<scenario>.<backend>.cluster.<worker>.benchmark_http_client.latency_2xx timer, ms
prefix defaults to sortie. The scenario's label is lowercased and reduced
to [a-z0-9_], with a / becoming a level of its own, so Live Metrics
is sortie.live_metrics and stage 2 of a staircase named ramp is
sortie.ramp.stage_2. That is what tells scenarios, stages and targets apart
on a dashboard.
<backend> is the engine the metric came from. Every backend of a pool runs
the same execution, and without it they would all write the same series, which
a statsd server would show as one backend's worth. With it there is a series
per engine. Counters are per worker: sum over workers for a backend's total,
and over backends for the pool's, which is what the report's totals are.
What names the engine is stats.backend, and there are two answers:
stats:
backend: name # or address, the default
statsd:
address: "10.96.14.7:8125"address, the default: the engine's address without the port, sanitized the same way, so10.0.0.11:8443is10_0_0_11, or host and port together when two engines share a host. Should two still read alike after that, as two IPv6 addresses can, each gets its position in the pool appended (_b0,_b1), so no two backends ever share a series. This is for pools sortie dispatches to itself, listed or found through DNS. Behind a distributor every target is sent the same options, so their metrics keep the prefix without a backend and land on one series.name: the name the engine was started with,nighthawk_service --backend-name, sanitized the same way (Node-A.exampleisnode_a_example). An address is only as stable as what it belongs to. A pod's changes with every install and every restart, so each run of a DaemonSet of engines writes a whole new set of series into the store, and yesterday's run and today's cannot be laid over each other. A node's name does not change. sortie knows a backend only by its address, so it sends a placeholder (%BACKEND%) where the name goes and each engine puts its own in. That works behind a distributor too, whose targets then have a series each.
Two things follow from sortie not knowing the names. An engine started without
one refuses the execution rather than emit the placeholder as a metric name,
and the report shows that backend's error. And nothing can check that two
engines of a pool end up named differently: two with one name write one
series. "Differently" means after the name is reduced to [a-z0-9_], which
is many-to-one: nodes called node-a and node.a are both node_a. So the
names have to stay distinct once punctuation and case are gone, which most
naming schemes satisfy and none guarantees. The chart supplies a name with
engine.backendNameFrom: node (the node's name, right for a DaemonSet) or
pod (the pod's name, unique among a Deployment's replicas but new whenever a
pod is replaced), and engine.backendName is a literal for a single engine;
it cannot check the result either. A name must be ASCII.
Three limits, each deliberate:
- The statsd address is an IP, not a name. Envoy's statsd sink does not resolve names, and a name would fail on the backend with the plan already dispatched, so the plan is refused instead. In Kubernetes use the Service's cluster IP.
- UDP only. Envoy's TCP statsd sink addresses a cluster of the bootstrap, and the engine writes its bootstrap itself.
- No OpenTelemetry sink. Envoy's OTLP sink aborts the engine on its first flush here: its gRPC client must be used on the thread that created it, and the engine creates sinks on its main thread and flushes them on another. To reach an OpenTelemetry collector, send statsd to its statsd receiver.
stats.sinks takes other Envoy stats sinks, written as they would be in an
Envoy bootstrap, and passes them through, so the prefix scheme above does not
apply to them. It reaches the sinks whose configuration is an
envoy.config.metrics.v3 message -- dog_statsd, statsd, hystrix,
metrics_service -- because sortie decodes the plan's typed_config and
links those types only. A sink configured by an extension's own message, such
as graphite_statsd, fails when the plan is parsed. The OpenTelemetry sink is
refused there too, and so is the engine's own adapter: name the Envoy sink and
sortie wraps it. A StatsdSink or DogStatsdSink written out there follows
the same rules as the statsd block: an IP and a port, and no
tcp_cluster_name. Its prefix is yours to write, and %BACKEND% in it is
replaced with the engine's name as above, whatever stats.backend says.
Because the statsd prefix is built from sanitized labels, two scenarios can
land on one: names that differ only in case or punctuation, or a scenario
named ramp/stage-1 beside a staircase named ramp. A plan in which two
statsd-reporting executions would share a prefix is refused; rename one, or
give them different prefix values.
Thresholds are <metric> <op> <value> strings. Operators are <, <=, >,
>=, ==, !=. Plan-level thresholds and scenario-level thresholds are
additive; both must hold.
| Selector | Meaning |
|---|---|
latency_2xx.p95 |
A percentile of a statistic. Fractional percentiles like p99.9 work. |
latency_2xx.mean |
mean, min, max, pstdev or count. |
counter:benchmark.http_5xx |
A counter's absolute value. |
rate:benchmark.http_2xx |
A counter divided by the execution duration — the achieved rate. |
Statistic and counter names match exactly or by dotted suffix, so latency_2xx
resolves benchmark_http_client.latency_2xx. An ambiguous suffix is an error
rather than an arbitrary pick.
Duration-valued metrics need duration values (500ms); counters and rates need
plain numbers. Mixing them is an error, not a comparison against nanoseconds.
A counter that never incremented is absent from Nighthawk's output rather than
present and zero, so a missing counter reads as zero — counter:benchmark.http_5xx == 0
passes on a clean run. A threshold naming a statistic that does not exist fails,
because it went unchecked rather than being satisfied.
A failed request does not end a run. The engine's own defaults stop an
execution the moment it counts one 4xx, 5xx, failed connection or reset, which
suits a benchmark that means nothing once the target misbehaves. sortie turns
them off: an execution lasts its duration, counts what goes wrong, and the
thresholds judge it. One 503 three hours into a soak is a number in the
report, not the end of that target's load.
A plan that does want an early stop asks for it, and what it asks for is left alone:
nighthawk_template:
failure_predicates: {"benchmark.http_5xx": 100} # stop past 100 of themAn execution the engine stops this way fails, and still reports what it had counted up to then.
Backends are independent. One that cannot be reached, or goes away during a run -- a node rebooting under an engine-per-node pool -- fails the execution and is named in the report, and the other backends carry on and are reported and judged as usual. Counter and rate thresholds are then judged against the survivors' totals, so a threshold on a total count will show the gap; the execution fails either way. Only when no backend returns anything is there nothing to report.
A backend can also be lost without a sound: a node that freezes or drops off
the network sends nothing at all, and a connection can wait on it for ever.
So every backend has a deadline, counted from dispatch: the execution's
duration, plus every wait the plan asks of the engine after it -- the
timeout, and the drain window of a gRPC stream, WebSocket, TCP or UDP run --
plus two minutes, and counted from its scheduled start when the plan gives it
one. One that has not
answered by then is cancelled and reported as silent, and the run ends with
the others' results instead of hanging with none. The deadline is wall time,
so a sortie that was itself stopped for a while -- its node frozen -- can wake
past it with the results waiting: an answer that is there, or arrives within
ten seconds of the deadline being noticed, is taken as the result. The same margin is applied
to what a backend says of itself: a result that claims to have run far longer
than planned -- a node frozen halfway and thawed later -- fails that backend
too. Its numbers stay in the report, marked as not those of the plan.
These are the counters a failed request shows up in. The finer ones refine
the two older ones, stream_resets and pool_connection_failure, without
changing them. They are not a partition: do not add them up to count failed
requests. A unary gRPC call that fails is in grpc_error and also in whatever
stopped it -- stream_resets for a reset, its HTTP status class for a
non-2xx response.
| Counter | A request that |
|---|---|
benchmark.http_4xx, benchmark.http_5xx |
got a complete response with that status |
benchmark.grpc_error |
was a unary gRPC call that ended with a status other than OK |
benchmark.stream_resets |
had its stream reset, in any way |
benchmark.stream_resets_before_headers |
was reset before the final response headers arrived; an informational response such as 103 Early Hints does not count as having arrived |
benchmark.stream_resets_incomplete_body |
was reset after the headers, before the body or trailers finished |
benchmark.stream_resets_<reason> |
was reset for that reason: Envoy's StreamResetReason in snake case, such as connection_termination, remote_reset, protocol_error |
benchmark.pool_connection_failure |
never got a connection, because connecting failed |
benchmark.pool_failure_local_connection_failure, benchmark.pool_failure_remote_connection_failure |
the same, by which side failed |
benchmark.pool_failure_timeout |
never got a connection, because connecting timed out |
benchmark.pool_overflow |
was refused by the client's own pool; in open loop this is saturation |
benchmark.http_inflight_lost |
was sent, or was queued for a connection, and had no outcome when the run was over: see below |
For plain HTTP and unary gRPC requests the two phase counters sum to
stream_resets, and so do the reason counters. The streaming modes
(bidi-stream, WebSocket) count a reset of one of their long-lived streams in
stream_resets alone.
pool_failure_timeout is outside pool_connection_failure, which never
included it.
A run does not end the instant its duration does. The requests that are in
flight at that moment -- sent and unanswered, or still queued for a connection
-- are waited for, for up to the scenario's timeout, and are counted and
timed as what they turn out to be. That wait is not part of the reported
execution duration, so it changes no rate. A request that is still without an
outcome when the timeout has passed is counted in
benchmark.http_inflight_lost, and in nothing else: it was not reset, and it
has no status. With that counter no request that was issued is missing from
the report. A run that is cancelled, or that a failure predicate ends, does
not wait: its in-flight requests are counted as lost at once. A run that a
termination predicate ends does wait, so its counters can pass the predicate's
threshold by the requests that were in flight. The engine does not fail a run
for http_inflight_lost; a threshold does, if the plan has one.
A zero-failure soak names every class, which costs nothing: a counter that never incremented reads as zero.
thresholds:
- "counter:benchmark.http_4xx == 0"
- "counter:benchmark.http_5xx == 0"
- "counter:benchmark.grpc_error == 0"
- "counter:benchmark.stream_resets == 0"
- "counter:benchmark.pool_connection_failure == 0"
- "counter:benchmark.pool_failure_timeout == 0"
- "counter:benchmark.pool_overflow == 0"
- "counter:benchmark.http_inflight_lost == 0"Leave pool_overflow out and an open-loop run in which the client refused
every request itself passes with nothing sent. Leave http_4xx out only if the
target is meant to answer with one.
Three things are not failure classes, and why:
- A disconnect after a complete response. A server that closes the
connection once the whole response has arrived -- an HTTP/3 server going away
between requests does this -- is not a reset. The request has already been
counted under its status, and none of Envoy's codecs raise a reset on a
stream that is complete in both directions. It shows only in Envoy's
connection counters,
upstream_cx_destroy_remote. Sostream_resets_incomplete_bodyis exactly the real mid-body cut. - A response timeout. The engine has no per-request one: a request waits
for its response until the run ends and for
timeoutafter that, and is then inhttp_inflight_lost.timeouton a scenario bounds connecting and that final wait. - A DNS failure. The target is resolved once, when an execution starts. A name that does not resolve fails the execution with an error, before any request.
The report prints a backend's non-zero failure counters beside its request
count, and lists them under failures in JSON. The JSON also carries, per
execution, results -- each backend's benchmark.* counters, elapsed time
and statistics -- totals, the counters summed over the pool, and
backend_errors for any backend that did not finish cleanly.
sent is how many requests were put on a connection to the target, answered
or not: on each entry of results for that backend, and on the execution as
their sum -- for a weighted scenario, one number per target. It is Envoy's
upstream_rq_total, kept beside totals and not in them. A request counts
from the moment it is given a connection, so one turned away by a full queue
(pool_overflow) or still waiting for a connection when the run ended is not
in it, and one whose connection broke while it was being written is. It is
therefore the number to set against the target's own count of what it
received, and an upper bound on it. The text report adds , N sent to a
backend's line only when N is not the request count beside it. HTTP and unary
gRPC only: the stream, TCP and UDP modes report what they sent in their own
counters. It is absent when zero.
Each execution is placed in time, so a failed stage can be matched to what
else was happening. started_at is when sortie began dispatching it and
ended_at when its last backend had answered or been given up on, which is
started_at plus elapsed_ms: RFC 3339, UTC, to the millisecond, by the
clock of the machine sortie runs on. The text report gives the start on each
execution's first line. The two bracket the load rather than time it:
connecting to the backends happens inside them, and so does the wait of an
execution the plan gave a scheduled start. Closer to when the load began is
each backend's own started_at in results: when the first of its workers
started its rate limiter's clock, by the engine's clock. That is just before
a request is asked for, so it is not proof that one was sent; it is absent
when the engine gave no timestamp.
statistics is each statistic that recorded anything, by id, with count,
mean, pstdev, min, max, p50, p90, p99 and p99.9: in
nanoseconds when unit is ns, plain numbers when it is raw. The
percentiles are resolved as thresholds resolve them, so a p99 in the report
is the one a threshold on that statistic was judged against. They are per
backend only, for the reason given above: a pool-wide percentile cannot be had
from the backends' own.
The report is written when the run ends. A run of hours should not have to end, or end well, before a stage that failed in its first hour is known, so every execution leaves two records the moment it finishes.
One is a line on stderr, beside the running line that announced it, with or
without --progress:
running steps/stage-40 (400 rps for 1m0s) on 10.0.0.1:8443, 10.0.0.2:8443
PASS steps/stage-40 (scenario steps, 1m0.412s)
running steps/stage-41 (410 rps for 1m0s) on 10.0.0.1:8443, 10.0.0.2:8443
FAIL steps/stage-41 (scenario steps, 1m0.398s): latency_2xx.p95 < 50ms actual 12ms, 81ms
PASS or FAIL, the label, the scenario and the elapsed time; a failure adds
what failed it, separated by ; : the execution's own error, each backend
that did not finish cleanly, and each threshold that did not hold with the
values observed (one per backend for a latency threshold, in the pool's
order). Thresholds that held are left to the report. It is always one line,
so grep '^ FAIL ' over a pod's log finds every failed stage so far.
The other is --results-stream FILE: each finished execution is appended to
the file as one line of JSON (JSON Lines). A line is
exactly the object that execution is in executions of the --json report --
same keys, same values, started_at and ended_at included -- so whatever
reads the report's executions reads the stream's lines, and the stream of a
run that finished holds the report's executions in order:
{"label":"steps/stage-41","scenario":"steps","pool":"nodes","rate":410,"duration_ms":60000,"elapsed_ms":60398,"started_at":"2026-10-07T12:40:07.118Z","ended_at":"2026-10-07T12:41:07.516Z","pass":false,"backends":["10.0.0.1:8443","10.0.0.2:8443"],"thresholds":[{"expr":"latency_2xx.p95 \u003c 50ms","scope":"per-backend","pass":false,"actual":"12ms, 81ms"}],"totals":{...},"results":[...]}
Each line is written whole, in one write, and synced to disk as its execution
finishes, so a reader following the file can parse every line that ends in a
newline, and what was written survives the pod. The targets of a weighted
scenario finish together and are written together, in plan order. The file is appended to,
never truncated: a run that is retried, or the next run given the same file,
adds its lines after the ones already there, and started_at tells them
apart. Delete or rename the file between runs to keep one run in it. A line
that cannot be written is reported on stderr and the run carries on, as is
a line that was written but could not be synced to disk; a file that cannot
be opened is refused before any load, as are - and anything that is stdout
or stderr under another name, since stdout carries the
report, the report's own file under any name, and a file that has content
this user cannot read back, since its end cannot be checked. A file that ends in part
of a line, left by a run that died or by a write that failed half way, is not
repaired: the part is closed off as a line of its own, which does not parse,
and the lines after it are whole. A reader skips a line it cannot parse. The
stream does not replace the report, which is still written at
the end, and it has no overall pass: that is known only then.
Nighthawk returns an HdrHistogram's own buckets, not round percentiles. A
request for p95 resolves to the first bucket at or above 0.95, which is the
same rule nighthawk_client applies to its own human-readable output.
Counters sum, and counter and rate thresholds are judged against the pool total.
Latency statistics do not aggregate. A pool-wide p95 cannot be recovered from per-backend p95s, which is why Nighthawk's own sink service merges Outputs by appending results rather than combining them. sortie evaluates a percentile or statistic threshold against every backend individually and requires all of them to hold. For a single-backend pool this is exactly the obvious behaviour.
What sortie drives, against what Envoy does, and how each piece was designed is in docs/parity.md: HTTP/1-3, gRPC, TLS, WebSocket, raw TCP and UDP are all in.
- No mid-run updates. A run's rate cannot be changed once started
(
UpdateRequestis declared in the engine's API and rejected). It can be stopped: interruptingsortie runcancels every backend's execution in aservices:pool and the run is reported as cancelled, not evaluated. A distributor pool is the exception: the distributor RPC is abandoned and its targets run to their configured duration, because nothing here hosts a distributor that forwards cancellations. - Progress is opt-in, and advisory.
sortie run --progress 5sasks each backend for a snapshot of its run that often and prints a line per execution and backend on stderr:<label> <backend> <elapsed> http_2xx N ... mean X max Y. A snapshot carries the live counters and, for each latency statistic, its count, mean, standard deviation, min and max. It carries no percentiles, so the line has no p99: a percentile needs a copy of every worker's histograms (about 3 MiB each, 14 per worker), and those copies are what once made a snapshot cost hundreds of megabytes that the engine did not give back. A snapshot now allocates no histogram, and an engine's memory is the same with--progressas without. The engine's API can still be asked for full statistics per snapshot (StartRequest.progress_statistics), at that price; sortie does not ask. A snapshot is not free of CPU, though: each one asks every worker of every execution for its summaries. A user running 8 concurrent executions per engine measured about 0.6 CPU-seconds per engine per snapshot, which is 19 millicores averaged over a 30 s interval and 10 over 60 s. Under theWAITidle strategy that is a visible share of an engine's whole cost, so pick the interval with it in mind. The verdict comes from the final response only. Backends behind a distributor report nothing until they finish. - An engine's memory is its histograms. Every worker of every execution
holds about 37 MiB of latency histograms for as long as it runs, and
assembling the final report copies them. Measured on one engine running 8
executions at once, one worker each, 64 rps in total against a local
target (an unoptimized x86-64 build): 123 MiB idle, 421 MiB while running,
with or without
--progress 1s, and a peak of 722 MiB for the instant the final reports are assembled. A user running 8 concurrent executions at 60 rps in total reported about 300 MiB steady and about 600 MiB at that peak. Budget for the peak, not the steady state: a memory limit below it kills the engine as the run ends. After a run the engine keeps part of that resident (340-400 MiB in the measurement above) until the next one reuses it. - No scripting. Nighthawk's
RequestSourceyields independent requests and never sees responses, so there is no session flow — no login, capture a token, reuse it. Scenarios are stateless load. - Scenarios run one after another. Only the targets of one scenario run
at the same time (see Weighted targets); two scenarios never overlap.
nighthawk_serviceruns as many executions at once as--max-concurrent-executionsallows, one by default, and refuses the rest.
Two multi-arch images and a Helm chart are published to Quay on every push to main:
quay.io/sortie/sortie linux/amd64, linux/arm64
quay.io/sortie/engine linux/amd64, linux/arm64
oci://quay.io/sortie/chart-sortie the chart
Commits before 2026-10-04 were published to ghcr.io/bpalermo/sortie,
ghcr.io/bpalermo/sortie/engine and
oci://ghcr.io/bpalermo/sortie/charts/sortie, and stay there; nothing newer
is pushed to them. Where things are published is one setting,
bazel/registry.bzl.
The engine image carries nighthawk_service (its entrypoint),
nighthawk_test_server and nighthawk_client, built from engine/ in this
repository at the same commit as sortie. Each architecture is built natively
and the two are stitched into one index. With engine.enabled=true the chart
also deploys nighthawk_service behind a Service named <release>-sortie-engine
on port 8443, for a plan's services: to name.
The chart runs a plan as a Job, or as a CronJob with cronJob.enabled=true.
The plan is held in a ConfigMap and mounted read-only, so changing a run does
not mean republishing anything. The Job's exit code is the verdict: a breached
threshold fails it, and a malformed plan fails it differently.
To load every node's proxy from that node -- a service-mesh soak -- run the engine as a DaemonSet and let the plan find its pods:
# The engines first, on every node, and wait for them all.
helm install nightly oci://quay.io/sortie/chart-sortie --version <chart version> \
--namespace loadtest --values values.yaml --set job.enabled=false
kubectl --namespace loadtest rollout status daemonset/nightly-sortie-engine
# Then the run.
helm upgrade nightly oci://quay.io/sortie/chart-sortie --version <chart version> \
--namespace loadtest --values values.yaml --set-file plan=plan.yamlTwo steps, because the plan's name is resolved once, when the run starts, and the first non-empty answer is taken: a Job installed together with the DaemonSet would drive whichever engine pods happened to be ready first and leave the other nodes out for the whole soak.
# values.yaml
engine:
enabled: true
kind: DaemonSet # behind a headless Service, <release>-sortie-engine-nodes
backendNameFrom: node # live metrics are keyed by node, not by pod IP
priorityClassName: loadgen # a class you create; see below
tolerations: [{operator: Exists}]
podAnnotations: {mesh.example.com/inject: "true"}
resources:
requests: {cpu: 500m, memory: 256Mi}
podAnnotations: {mesh.example.com/inject: "true"} # the sortie pod itself# plan.yaml
version: v1
pools:
- name: nodes
dns: nightly-sortie-engine-nodes.loadtest.svc.cluster.local:8443
stats:
backend: name # the engine's node; see Live metrics
statsd:
address: "10.96.14.7:8125"
scenarios:
- name: soak
pool: nodes
target: http://orders.shop.svc.cluster.local:8080/
executor:
type: constant-rate
rate: 60
duration: 8h
per_backend: true
open_loop: true
thresholds:
- "counter:benchmark.http_5xx == 0"To watch the soak node by node, the plan sends live metrics
with backend: name and the engines take their names from their nodes
(engine.backendNameFrom: node). The series are then
sortie.soak.<node>.cluster..., the same ones on every run; named by address
they would be keyed by pod IP and start over with every install.
The same series on every run means each run's numbers follow the last run's
on it. The engine counts every run from zero, but what it sends is what a
counter gained since the last flush, so a receiver that keeps a running total
adds the next run to it: a series it has kept does not drop at a run
boundary. It starts again from zero only when the receiver had expired the
series between runs, or was itself restarted. Either can happen, so read
counters across runs with rate() or increase(), which take a drop as a
reset, and do not take a total to be one run's count.
The engine is the pod that opens the connections, so it is the one a mesh has
to inject; engine.podAnnotations and engine.podLabels are for that, and the
same pair at the top level is for the sortie pod, which a mesh treats like any
other.
A DaemonSet pod cannot move, so when its node fills up it is evicted rather
than rescheduled and the run loses that node. engine.priorityClassName names
a PriorityClass above the workloads under test, which makes one of them the
eviction victim instead. The class is cluster-scoped and not the chart's to
own, so create it yourself.
A CronJob has the same property for free: by the time its schedule fires, the engines have long been up.
The report is the pod's log unless you keep it. report.path with a
report.volume writes it to a file on a volume of your choosing, as JSON by
default, and the readable summary still goes to the log:
report:
path: /var/run/sortie/report.json
volume:
persistentVolumeClaim: {claimName: soak-reports}
podSecurityContext: {runAsNonRoot: true, fsGroup: 65532}report.stream with a file name, say results.jsonl, adds
--results-stream: each execution's result is appended to that file, beside
the report on the same volume, as the execution finishes (see
a verdict as each execution finishes).
For a run of hours it is the part of the report that exists if the pod does
not live to write the rest.
The next run on the same volume and path overwrites the report, and appends
to the stream. The volume is mounted at the file's directory, so give the
file a directory of its own: a path directly under / or /etc, or at or
under /sortie or /etc/sortie, is refused.
The Job and the engine come up together, so sortie waits up to 30 seconds for
each backend to accept connections before the run starts; a backend that is
not there by then fails the run with the address it tried. test/chart/run.sh
installs the chart on a kind cluster with both images built from the checkout
and checks both verdicts; CI runs it on every change.
The Job's name carries a digest of its rendered pod template, because a Job's
spec.template is immutable: with a stable name, helm upgrade with a changed
plan would fail with field is immutable rather than run it. With the suffix an
upgrade creates a new Job and Helm removes the previous one. Changing nothing
therefore re-applies the same Job rather than re-running it; to run an unchanged
plan again, delete the Job. Changing only backoffLimit or
ttlSecondsAfterFinished does not rename it -- those are mutable on a Job, so
they patch the existing run instead of starting a new one.
job.ttlSecondsAfterFinished defaults to an hour, and deleting the Job is also
what re-runs it: once the TTL has removed it, the next helm upgrade finds it
missing and creates it again, generating load even if the upgrade changed
nothing about the run. Set it to null to keep finished Jobs until something
deletes them.
Upgrading a release while a run is in flight replaces the Job. The old pod is
sent SIGTERM, on which sortie cancels its backends' executions and waits for
their partial responses (seconds), and the old run is reported as cancelled,
not evaluated. Nothing orders the replacement after that, though: Kubernetes
can schedule the new pod while the old one is still cancelling, and a backend
runs one execution at a time, so the new Job can find it busy and fail --
with backoffLimit: 0 it does not retry. Upgrading between runs avoids the
race; so does a backoffLimit of 1 or 2, at the cost of re-running a plan
that failed for a real reason.
The ConfigMap is named after the plan's digest for the same reason in reverse. A stable name would be updated in place, and a CronJob's Job that was created before an upgrade but had not started yet would mount the new plan while reporting itself as the old one. Each plan gets its own object, so a Job can only mount the plan it was created for.
helm install nightly oci://quay.io/sortie/chart-sortie \
--version <chart version> --set-file plan=plan.yamlThe default plan in values.yaml names no backend that exists. That is
deliberate: a plausible-looking default would generate load against whatever
happened to answer.
Images are signed with cosign, keyless, through GitHub's OIDC identity — there is no key to store or rotate. Signatures cover the index and every per-arch manifest, so pulling by an architecture-specific digest is covered too:
cosign verify \
--certificate-identity-regexp '^https://github\.com/bpalermo/sortie/\.github/workflows/publish\.yml@refs/heads/main$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
quay.io/sortie/sortie@sha256:...cosign verify has no --recursive, so that checks one digest. From a
checkout, bazel run //bazel/cosign:verify_image -- quay.io/sortie/sortie@sha256:...
verifies the index and every manifest under it with the cosign this repository
pins, and is what the publish workflow runs.
A published chart pins both images by digest: the driver's, injected at package time from the push target, and the engine's, the index the same workflow run pushed and signed just before. So a chart can only ever reference the images built alongside it, and those are the ones whose signatures were verified. A chart built from a checkout names the engine by the commit's tag instead.
An image's digest names its content and nothing else: no label in the image
records the commit, so a commit that does not change an image publishes the
digest the one before it did, and a consumer who pins by digest has nothing to
re-pin. The engine image is the usual case, since most commits leave the engine
alone. The driver's digest moves with every commit, because the binary reports
its own version. Which commit published a digest is answered outside the image:
every commit's dev-<commit> tag names what that commit published, and the
certificate of the signature names the commit that first published it
(cosign verify prints it as githubWorkflowSha). A digest that is already
signed is not signed again.
The chart itself is signed the same way, as a single manifest:
bazel run //bazel/cosign:verify_image -- --single quay.io/sortie/chart-sortie@sha256:...Verification needs cosign 3 or newer. Signatures are sigstore bundles
attached to the manifest they sign as OCI 1.1 referrers; there is no
sha256-<digest> tag. A cosign 2 client does not find them and reports no signatures found, which is indistinguishable from an unsigned image, so check
the version before concluding anything from that. (Images on ghcr.io, from
before the move, carry their signatures on sha256-<digest> tags instead,
because that registry does not serve the Referrers API.)
The schema is a protobuf definition, api/sortie/plan/v1/plan.proto. Field
names in a plan file are the field names there, and most constraints are
declared inline with protovalidate
so a rule sits next to the field it governs.
Plans are parsed with protoyaml, which reports violations with a line and
column and the offending line:
plan.yaml:11:7 scenarios[0].executor: the ramping-rate executor requires a ramp_time
11 | type: ramping-rate
11 | ......^
Unknown fields are rejected, so a misspelled key fails the run rather than being silently ignored.
Two kinds of rule cannot live in the schema and stay in Go: those spanning
messages (a scenario naming a pool declared elsewhere in the file) and those
depending on dispatch (a rate divisible by backends x concurrency, which needs
the pool). Threshold expressions are also checked at load time, since their
grammar belongs to sortie rather than to protobuf.
Durations follow protobuf's own JSON mapping — decimal seconds ending in s,
so 90s rather than Go's 1m30s.
engine/ is a fork of envoyproxy/nighthawk,
Apache License 2.0; its LICENSE is kept in that directory and its README says
what was changed. The rest of this repository is under the root LICENSE.
The engine's API protos live in engine/api and are compiled by Bazel for
both C++ and Go, so the control plane and the engine always agree on one
definition. Nothing is vendored and no generated code is checked in, so the
bindings cannot go stale against the .proto files they came from.
Envoy's types come from the envoy_api module rather than from the
go-control-plane Go module, because that is where the generated Nighthawk
bindings get theirs. Two targets carrying the same Go import path would put a
second copy of every Envoy message in the binary and panic at init on duplicate
registration. The same applies to google/rpc/status.proto, whose Go code is
taken from the genproto module gRPC-Go already links.
aspect_rules_lint runs buildifier over the hand-written Starlark as an
ordinary test target, so bazel test //... covers it.
Go is analysed by rules_go's nogo (//:nogo), which runs as part of
compilation, so a vet finding fails the build rather than a separate job.
aspect_rules_lint ships no Go linter, which is why the two are split.
nogo on a Go 1.27 SDK needs golang.org/x/tools >= v0.48.0, and go_deps
resolves one x/tools across every module by MVS, so this repository's go.mod
is what the analyzers are built against. //bazel/deps:deps_test fails if that
floor is breached, because the symptom otherwise is an export-data error that
never mentions x/tools.