08 - Provider simulator and record mode¶
Why¶
Two of the built-in providers (Aruba Central, Meraki) cannot be tested against real platforms by the maintainer. More generally, contributors and users need to develop, test and demo providers without vendor access, and nobody can safely test quota exhaustion against a production API. The simulator makes every provider testable, and its rate-limit enforcement makes the budget engine testable end-to-end.
Principles¶
- Same definition, two consumers. The simulator reads the provider package: the
Providerdocument (limits, pagination, auth, error semantics) and the package'ssim/directory (routes, fixtures, scenarios). Limits are declared once. - Package-internal kinds.
SimulationandScenariodocuments live only in a package'ssim/directory; user configuration paths reject them. - Server-side enforcement reuses the budget package (rate, concurrency, quota windows) in "enforcer" mode, so the simulator returns 429,
Retry-Afterand quota headers exactly as declared. - Deterministic by default (seeded randomness), reproducible in CI.
- Runs on the injected clock. Automated tests share a fake clock between Tapyr and the simulator and run 24 simulated hours in seconds;
--time-scaleaccelerates the real clock for demos and manual tests.
sim/sim.yaml¶
apiVersion: tapyr/v1alpha1
kind: Simulation
metadata:
name: meraki-dashboard
spec:
inventory: # synthetic inventory generator (generic sets)
seed: 42
sets:
- { name: organizations, count: 2 }
- { name: networks, parent: organizations, count: 5 } # count items per parent item
- { name: devices, parent: networks, count: 20 } # per network
auth:
accept: # credentials the simulator accepts
bearer: ["sim-api-key"]
headers: # quota headers emitted, mapped to limit names
X-RateLimit-Limit-day: "limit:daily.max"
X-RateLimit-Remaining-day: "limit:daily.remaining"
routes:
- method: GET
path: /organizations
response:
template: fixtures/organizations.json.tmpl
- method: GET
path: /organizations/{organizationId}/devices/availabilities
pagination: link_header # simulator paginates the generated list
response:
# values evolving over simulated time come from template helpers,
# e.g. {{ flap $d "status" "10m" "online:0.95" "offline:0.03" "alerting:0.02" }}
template: fixtures/availabilities.json.tmpl
basePath(default/) is the path prefix of the API (the path part of the provider base URL); routes are relative to it.- Credentials are checked the way the
Providerdeclares its auth (bearer:Authorization: Bearer <one of accept.bearer>;header: the declared header with one ofaccept.header;basic: one ofaccept.basic). Wrong or missing credentials get the provider'sauthFailedstatus. - A route with
paginationserves the items of its fixture (a JSON array) page by page. The pagination parameters are not redeclared: they come from the job templates requesting that path, or from the provider default. - Fixtures are JSON templates (Go
text/templatewith helpers:rangeover inventory, deterministic pseudo-random values, counters increasing with time, timestamps from the simulated clock). - Static JSON fixtures (e.g. recorded) are supported directly.
- Unknown routes return 404 like a real API.
Synthetic inventory and templated fixtures¶
The inventory is generic: the core knows sets of items and parents, never vendor objects, and dynamics are template helpers rather than per-route declarations.
- Every generated item has
name(its set),index(position in its set, from 0),n(position under its parent, from 0),key(<set>-<index>) andparent(the parent item, or nil). Sets are generated in declaration order; a parent set must come first. The inventory is fixed for the lifetime of the simulator. response.template(instead ofresponse.file) is a Gotext/templaterendering JSON. Its data:.vars(route variables),.inventory(items by set name),.now(simulated time) and.query(query parameters). Helpers, all deterministic for a given seed:items "set"andchildren "set" item(items of a set, or those under an item),find "set" "key"(the item with that key),where list "field" value;id item "prefix"(a stable identifier),serial item "XXXX-XXXX-XXXX"(X letters, 9 digits),mac item,ipv4 item(in 10.0.0.0/8),pick item "field" "a" "b" ...(one value per item and field),num item "field" lo hi(an integer in [lo, hi], fixed per item and field);- dynamics:
rand item "field" min max(a float in [min, max] rounded to 3 decimals, fixed per item; usenumfor integers such as counts and identifiers),walk item "field" min max "10m"(a float, likerand, that moves inside [min, max] every period),flap item "field" "10m" "online:0.95" "offline:0.03" "alerting:0.02"(a weighted state, drawn again every period),counter item "field" ratePerSecond(grows with simulated time, for native counters); json value(JSON encoding),ts "2006-01-02T15:04:05Z07:00" time,unix time,ago "5m"(now minus a duration),add,sub,mul,div,mod,seq n,last list.- Dynamics depend only on the seed, the item, the field and the period number, so a contract test at a fixed simulated time always sees the same values.
- Static fixtures (
file) keep working, recorded ones included. A fixture path may use the route variables (file: devices/{id}.json): a missing file answers 404, like an unknown object. A fixture that does not render or parse answers 500 with its cause, which the contract test oftapyr provider lintshows. - The simulator loads its package at startup: restart it after editing a fixture. Limitations to keep in mind when imitating an API: quota reset headers (
limit:<name>.reset) are sent as a number of seconds until the reset, and the status of a route is fixed (errors come from scenarios, not from the fixtures). spec.portOffsetplaces the simulator intapyr sim --all: it listens at--listen-baseplus this offset, so that adding a package moves no other simulator. Packages without one take the offsets after the largest declared one, in provider name order; two packages declaring the same offset are an error. The built-in packages declare 0 (aruba-central-classic) to 4 (teltonika-rutos).response.itemsField(a dotted path, for exampledataoraps) paginates the list found at that path of the document instead of a top-level array: the simulator replaces the list by the requested page and keeps the rest of the envelope (counts and totals are computed by the template over the whole list).
Scenarios¶
sim/scenarios/*.yaml, selected with --scenario. A scenario overlays faults on top of the default behavior, on a timeline:
apiVersion: tapyr/v1alpha1
kind: Scenario
metadata:
name: bad-day
spec:
steps:
- at: 0s
externalConsumption: { limit: daily, ratePerHour: 300 } # another tool eats the quota
- at: 2h
latency: { p50: 800ms, p99: 5s }
- at: 3h
errors: { routes: ["/organizations/**"], status: 503, ratio: 0.3 } # globs as in appliesTo.endpoints
for: 20m
- at: 6h
outage: true # connections dropped
for: 10m
- at: 8h
tokenExpiry: true # invalidate tokens, force 401
Built-in scenarios for every provider: default, quota-tight (undersized daily quota), bad-day.
A step applies from at until at + for, or until the end when for is omitted. at is measured from the simulator start, in simulated time. Step actions:
limitsoverrides themaxof named quota limits, for examplelimits: { daily: { max: 1500 } }.externalConsumptionmakes another client consume a quota at a steady hourly rate.errorsgives a status to a ratio of the requests to matching routes.fixturesreplaces the fixture of routes during the step (fixtures: { "/orgs/{orgId}/networks": "fixtures/networks-after.json" }), to simulate inventory changes.latency: { p50: 800ms, p99: 5s }delays responses following a log-normal distribution with these quantiles (seeded), in simulated time.outage: truecloses connections without a response (the client sees a network error) while the step is active; a step withoutforlasts until the end.tokenExpiry: trueinvalidates, when the step starts, every token issued before it (the next request getsauthFailed).
Auth and pagination¶
With oauth2_client_credentials, the simulator serves the path of the provider's token URL (rendered without account params, so its defaults point to the simulator) and accepts the client credentials listed in auth.accept.clients (clientId, clientSecret, in the Authorization header or the form); with session_login, it serves the login path under basePath and accepts a JSON body carrying both values of one of the auth.accept.logins username and password pairs. Both issue opaque tokens valid for auth.tokenLifetime (default 10m, simulated time), then accepted as the provider applies them (bearer, header with format, or cookie); expired tokens get the authFailed status. The token endpoint answers access_token, token_type and expires_in; the login endpoint answers {"token": ..., "expiresIn": <seconds>}, or the auth.loginResponse fixture with {token} and {expiresIn} replaced, to match the shape the provider's token expression reads. Token and login requests are checked against the limits as auth requests.
A provider whose auth is a plugin is simulated by the plugin itself: the plugin registers a simulator side that serves its token endpoints and validates its tokens, with the credentials of auth.accept.plugin (free-form, read by the plugin).
Pagination page and cursor are served like offset and link_header (expression is client-side only); with cursor, the next cursor is an opaque value in the X-Next-Cursor response header (absent on the last page), read by templates with default(response.headers['x-next-cursor'][0], '').
CLI¶
tapyr sim --provider meraki-dashboard [--provider-path ./providers] [--scenario bad-day] [--listen :8081] [--time-scale 60] [--seed 42]
tapyr sim --all --listen-base 127.0.0.1:8081 # one simulator per provider, at --listen-base plus its portOffset
--time-scale N runs the simulated clock N times faster than real time (windows, refills, penalties and scenario steps follow it). It is meant for demos and manual tests; automated tests share a fake clock between Tapyr and the simulator instead.
The simulator exposes one endpoint of its own, GET /_sim/status: a JSON object with the provider and its package version, the served routes, the number of requests received, the simulated time, the active scenario, stats (requests served, rate limited by reason, injected errors, tokens issued, dropped connections, sessions, requests per route) and the state of the quotas. Useful to cross-check Tapyr's self-metrics in demos.
Record mode¶
tapyr record captures real API responses, anonymized, to build fixtures for a provider package or to attach responses to a bug report. It writes outside the repository: nothing enters it before a review.
tapyr record --config ./config --job rms-devices [--job ...] --out ./recordings/rms [--anonymize ./anonymize.yaml]
- Every instance of the selected jobs runs once, through the budget engine. Children of fan-outs run once their parent has run.
--outreceives one JSON file per page (<job>/<n>.json, anonymized),index.yaml(method, templated path, pseudonymized variables, status, rate-limit headers, file per page) andreport.txt. Request credentials and response headers other than the rate-limit headers of the provider (syncheaders andRetry-After) are never written.- Default rules replace, by key name and by value pattern: serial numbers, MAC addresses, IMEI, IMSI, ICCID, IPv4 and IPv6 addresses, hostnames, emails, phone numbers, names (keys
nameor ending in_name/Name), identifiers (keysidor ending in_id/Id, with the same pseudonym in path variables, so that fixtures stay consistent), coordinates and addresses. Pseudonyms keep the format: MACs become locally administered, IPv4 addresses move to 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24, IPv6 to 2001:db8::/32, emails to example.com, serials keep their length and alphabet, numbers stay numbers. They come from an HMAC with a random per-recording key that is never written. --anonymizeadds rules (replace: [{path: "data[*].company_name", as: name}]) and exceptions (keep: ["data[*].model"]); paths are dotted, with[*]for every element.report.txtlists every replaced path with its rule and count, never an original value, for review before anything is committed. The recording is meant to be turned into static fixtures by hand (fileroutes).
Contract tests¶
Each provider package's tests/ directory holds the expected samples of its templates run against the simulator with a fixed seed. tapyr provider lint and the CI run them: a change in mapping or fixtures that changes the produced series must update the expected file explicitly.
tests/contract.yamlholds the Account and Job documents run by the test;$SIM_URLis replaced by the URL of the simulator with its base path, and$SIM_ADDRby the URL without it (for base URLs that add the path themselves), before the documents are parsed; secrets are read fromtests/contract.env(KEY=VALUElines of simulator credentials), never from the environment, and the account uses the credentials accepted by the simulator.tapyr provider lint <dir>validates the package, compiles its templates, lints its dashboards and runs the contract test: the simulator and the collector share a fake clock starting at2026-01-01T00:00:00Zwith seed 42; every job instance runs once (children after their parents), and the samples are compared withtests/expected.prom(Prometheus exposition, sorted, without timestamps).--updatewrites the file instead. The CI runs the lint of every built-in package.- Dashboards (
dashboards/*.json, Grafana) must parse, use a datasource variable, and query only metrics produced by the package templates ortapyr_self-metrics.
Future¶
- Provider packages distributed with their simulator as OCI artifacts, community registry.
- Simulator scenarios composable across providers for lab use (e.g. a shared "branch office outage" timeline).