03 - Configuration model¶
Principles¶
- YAML, multi-document, Kubernetes-like envelope:
apiVersion,kind,metadata,spec. apiVersion: tapyr/v1alpha1for every document.- Documents can be spread across any number of files and directories. Tapyr loads all of them and resolves references by name.
- Every
kindhas a JSON Schema generated from Go types, published inschemas/v1alpha1/and printable withtapyr schema; the configuration reference lists every field, generated from the same types.schemas/v1alpha1/tapyr.jsonaccepts any kind (dispatched onkind) and is meant for editors, for example with the YAML language server:# yaml-language-server: $schema=https://raw.githubusercontent.com/kentrow/tapyr/main/schemas/v1alpha1/tapyr.json. - Secrets are never inline. They are references to environment variables or files.
- Durations use Go syntax (
30s,5m,1h). Rates use<n>/<unit>with units,morh(10/s,600/m). Percentages are strings"<n>%"(10%,2.5%). Ratios such asbudget.shareare plain numbers. - Names are DNS-label-like: lowercase, digits, hyphens, max 63 characters.
metadata.nameis unique perkindacross the whole configuration (provider packages conventionally prefix their template names with the package name). Limit names are unique per provider, target names per account; both follow the same DNS-label rule. - Identifiers used inside expressions and path templates (params, secrets, variables) match
^[A-Za-z_][A-Za-z0-9_]*$(apiKey,organizationId), so they are usable asvars.organizationIdin CEL and{organizationId}in paths. - Parsing is strict: unknown fields are errors, except inside free-form maps (
labels,params,headers,externalLabels,resourceAttributes, pluginconfig). Values of string maps must be YAML strings:port: 8080orcountry: NOare rejected with a hint to quote the value ("8080","NO"). - Relative paths written in a document (
filesecrets,caFile,providerPaths,state.path, TLS files) are resolved against the directory of the file that declares them. Built-in defaults (./data/tapyr.db) are resolved against the working directory. - Errors are reported as
file:line:column: message, all at once (validation does not stop at the first error).
Value types¶
Three syntaxes coexist, each confined to well-defined fields:
| Syntax | Where | Evaluated |
|---|---|---|
Go text/template ({{ .params.baseURL }}) |
Fields documented as templates: Provider.spec.baseURL, Provider.spec.defaults.headers values, oauth2ClientCredentials.tokenURL |
Once per account and target, at compile time. .params is the account params overridden by the target params. Functions: default, trimSuffix, lower, upper |
{name} placeholders |
request.path, sessionLogin.request.path |
Per request, from variables (discovery vars, then target params, then account params). Values are URL-path-escaped |
| CEL | Fields documented as expressions (extract.*, forEach.vars, pagination stop/total/cursor/next, ...) and { expr: ... } values |
At runtime |
Secret and value references:
- SecretRef (user side:
Account,Output,Settings): exactly one of{ env: NAME }or{ file: /path }. Secrets are never written inline. - Value (provider and job side): a plain string (literal),
{ secret: <name> }(a secret declared by the provider and supplied by the account), or{ expr: <cel> }. Inauthblocks, a Value is a literal or a secret reference only: credentials are resolved once per account, before any request, so there is nothing for an expression to read (tapyr validaterejects one). - Header values in outputs: a plain string (literal) or a SecretRef.
Kinds¶
| Kind | Who writes it | Purpose |
|---|---|---|
Provider |
Provider package author | API family: base URL template, auth, limits, pagination, error semantics |
JobTemplate |
Provider package author | Reusable, provider-level job definitions (endpoint + mapping) |
Account |
User | Instance of a provider with credentials, targets, labels, budget share |
Job |
User | What to collect for which account: either from a JobTemplate or fully custom |
Output |
User | Where data goes |
Settings |
User | Global settings (server, state, collection defaults); at most one document |
Simulation and Scenario (see 08-simulator.md) also use apiVersion: tapyr/v1alpha1, but they are internal to provider packages (sim/ directory) and are rejected with an explicit error when found in user configuration paths.
Provider¶
apiVersion: tapyr/v1alpha1
kind: Provider
metadata:
name: meraki-dashboard
version: 0.1.0 # provider package version (semver)
description: Cisco Meraki Dashboard API v1
docs: https://developer.cisco.com/meraki/api-v1/
spec:
baseURL: "{{ .params.baseURL | default \"https://api.meraki.com/api/v1\" }}"
params: # parameters an Account may or must provide
- name: baseURL
required: false
description: Regional base URL
secrets: # secrets an Account must provide
- name: apiKey
required: true
auth:
type: bearer
bearer:
token: { secret: apiKey }
defaults:
headers:
Accept: application/json
timeout: 30s
pagination: # default pagination, overridable per job template
type: link_header
linkHeader:
pageSizeParam: perPage
pageSize: 1000
limits:
- name: org-rate
description: Per-organization throughput
kind: rate
rate: 10/s
burst: 10
scope: { by: [account, "var:organizationId"] }
appliesTo: { requestsWithVar: organizationId }
- name: ip-rate
kind: rate
rate: 100/s
scope: { by: [instance] }
- name: ip-concurrency
kind: concurrency
max: 10
scope: { by: [instance] }
errors:
rateLimited:
statuses: [429]
retryAfterHeader: Retry-After
authFailed:
statuses: [401]
retryable:
statuses: [500, 502, 503, 504]
redirects: # optional: other hosts requests may be redirected to
hosts: ["*.example.com"] # see "Redirects" in 05-collection.md
Notes:
- String fields marked as templates use Go
text/templatewith a restricted function set (default,trimSuffix,lower,upper), evaluated at compile time per account and target (see "Value types"). Runtime expressions use CEL. limitsare described in detail in04-budget-engine.md.errorsdeclares the provider's error semantics; defaults apply when omitted:rateLimited(statuses: [429],retryAfterHeader: Retry-After,defaultPenalty: 2s,penalize: narrowest),authFailed(statuses: [401]),retryable(statuses: [500, 502, 503, 504]).penalizeisnarrowestorall(see04-budget-engine.md, "429 handling").auth.typeis one ofnone,header,bearer,basic,oauth2_client_credentials,session_login,plugin. The configuration block of a type is named after the type in camelCase (bearer,oauth2ClientCredentials,sessionLogin,plugin). Pagination follows the same rule (linkHeaderforlink_header).- Auth plugins (Go code, for flows that cannot be expressed declaratively) are selected with
type: pluginandplugin: { name: <plugin>, config: {...} }. The core schema treatsconfigas free-form; the plugin validates it at compile time. This keeps vendor names out of the core schema. paramsandsecretsdeclare what an Account may or must provide. An Account providing an undeclared param or secret is an error.
JobTemplate¶
A provider package ships job templates so users get a working setup with a few lines.
apiVersion: tapyr/v1alpha1
kind: JobTemplate
metadata:
name: meraki-organizations
spec:
provider: meraki-dashboard
role: inventory # inventory | metrics | both (default metrics)
defaultInterval: 6h
defaultPriority: low
request:
method: GET
path: /organizations
extract:
items: "response.body"
inventory:
key: "item.id"
fields:
organizationId: "item.id"
organizationName: "item.name"
---
apiVersion: tapyr/v1alpha1
kind: JobTemplate
metadata:
name: meraki-device-statuses
spec:
provider: meraki-dashboard
defaultInterval: 5m
defaultPriority: high
forEach:
template: meraki-organizations # fan out over the parent's inventory items
vars:
organizationId: "parent.organizationId"
request:
method: GET
path: /organizations/{organizationId}/devices/statuses
estimate:
pagesPerRun: 1 # hint for the planner before history exists
extract:
items: "response.body"
metrics:
- name: meraki_device_up
type: gauge
help: Device online status (1 online, 0 otherwise)
value: "item.status == 'online' ? 1.0 : 0.0"
labels:
serial: "item.serial"
network_id: "item.networkId"
organization_id: "vars.organizationId"
info:
- name: meraki_device_info
labels:
serial: "item.serial"
name: "item.name"
model: "item.model"
product_type: "item.productType"
lan_ip: "has(item.lanIp) ? item.lanIp : ''"
Account¶
apiVersion: tapyr/v1alpha1
kind: Account
metadata:
name: acme-meraki
spec:
provider: meraki-dashboard
params:
baseURL: https://api.meraki.com/api/v1
secrets:
apiKey: { env: TAPYR_ACME_MERAKI_API_KEY }
labels: # added to every sample from this account
tenant: acme
budget:
share: 0.7 # Tapyr may use 70% of quota-type limits (others share the key)
Multi-target account (one account, N devices):
apiVersion: tapyr/v1alpha1
kind: Account
metadata:
name: branch-routers
spec:
provider: teltonika-rutos
secrets:
username: { env: TAPYR_RUTOS_USER }
password: { file: /run/secrets/rutos_password }
targets:
- name: north-01
params: { baseURL: "https://10.10.1.1" }
labels: { site: north }
- name: south-01
params: { baseURL: "https://10.20.1.1" }
labels: { site: south }
secrets: # per-target override
password: { env: TAPYR_RUTOS_SOUTH_PASSWORD }
tls:
insecureSkipVerify: false
caFile: /etc/tapyr/rutos-ca.pem
- Jobs on a multi-target account run once per target. The
targetscope dimension lets providers declare per-device limits. - Secret references:
{ env: NAME },{ file: /path }. File secrets are re-read on reload. budget.share(0 < share <= 1, default 1) scales everyquotalimit for this account. Rate and concurrency limits are not scaled by default;budget.scaleRates: truescales them too (rate,burstand concurrencymax, rounded down, minimum 1). When a scope key is shared by several accounts (for example aninstanceorcredentialscope), the smallest share among them applies.- Required params must be provided by the account or by every target. Required secrets must be provided by the account or by every target (target secrets override account secrets).
Job¶
From a template:
apiVersion: tapyr/v1alpha1
kind: Job
metadata:
name: acme-device-statuses
spec:
account: acme-meraki
template: meraki-device-statuses
interval: 5m # desired interval (planner may stretch it)
priority: critical # critical | high | normal | low
outputs: [victoria] # default: all outputs
labels: { env: prod }
When a template uses forEach over a parent template, Tapyr automatically instantiates the parent as an implicit inventory job for the same account if no explicit job references it. Users can declare it explicitly to override its interval.
A Job either references a template or carries an inline definition, never both:
- With
template, the job may only setinterval(default: the template'sdefaultInterval),priority(default: the template'sdefaultPriority),outputs,labelsandstaleAfter. Definition fields (role,request,pagination,forEach,estimate,extract) are rejected. - Without
template, the job carries the same definition fields as aJobTemplatespec,intervalis required andprioritydefaults tonormal.
The definition fields shared by JobTemplate and inline Job:
| Field | Content |
|---|---|
role |
inventory, metrics (default) or both |
request |
method (default GET), path (with {var} placeholders), query (map of Value), headers (map of strings), body (map of Value, POST queries only), readOnly (required true for POST), idempotent (POST retried only if true), timeout |
pagination |
Overrides the provider default (see 05-collection.md). Every type accepts maxPages (default 100) |
forEach |
template (a parent JobTemplate) or job (a parent Job of the same account), exactly one; vars (map of CEL on parent), filter (CEL), maxInstances (default 10000). The parent must have role: inventory or both |
estimate |
pagesPerRun (planner hint) |
extract |
items, filter, partial, metrics, info, inventory (key, fields, ttl), post (see 06-mapping-and-outputs.md) |
Fully custom job (no template):
apiVersion: tapyr/v1alpha1
kind: Job
metadata:
name: rms-devices
spec:
account: home-rms
interval: 10m
priority: normal
request:
method: GET
path: /devices
query:
limit: "100"
pagination:
type: offset
offset:
offsetParam: offset
limitParam: limit
pageSize: 100
stop: "size(response.body.data) < 100"
extract:
items: "response.body.data"
metrics:
- name: teltonika_rms_device_online
type: gauge
value: "item.status == 1 ? 1.0 : 0.0"
labels:
device_id: "string(item.id)"
name: "item.name"
Output¶
apiVersion: tapyr/v1alpha1
kind: Output
metadata:
name: victoria
spec:
type: remote_write
remoteWrite:
url: http://victoriametrics:8428/api/v1/write
headers: {}
basicAuth: { username: { env: VM_USER }, password: { env: VM_PASSWORD } }
timeout: 10s
queue:
capacity: 50000 # samples
maxBatch: 5000
flushInterval: 5s
externalLabels:
tapyr_instance: lab
---
apiVersion: tapyr/v1alpha1
kind: Output
metadata:
name: prom
spec:
type: prometheus # served on the main server under /metrics
prometheus:
exposeTimestamps: false
---
apiVersion: tapyr/v1alpha1
kind: Output
metadata:
name: otel
spec:
type: otlp
otlp:
endpoint: http://otel-collector:4318
protocol: http/protobuf # http/protobuf | grpc
headers: {}
resourceAttributes:
service.name: tapyr
- At most one
prometheusoutput may be declared (it is served on the main server under/metrics).queueapplies to push outputs only and is rejected onprometheus. - Queue defaults:
capacity: 50000,maxBatch: 5000,flushInterval: 5s. Timeout default:10s. - Header values are plain strings or SecretRefs (
X-Scope-OrgID: tenant-a,Authorization: { env: OTLP_TOKEN }).
Settings¶
apiVersion: tapyr/v1alpha1
kind: Settings
metadata:
name: default
spec:
server:
listen: 127.0.0.1:9790
basicAuth:
username: { env: TAPYR_UI_USER }
password: { env: TAPYR_UI_PASSWORD }
protectMetrics: false # also protect /metrics and /metrics/self
# tls: { certFile: /etc/tapyr/tls.crt, keyFile: /etc/tapyr/tls.key }
ui: { enabled: true }
debug: # live debug endpoint (raw responses)
enabled: false
maxBodyBytes: 1048576
state:
path: ./data/tapyr.db
providerPaths:
- ./providers # user provider packages, in addition to built-ins
collection:
maxConcurrentRequests: 32
defaultTimeout: 30s
jitter: 10%
budget:
retryReserve: 10% # 0% to 50%
replanInterval: 15m
maxStretch: 12 # planner stretch cap per priority class
retry:
maxAttempts: 4
initialBackoff: 1s
maxBackoff: 60s
multiplier: 2
circuitBreaker:
failureThreshold: 5
cooldown: 2m
maxCooldown: 30m # cooldown doubles on repeated openings up to this value
mapping:
maxCost: 1000000 # CEL cost limit per expression evaluation
maxItemsPerRun: 100000
maxSamplesPerRun: 200000
labelConflict: override # override (job > target > account > mapping) | error
selfMetrics:
outputs: [] # optionally push self-metrics through these outputs
interval: 30s # push interval of the self-metrics
log:
level: info
format: json
All settings have defaults; the Settings document is optional. collection.jitter is in [0%, 50%].
Logging precedence: --log-level / --log-format flags, then TAPYR_LOG_LEVEL / TAPYR_LOG_FORMAT, then Settings.spec.log, then defaults. A bootstrap logger built from flags and environment is used until the configuration is loaded.
Provider packages¶
A provider package is a directory named after its provider:
providers/<name>/
├── provider.yaml # Provider (exactly one): base URL, params, secrets, auth, pagination, limits, errors
├── templates/*.yaml # JobTemplates: request, estimate.pagesPerRun, CEL extract
├── sim/sim.yaml # Simulation (see 08-simulator.md): inventory, routes, fixtures, auth accepted
├── sim/fixtures/ # static fixtures or Go templates (*.tmpl)
├── sim/scenarios/ # default.yaml, quota-tight.yaml, bad-day.yaml (all three required)
├── tests/contract.yaml # jobs run once against the simulator
├── tests/contract.env # the simulator credentials for the contract test
├── tests/expected.prom # the samples they must produce
├── dashboards/*.json # Grafana dashboard(s) with a datasource variable
├── examples/*.yaml # example Account and Job documents that load
└── README.md # coverage, auth setup, limits with sources, validation status, caveats
metadata.versionis semver. Breaking changes to metric names or labels bump the major version.- Built-in packages are embedded in the binary. User packages from
providerPathsoverride built-ins with the same name (with a warning). - Precedence, highest first:
Providerdocuments written directly in the configuration paths, then packages fromproviderPaths(in order), then built-in packages. A package that is overridden is dropped entirely, its job templates included. Every override is reported as a warning byvalidateandrun. - Each
providerPathsentry is either a package directory (it containsprovider.yaml) or a directory of packages (each subdirectory containing aprovider.yamlis a package). - In a package,
provider.yamlholds exactly oneProvider,templates/*.yamlhold onlyJobTemplatedocuments whosespec.provideris the package name, andsim/sim.yamlholds at most oneSimulation. The package directory name must equal the provider name. tapyr provider lint <dir>validates a package and runs its contract test (see08-simulator.md).
Validation rules (non-exhaustive)¶
- Every reference resolves (
account -> provider,template -> provider,job -> account,job -> template,forEach -> template | job,outputs -> output,selfMetrics.outputs -> output). Provider names resolve against the configuration, theproviderPathspackages and the built-in packages. - Names are unique per kind; at most one
Settingsdocument; at most oneprometheusoutput. - Discovery graph is acyclic; maximum depth 4 (at most 4 fan-out levels from the root inventory job to the deepest child; a job using a template shares the level of its template).
- A job template's
providermatches the job account's provider. - Accounts provide every required param and secret, and no undeclared one.
- Template fields parse as Go templates using only the allowed functions.
- All CEL expressions compile and type-check against the job's environment (see
06-mapping-and-outputs.md). - Every variable used in a path template is provided by
forEach.varsor the account params. - Scope dimensions
var:<name>refer to variables available in the requests the limit applies to. - Metric names follow Prometheus naming rules; label names are valid; a metric name has a single type across all jobs.
budget.sharein (0, 1].retryReservein [0%, 50%].- Required secrets resolve at startup (
validate --check-secretschecks them offline).