Camera & capture utilities¶
These four utilities in platform-utils support the camera fleet and the edge capture pipeline: generating blackbox-probe monitoring targets, chasing down un-migrated cameras, watching the watchlist match ingestion stream, and reporting per-camera S3 storage usage.
Each is a small Python container built from src/<name>/ and deployed by platform-infra as a Kubernetes CronJob via Flux. This page documents:
| Utility | What it does | Kind / Schedule | Namespace |
|---|---|---|---|
camera-probe-propagator |
Generates Prometheus ScrapeConfig CRDs from the camera DB and commits them to platform-infra |
CronJob */30 * * * * |
utils |
sonic-stragglers-report |
Finds cameras still uploading captures to the old SonicWall IP (now a MikroTik CHR) | CronJob 0 1 * * * (runs 23 h) |
misc |
watchlist-log-items |
Alerts to Slack when the watchlist MongoDB collection stops receiving items | CronJob */30 * * * * |
misc |
camera-image-size-report |
Analyses S3 capture-image storage per device and reports to Slack | CronJob 0 8 * * 1 (weekly) |
misc |
Where the images and manifests live
Source, Dockerfile, and the canonical k8s/ manifests are in platform-utils. Images are published to ghcr.io/example-org/<name>. platform-infra consumes them under kubernetes/apps/utils/ — an ImageRepository / ImagePolicy / ImageUpdateAutomation per component plus a Flux Kustomization that points at the platform-utils k8s/overlays/<env>/<name> path.
camera-probe-propagator¶
The most involved of the four. It turns the camera inventory in MySQL into two Prometheus ScrapeConfig CRDs (ICMP and HTTP blackbox probes) and pushes them straight into the platform-infra Git repository, so the blackbox-exporter always probes the live set of cameras without anyone hand-editing YAML.
Source: src/camera-probe-propagator/ — main.py, mikrotik.py.
What it does (pipeline)¶
- Auth to GitHub as a GitHub App (App ID
1234567), minting an installation access token (get_installation_token). Skipped entirely in localOUTPUT_DIRmode. - Query MySQL (
_CAMERA_QUERY) — selects fromcamera_profilewhereis_activated = 1andhostis non-empty, ordered by name. Columns become scrapeconfig labels:device_id→camera_id,name,site_name,host,port,is_vpn,camera_model,camera_make,camera_serial_number,camera_firmware,is_activated,connectionType,camera_lat,camera_long. Aborts (exit 1) if the query returns zero cameras — a guard against writing empty scrapeconfigs. - Camera→WireGuard-peer correlation (best-effort, see below).
- Build two ScrapeConfig documents (
monitoring.coreos.com/v1alpha1, namespaceobservability, labelrelease: kube-prometheus-stack):blackbox-camera-icmp—scrapeInterval: 30s,scrapeTimeout: 10s, moduleicmp_camera, targets are bare hosts (port stripped via relabeling).blackbox-camera-http—scrapeInterval: 120s,scrapeTimeout: 30s, modulehttp_camera, targets arehttp://<host>:<port>(port defaults to 80).- Both relabel
__address__toblackbox-exporter.observability.svc.cluster.local:9115and setinstance/__param_target.
- Commit to GitHub (
push_files) via the Git Data API (blob → tree → commit → advance ref) as a single commit of both files. It first GETs current file contents and skips the commit if nothing changed. Commit message:chore: regenerate camera scrapeconfigs [<ISO-timestamp>]. - Slack notification (
send_slack_notification) — diffs the old vs new HTTP scrapeconfig bycamera_idand posts an "added / removed / changed" summary with per-field before→after values. Silently skipped ifSLACK_BOT_TOKEN/SLACK_CHANNEL_IDare unset.
A sample of the generated output lives in the repo at output/scrapeconfig-camera-icmp.yaml:
- targets:
- client-f.ddns.net
labels:
camera_id: "11111111-2222-3333-4444-555555555555"
camera_name: "SiteA-MainSt@FirstAve R:Northbound"
site_name: "Client B"
is_vpn: "false"
camera_model: "VA-2CD7A46-IZHS"
camera_make: "VendorA"
camera_firmware: "V5.7.80 build 000000"
camera_lat: "-33.92000000"
camera_long: "18.42000000"
Camera → WireGuard peer correlation¶
Only VPN cameras (is_vpn = 'true') sit behind a WireGuard peer. When PEER_CORRELATION_ENABLED=true (default), the job SSHes every router in its inventory read-only (mikrotik.fetch_wg_peers), builds the peer list, and finds the peer whose allowed-address CIDR contains each camera's IP (longest-prefix match, best_peer_for_ip). Matched cameras get three extra labels — peer, router, peer_pubkey — so Grafana can filter cameras by peer and join to the MikroTik WireGuard exporter's per-peer health on public_key == peer_pubkey.
This path is fully best-effort and never blocks scrapeconfig generation:
- Any exception is caught and the run continues without peer labels.
- On a partial router outage (
all_ok = False), an unmatched VPN camera keeps its last-known peer label — read back from the current ICMP scrapeconfig on GitHub (_prior_peer_labels) — so a single router being unreachable does not flap the committed YAML. - Kill-switch: set
PEER_CORRELATION_ENABLED=falseto skip router SSH entirely.
The router inventory is the routers.json ConfigMap (routers-configmap.yaml) — the same inventory as mikrotik-wireguard-exporter; keep the two in sync:
{ "routers": [
{"name": "client-c", "host": "client-c-tmc.ddns.net", "port": 8101},
{"name": "client-a", "host": "hx0000000a1.sn.mynetname.net", "port": 22},
{"name": "client-b", "host": "hx0000000c3.sn.mynetname.net", "port": 22, "enabled": false}
] }
Build & image¶
- Language / deps (
requirements.txt): Python —mysql-connector-python,PyYAML,PyJWT,cryptography,requests,paramiko(router SSH). - Dockerfile: base
python:3.12-slim, installsgcc/libssl-dev, runs as non-rootappuser(UID 1000). - Image:
ghcr.io/example-org/camera-probe-propagator(bake targetcamera-probe-propagatorindocker-bake.hcl).
Deployment¶
Manifests: k8s/base/camera-probe-propagator/. This is the only one of the four that runs in the utils namespace (the others use misc).
- CronJob
camera-probe-propagator—schedule: "*/30 * * * *",concurrencyPolicy: Forbid,activeDeadlineSeconds: 300,backoffLimit: 1,ttlSecondsAfterFinished: 86400.automountServiceAccountToken: false(no cluster API access needed). PodsecurityContextsetsfsGroup: 1000so the non-root UID can read the0440SSH key file. - Container security:
runAsNonRoot,runAsUser: 1000,readOnlyRootFilesystem: true,allowPrivilegeEscalation: false,seccompProfile: RuntimeDefault, all capabilities dropped. - Resources: requests 100m CPU / 128Mi, limits 1 CPU / 512Mi.
- Volumes:
tmpemptyDir;github-keysecret at/secrets/github;mikrotik-sshsecret (keyid_ed25519, mode0440) at/etc/camera-probe-propagator/ssh;inventoryConfigMap at/etc/camera-probe-propagator/inventory.
Config comes from the camera-probe-propagator-config ConfigMap (base values blank, filled by the prod overlay k8s/overlays/prod/camera-probe-propagator/kustomization.yaml):
| Key | Prod value |
|---|---|
GITHUB_APP_ID |
1234567 |
GITHUB_REPO_OWNER / GITHUB_REPO_NAME / GITHUB_BRANCH |
example-org / platform-infra / main |
MYSQL_HOST / MYSQL_PORT / MYSQL_DATABASE |
db-proxy.internal.prod.cpt.aws.example.net / 3306 / capture_admin_portal |
SLACK_CHANNEL_ID |
C000000AAA2 (#engineering-info) |
SCRAPECONFIG_ICMP_PATH |
kubernetes/infrastructure/services/observability/overlays/prod/blackbox-exporter/scrapeconfig-camera-icmp.yaml |
SCRAPECONFIG_HTTP_PATH |
kubernetes/infrastructure/services/observability/overlays/prod/blackbox-exporter/scrapeconfig-camera-http.yaml |
PEER_CORRELATION_ENABLED |
true |
Secrets (externalsecret.yaml) — four ExternalSecrets from the aws-secrets-manager ClusterSecretStore:
| K8s Secret | Source (AWS Secrets Manager) | Used as |
|---|---|---|
camera-probe-propagator-db |
prod-db-readonly-credentials (username, password) |
MYSQL_USER, MYSQL_PASSWORD |
camera-probe-propagator-github |
github → platform-bot-private-key.pem |
mounted PEM → GITHUB_PRIVATE_KEY_FILE=/secrets/github/private-key.pem |
camera-probe-propagator-slack |
slack-credentials → slack-bot-token |
SLACK_BOT_TOKEN (optional) |
camera-probe-propagator-mikrotik-ssh |
mikrotik → readonly-private-key |
shared read-only router SSH key |
Local / dry-run mode
Setting OUTPUT_DIR makes the generator write the YAML files to disk instead of pushing to GitHub, and GitHub credentials are not required. See .env.local.example and docker-compose.yml for the local workflow (SSH-tunnel to the DB, mount the PEM at secrets/github-app-key.pem).
platform-infra wiring: kubernetes/apps/utils/overlays/prod/camera-probe-propagator/ holds the Flux Kustomization (path ./k8s/overlays/prod/camera-probe-propagator on the platform-utils GitRepository), plus ImagePolicy (semver >=1.0.0, tags ^v[0-9]+\.[0-9]+\.[0-9]+$) and ImageUpdateAutomation that bumps the pinned tag in the utils repo (commit author platform-bot).
This job writes to platform-infra main
A run commits regenerated scrapeconfigs directly to platform-infra main (when files differ). The is_activated = 1 AND host != '' filter plus the zero-row abort are the safety rails against an empty or wildly-wrong DB result wiping the probe targets.
sonic-stragglers-report¶
Tracks the SonicWall→AWS camera migration by finding cameras still uploading license captures to the old firewall public IP, which now terminates on a MikroTik CHR. The CHR is polled over SSH for its live firewall connection table; any camera still hitting TCP port 3333 (the capture-upload port) is a "straggler" that hasn't been repointed to AWS.
Source: src/sonic-stragglers-report/ — main.py, README.md.
What it does¶
- Monitor (
collect_tcp_connections) — forMONITORING_DURATIONseconds (default 82800 = 23 h), loops every 10 s runningssh … /ip/firewall/connection/printagainst the CHR. It parses each line fortcpconnections to:3333in an established state (SACsd/Sd/sd), extracts and validates the source IP, and appendsip,established,<ts>to a per-run file under/tmp. SSH args are validated (validate_ssh_parameters) and the command is built as an argv list — noshell=True. - Analyse (
analyze_ip_patterns) — dedupes to unique IPs with connection counts, then geolocates up to 50 IPs (threaded) viaipapi.co(ifIPAPI_KEY) or the freeip-api.com. Flags each cameraHIGH/NORMALpriority relative to the average. - Report — writes a CSV (
<date>_sonic_stragglers_migration.csv) with columnsCamera_IP, Capture_Upload_Count, Connection_States, Migration_Priority, Country, Region, City, Organization, ISP, Last_Seen, plus a human-readable summary.txt. If no stragglers are found it still writes a one-row "all cameras migrated" CSV. - Slack (
send_slack_report) — posts a summary message and uploads the CSV viafiles_upload_v2. Skipped with a warning if Slack isn't configured.
The repo also contains a committed match_cameras/ helper set (README) — shell + dig scripts (extract_public_ips.sh, compare_ips.sh, fetch_geoip.py) that cross-reference straggler IPs against camera_profile.csv to name the offending cameras, plus a historical migration CSV/summary from 2026-02-04.
Build & image¶
- Deps (
requirements.txt):slack-sdk,requests. - Dockerfile:
python:3.11-slim, installsopenssh-client, non-rootappuser(UID 1000), pre-seedsknown_hostsforfirewall.example.com. - Image:
ghcr.io/example-org/sonic-stragglers-report.
Deployment¶
Manifests: k8s/base/sonic-stragglers-report/. Namespace misc.
- CronJob
sonic-stragglers-report—schedule: "0 1 * * *"(daily 01:00 UTC),activeDeadlineSeconds: 86400(24 h — 23 h run + 1 h buffer),concurrencyPolicy: Forbid,backoffLimit: 1. - Security: non-root UID 1000,
readOnlyRootFilesystem, all caps dropped. Volumes:tmpemptyDir, and an in-memory (emptyDir: {medium: Memory})/app/.sshwhere the runtime SSH key is written. - Config (
sonic-stragglers-configConfigMap):SLACK_CHANNEL_ID: C000000AAA1,ROUTER_HOST: firewall.example.com,ROUTER_USER: admin,MONITORING_DURATION: 82800. - Secrets (
sonic-stragglers-secret, SOPS-encryptedsecret.enc.yaml):SLACK_BOT_TOKEN,SSH_KEY_CONTENT(router private key, injected at runtime — never baked into the image), optionalIPAPI_KEY.
Environment variables consumed by main.py: SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, ROUTER_HOST, ROUTER_USER, SSH_KEY_PATH (default /app/.ssh/runtime_key), SSH_KEY_CONTENT, MONITORING_DURATION, IPAPI_KEY. A --local CLI flag switches to a system SSH config alias (default hub-az-chr) for laptop runs.
Staging overlay (k8s/overlays/staging/…) patches the run down to 1 h (MONITORING_DURATION: 3600, activeDeadlineSeconds: 7200) and shifts the schedule to 0 2 * * *.
platform-infra wiring: kubernetes/apps/utils/overlays/prod/sonic-stragglers-report/ — Flux Kustomization + image automation (prod tag currently v1.22.0).
Output destination
A per-run CSV + summary in /tmp (ephemeral), surfaced to the Slack channel. Nothing is written to a database or S3 — the CSV attachment in Slack is the deliverable.
watchlist-log-items¶
A liveness monitor for the watchlist (watchlist match) ingestion pipeline. Every 30 minutes it counts recent documents in the capture MongoDB and pages Slack if the count is zero — an early signal that cameras have stopped logging watchlist hits or the ingestion pipeline has stalled.
Source: src/watchlist-log-items/ — main.py, utils.py.
What it does¶
- Sleeps a random 0–5 s (guards against duplicate executions if two pods start at once).
- Connects to MongoDB (
connect_to_mongo,pymongo.MongoClient) and counts documents iningest.VOIlogwherecapture.isoDate >= now - CHECK_INTERVAL_MINUTES(default 30 min). - Alerting logic:
total_records == 0→ posts<!channel> No watchlist log items in the last N minutes.and exits 1 (failure).- Otherwise → posts
✅ *watchlist Item Log* - *N* records in N minand exits 0.
- On any exception it attempts a Slack error message and exits 1.
It also prints machine-readable lines (STATUS=…, TOTAL_RECORDS=…, CHECK_INTERVAL=…, TIMESTAMP=…) to stdout for K8s log scraping.
Build & image¶
- Deps (
requirements.txt):pymongo==4.6.0,slack_sdk==3.21.3. - Dockerfile:
python:3.11-slim, non-rootappuser(UID 1000), copiesmain.py+utils.py. - Image:
ghcr.io/example-org/watchlist-log-items.
Deployment¶
Manifests: k8s/base/watchlist-log-items/. Namespace misc.
- CronJob
watchlist-log-items-monitor—schedule: "*/30 * * * *",activeDeadlineSeconds: 300,backoffLimit: 2,ttlSecondsAfterFinished: 21600,concurrencyPolicy: Forbid. - Security: non-root UID 1000,
readOnlyRootFilesystem, all caps dropped,tmpemptyDir. - Config (
watchlist-log-items-configConfigMap):CHECK_INTERVAL_MINUTES: "30"; the prod overlay patchesSLACK_CHANNEL_ID: C000000AAA1(#info). - Secrets (
externalsecret.yaml) fromaws-secrets-manager:mongo-uri→ keymongopropertyMONGO_URI_READ_ONLY→ envMONGO_URI(read-only Mongo user).slack-credentials→ keyslack-credentialspropertyslack-bot-token→ envSLACK_BOT_TOKEN.
Environment variables: MONGO_URI (required), SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, CHECK_INTERVAL_MINUTES.
platform-infra wiring: kubernetes/apps/utils/overlays/prod/watchlist-log-items/ — Flux Kustomization + image automation (prod tag v1.22.0). This container replaces the old watchlist-alerts-alerter1.yaml GitHub Actions workflow.
Output destination
Slack only — an alert message to the configured channel (@channel mention on failure). The non-zero exit on the failure path also surfaces the run as a failed Job in Kubernetes.
camera-image-size-report¶
A weekly storage-accounting report. For every online camera it walks the day's capture images in S3, computes per-device size metrics, enriches them with camera names from MySQL, and posts a sorted CSV to Slack — used for capacity planning and spotting cameras generating abnormal image volumes.
Source: src/camera-image-size-report/ — main.py.
What it does¶
- Select online cameras (
run_query) —SELECT device_id FROM camera_profile WHERE id IN (SELECT DISTINCT camera_profile_id FROM up_down_status WHERE is_online = 1); writes the device IDs tobucket_names.txt. - Walk S3 (
process_objects_in_s3_prefix, 50-wayThreadPoolExecutor) — for eachdevice_id, paginateslist_objects_v2onS3_BUCKET_NAMEunder prefix<device_id>/<YYYY-MM-DD>(target date = yesterday). Sums object count and bytes, computing total GB, average KiB per capture, and captures-per-GB. Appends a row per device to<date>_camera_usage.csv. - Enrich & sort (
pandas) — joins eachDevice_Idto itsnamefromcamera_profile(get_camera_name), recomputesNumber_of_captures_per_Gig, and sorts byAverage_size_per_capture_KiBdescending. Final columns:camera_name, Device_Id, Total_number_of_captures, Total_size_of_captures_GB, Average_size_per_capture_KiB, Number_of_captures_per_Gig. - Slack (
send_slack) — postsCamera image size report for <date>and uploads the CSV.
Build & image¶
- Deps (
requirements.txt):boto3,mysql-connector-python,pandas,slack-sdk. - Dockerfile:
python:3.11-slim, non-rootappuser(UID 1000). - Image:
ghcr.io/example-org/camera-image-size-report.
Deployment¶
Manifests: k8s/base/camera-image-size-report/. Namespace misc.
- CronJob
camera-image-size-report—schedule: "0 8 * * 1"(Mondays 08:00 UTC),activeDeadlineSeconds: 3600,backoffLimit: 2,concurrencyPolicy: Forbid. - Security: non-root UID 1000,
readOnlyRootFilesystem, all caps dropped,tmpemptyDir. - Config (
camera-image-size-report-configConfigMap):SLACK_CHANNEL_ID: C000000AAA1,AWS_REGION: us-east-1,S3_BUCKET_NAME: example-prod-media,MYSQL_HOST: db-proxy.internal.prod.cpt.aws.example.net,MYSQL_PORT: 3306,MYSQL_DATABASE: capture_admin_portal,MYSQL_USER: captureadmin. - Secrets (
camera-image-size-report-secret, SOPS-encryptedsecret.enc.yaml):SLACK_BOT_TOKEN,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,MYSQL_PASSWORD.
Environment variables: SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKET_NAME, MYSQL_HOST/MYSQL_PORT/MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD.
S3 access via static keys
Unlike the other utilities, S3 access here uses static AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from the SOPS secret rather than IRSA. Required IAM: s3:ListBucket, s3:GetObject on the example-prod-media bucket.
platform-infra wiring: kubernetes/apps/utils/overlays/prod/camera-image-size-report/ — Flux Kustomization + image automation (prod tag v1.22.0).
Output destination
A CSV report (<date>_camera_usage.csv) uploaded to the Slack channel. No persistent store — the metrics live in the Slack attachment and job logs.
Common patterns¶
- Deployment shape: all four are Flux-managed
CronJobs. The base + overlay manifests live inplatform-utils/k8s/;platform-infrareferences them per-env underkubernetes/apps/utils/overlays/<env>/<name>and drives image bumps viaImageRepository→ImagePolicy→ImageUpdateAutomation(setters that rewrite the pinnednewTagin the utils repo, committed asplatform-bot). - Security baseline: non-root UID 1000,
readOnlyRootFilesystem, dropped capabilities,concurrencyPolicy: Forbid, boundedactiveDeadlineSeconds. - Secrets: sourced from AWS Secrets Manager via
external-secrets(camera-probe-propagator,watchlist-log-items) or committed SOPS-encrypted secrets (sonic-stragglers-report,camera-image-size-report). - Data sources: the capture MySQL
camera_profiletable (capture_admin_portalondb-proxy.internal.prod.cpt.aws.example.net) is the common camera inventory;camera-probe-propagatorandcamera-image-size-reportboth read it,watchlist-log-itemsreads MongoDBingest.VOIlog, andsonic-stragglers-reportreads the MikroTik CHR firewall table over SSH.