diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index 93f0549..6829f37 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -15,6 +15,8 @@ local openclaw = import 'milky-way/lib/openclaw.libsonnet'; local qbittorrent = import 'milky-way/lib/qbittorrent.libsonnet'; local sonarr = import 'milky-way/lib/sonarr.libsonnet'; local prowlarr = import 'milky-way/lib/prowlarr.libsonnet'; +local buildarr = import 'milky-way/lib/buildarr.libsonnet'; +local utils = import 'milky-way/lib/utils.libsonnet'; local wgConf = import 'milky-way/lib/wireguard-conf.libsonnet'; local sftp = import 'milky-way/lib/sftp.libsonnet'; local grandCentral = import 'milky-way/lib/grand-central.libsonnet'; @@ -159,6 +161,7 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; // RWO PVC. The download-client/indexer links are entered in the UI post-deploy (they need API // keys each app generates on first boot). sonarr: sonarr.new( + apiKey = secrets.sonarr.apiKey, tailscaleHostname = "sonarr", mediaVolumeClaimName = this.mdataPvc.metadata.name, ), @@ -167,9 +170,102 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; // (sonarr.default.svc.cluster.local:8989, and later Radarr) over ClusterIP DNS. WebUI via // Tailscale L7 ingress; SQLite config on its own iSCSI RWO PVC. prowlarr: prowlarr.new( + apiKey = secrets.prowlarr.apiKey, tailscaleHostname = "prowlarr", ), + // Buildarr: declaratively asserts the inter-app links the *arr apps store in SQLite (and which the + // SONARR__/PROWLARR__ env overrides can't reach) -- Sonarr's qBittorrent download client and + // Prowlarr's Sonarr application (which auto-syncs Prowlarr's indexers into Sonarr). Plumbing only: + // the trackers themselves stay manual in Prowlarr, so `delete_unmanaged: false` is set on every + // managed section AND as a plugin-global default -- Buildarr would otherwise be free to delete + // resources it doesn't manage. Buildarr has no single master switch for this (it's per-section), so + // the global blocks cover only the sections we manage; a future edit that manages a NEW section must + // add its own explicit `delete_unmanaged: false`. NEVER flip any of these to true. + // + // This desired-state config is owned HERE (the lib is just the daemon plumbing). Host/port for each + // app come from its Service (the source of truth): the FQDN via utils.domainOfService, and the + // webui port as ports[0] after asserting ports[0] really is the webui entry (qbittorrent's Service + // also exposes gluetun-ctrl). API keys come from sops. + local buildarrConfig = + local sonarrOrionSystemInstanceName = 'sonarr-orion-system'; + local prowlarrOrionSystemInstanceName = 'prowlarr-orion-system'; + local httpUrl(hostname, port) = 'http://%s:%d' % [hostname, port]; + { + buildarr: { + // Buildarr rolls via the Deployment's checksum/config annotation, not in-place file watch. + watch_config: false, + }, + sonarr: { + // GLOBAL default for all sonarr instances (current + future). MUST stay false -- never + // clobber download clients added by hand in Sonarr's UI. + settings: { download_clients: { delete_unmanaged: false } }, + instances: { + [sonarrOrionSystemInstanceName]: { + hostname: utils.domainOfService(this.sonarr.service), + port: utils.assertAndReturn(this.sonarr.service.spec.ports[0], function(p) p.name == 'webui').port, + protocol: 'http', + api_key: secrets.sonarr.apiKey, + settings: { + download_clients: { + delete_unmanaged: false, // also explicit per-instance (belt & suspenders) + definitions: { + qBittorrent: { + type: 'qbittorrent', + host: utils.domainOfService(this.qbittorrent.service), + port: utils.assertAndReturn(this.qbittorrent.service.spec.ports[0], function(p) p.name == 'webui').port, + // No username/password: qBittorrent's AuthSubnetWhitelist bypasses auth for + // in-cluster callers (Sonarr is in the pod CIDR). See lib/qbittorrent.libsonnet. + category: 'tv-sonarr', // qBittorrent category Sonarr tags its grabs with + }, + }, + }, + }, + }, + }, + }, + prowlarr: { + // GLOBAL default for all prowlarr instances (current + future). MUST stay false -- never + // clobber apps/indexers added by hand in Prowlarr's UI. + settings: { apps: { applications: { delete_unmanaged: false } } }, + instances: { + [prowlarrOrionSystemInstanceName]: { + hostname: utils.domainOfService(this.prowlarr.service), + port: utils.assertAndReturn(this.prowlarr.service.spec.ports[0], function(p) p.name == 'webui').port, + protocol: 'http', + api_key: secrets.prowlarr.apiKey, + settings: { + apps: { + applications: { + delete_unmanaged: false, // also explicit per-instance (belt & suspenders) + definitions: { + Sonarr: { + type: 'sonarr', + // Cross-link by name: Buildarr resolves the Sonarr instance above and fills in + // its API key itself. The two URLs are still required explicitly (instance_name + // only links the key): prowlarr_url is how Sonarr dials back to Prowlarr for the + // indexer proxy; base_url is how Prowlarr reaches Sonarr to push the sync. + instance_name: sonarrOrionSystemInstanceName, + prowlarr_url: httpUrl( + utils.domainOfService(this.prowlarr.service), + utils.assertAndReturn(this.prowlarr.service.spec.ports[0], function(p) p.name == 'webui').port, + ), + base_url: httpUrl( + utils.domainOfService(this.sonarr.service), + utils.assertAndReturn(this.sonarr.service.spec.ports[0], function(p) p.name == 'webui').port, + ), + sync_level: 'full_sync', + }, + }, + }, + }, + }, + }, + }, + }, + }, + buildarrConnect: buildarr.new(config = buildarrConfig), + // Public-key-only SFTP front door onto the shared mdata volume (read-write), reached over the // tailnet (mdata-sftp.tail4c9a.ts.net:22) and over the LAN via methanol's mDNS alias // (mdata-methanol.local:30022 -- alias + firewall port live in venus methanol.nix). Authorized diff --git a/milky-way/lib/buildarr.libsonnet b/milky-way/lib/buildarr.libsonnet new file mode 100644 index 0000000..21d91f5 --- /dev/null +++ b/milky-way/lib/buildarr.libsonnet @@ -0,0 +1,98 @@ +local utils = import 'milky-way/lib/utils.libsonnet'; +local images = import 'milky-way/lib/images.libsonnet'; + +// Buildarr daemon: renders a given buildarr.yml `config` object into a read-only-mounted Secret and a +// headless daemon Deployment that reconciles the *arr apps to match it. This lib owns ONLY the K8s +// plumbing; the caller owns the desired state -- it passes the full config in (see the buildarrConfig +// local in main.jsonnet), so the app-specific knowledge (which apps link to which, download clients, +// Prowlarr applications, delete_unmanaged policy) lives at the call site, not here. +// +// DECLARATIVE (read-only) config, NOT a seed. Unlike qbittorrent/openclaw -- whose apps rewrite their +// own config and so are only *seeded* once -- Buildarr never rewrites buildarr.yml. The passed-in +// config IS the source of truth: it's mounted read-only and `tk apply` fully determines it (mirrors +// ddns-updater's read-only /secret config). The mutable *arr state lives in the apps themselves; +// Buildarr just drives it to match this file. +// +// No web UI / Service / Ingress: Buildarr is a headless daemon that only needs in-cluster egress to +// the apps it manages. Run faithfully to upstream's docker-compose: the image entrypoint handles +// PUID/PGID then execs the CMD, so we set only `args` (`daemon `) and leave the entrypoint +// intact, and mount the config read-only at /config exactly as the compose does. +{ + new( + config, // required -> the full buildarr.yml as a Jsonnet object + name='buildarr', + namespace='default', + image=images.buildarr.fullyQualifiedImageReferencePinned, + timezone='America/Los_Angeles', + ):: { + local this = self, + + // buildarr.yml as an Opaque Secret (not a ConfigMap): the rendered YAML embeds the apps' API keys. + secret: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: name + '-config', namespace: namespace }, + type: 'Opaque', + stringData: { 'buildarr.yml': std.manifestYamlDoc(config) }, + }, + + deployment: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: name, namespace: namespace }, + spec: { + replicas: 1, + selector: { matchLabels: { app: name } }, + template: { + metadata: { + labels: {} + this.deployment.spec.selector.matchLabels, + // ConfigMap/Secret mounts don't roll a Deployment on their own; hashing the config into + // the template makes an edit roll the daemon so it reconciles the new desired state. + annotations: { 'checksum/config': std.md5(std.manifestJsonEx(config, '')) }, + }, + spec: { + tolerations: [ + { key: 'ephemeral', operator: 'Exists', effect: 'NoSchedule' }, + ], + containers: [ + { + name: name, + image: image, + // Mirror upstream's `command: [daemon, ]`: set only the CMD (args) and keep + // the image entrypoint, which applies PUID/PGID before exec'ing buildarr. `daemon` + // reconciles once on start, then on its schedule -- so a wiped *arr DB or UI drift + // self-heals. On first boot it may error until Sonarr/Prowlarr /ping is ready; + // restartPolicy: Always retries until it converges. + args: ['daemon', '/config/buildarr.yml'], + env: [ + { name: 'PUID', value: '1000' }, + { name: 'PGID', value: '1000' }, + { name: 'TZ', value: timezone }, + ], + // Config mounted read-only at /config (exactly as the upstream compose does with + // read_only: true). Buildarr's runtime scratch goes to the writable container root fs. + volumeMounts: [ + { + name: utils.assertEqualAndReturn(this.deployment.spec.template.spec.volumes[0].name, 'config'), + mountPath: '/config', + readOnly: true, + }, + ], + resources: { + requests: { memory: '128Mi', cpu: '50m' }, + limits: { memory: '512Mi', cpu: '500m' }, + }, + }, + ], + volumes: [ + { + name: 'config', + secret: { secretName: utils.assertEqualAndReturn(this.secret.metadata.name, name + '-config') }, + }, + ], + }, + }, + }, + }, + }, +} diff --git a/milky-way/lib/images.libsonnet b/milky-way/lib/images.libsonnet index 796df56..016178e 100644 --- a/milky-way/lib/images.libsonnet +++ b/milky-way/lib/images.libsonnet @@ -64,6 +64,15 @@ local images = { fullyQualifiedRepository: "lscr.io/linuxserver/prowlarr", defaultDigest: { hash: "sha256:7ab5769616c1929247c8e7944453253f0b777fac2724c3bc9976ae2ff4023257", tagHint: "2.4.0.5397-ls150" }, }, + // Buildarr: declaratively reconciles *arr state (used here only to wire Sonarr<->Prowlarr<-> + // qBittorrent together). The image bundles the sonarr/radarr/prowlarr plugins. The hash is the + // multi-arch INDEX digest (same as the *arr/qbittorrent pins above; k3s resolves the per-node + // arch); tagHint is the readable release. Re-resolve with + // `docker buildx imagetools inspect callum027/buildarr:latest`. + buildarr: { + fullyQualifiedRepository: "callum027/buildarr", + defaultDigest: { hash: "sha256:57e2343fefe5d5701364b5e93b4985dbf08310d7b152f70556bdaba7e9475447", tagHint: "0.7.8" }, + }, // Minimal OpenSSH SFTP-only server. The :alpine tag is a single-arch (linux/amd64) manifest -- // matches methanol -- so the digest below is that manifest, not a multi-arch index. "atmoz-sftp": { diff --git a/milky-way/lib/prowlarr.libsonnet b/milky-way/lib/prowlarr.libsonnet index ae1f580..0c0c63a 100644 --- a/milky-way/lib/prowlarr.libsonnet +++ b/milky-way/lib/prowlarr.libsonnet @@ -6,13 +6,19 @@ local images = import 'milky-way/lib/images.libsonnet'; // no media files, so -- unlike Sonarr -- it mounts NO shared media volume; the only persistent // state is its own /config. // -// No VPN sidecar and no config seed (Prowlarr writes config.xml itself on first boot). /config -// holds a SQLite DB + config.xml rewritten at runtime; SQLite over NFS is unsafe, so config lives -// on iSCSI (RWO), and an RWO PVC forces strategy: Recreate (old pod releases before new mounts). -// WebUI exposed over the tailnet via Tailscale L7 ingress. +// No VPN sidecar (and no media volume). /config holds a SQLite DB + config.xml rewritten at +// runtime; SQLite over NFS is unsafe, so config lives on iSCSI (RWO), and an RWO PVC forces +// strategy: Recreate (old pod releases before new mounts). WebUI exposed over the tailnet via +// Tailscale L7 ingress. +// +// Config pinned declaratively via servarr env overrides (PROWLARR__
__, double +// underscore; they win over config.xml every boot): the API key (from a Secret, so it's stable +// rather than the random key Prowlarr would mint on first boot), the explicit port, and the update +// settings that keep Prowlarr from EVER updating itself (mechanism=Docker disables the in-app updater). { new( tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net + apiKey, // required -> Prowlarr API key (from sops; surfaced via the Secret below) name='prowlarr', namespace='default', image=images.prowlarr.fullyQualifiedImageReferencePinned, @@ -23,6 +29,16 @@ local images = import 'milky-way/lib/images.libsonnet'; ):: { local this = self, + // API key as an Opaque Secret (value from the sops-backed `apiKey` param). Mirrors the + // openclaw/gluetun stringData idiom; the env var below reads it via secretKeyRef. + secret: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: name + '-secrets', namespace: namespace }, + type: 'Opaque', + stringData: { apikey: apiKey }, + }, + configPvc: { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -56,6 +72,22 @@ local images = import 'milky-way/lib/images.libsonnet'; { name: 'PUID', value: '1000' }, { name: 'PGID', value: '1000' }, { name: 'TZ', value: timezone }, + // Servarr config overrides (PROWLARR__
__). The PROWLARR__ prefix is the + // application's, independent of the `name` param; these override config.xml each boot. + { name: 'PROWLARR__SERVER__PORT', value: std.toString(port) }, // explicit; same source as containerPort/Service + { name: 'PROWLARR__UPDATE__MECHANISM', value: 'Docker' }, // container-managed -> disables Prowlarr's in-app updater + { name: 'PROWLARR__UPDATE__AUTOMATICALLY', value: 'false' }, // never auto-apply updates + // Auth handled at the network edge (Tailscale ingress is the boundary, same model as + // qbittorrent), so the app itself does no login -> 'External'. This is also what lets + // Buildarr manage Prowlarr: its bundled prowlarr plugin only accepts basic/forms/ + // external and CRASHES reading the servarr default 'none' (Sonarr's newer plugin + // tolerates 'none', so Sonarr needs no equivalent override). + { name: 'PROWLARR__AUTH__METHOD', value: 'External' }, + // API key read from the Secret above -> stable across reboots / config resets. + { + name: 'PROWLARR__AUTH__APIKEY', + valueFrom: { secretKeyRef: { name: this.secret.metadata.name, key: 'apikey' } }, + }, ], ports: [{ name: 'webui', containerPort: port }], volumeMounts: [ diff --git a/milky-way/lib/sonarr.libsonnet b/milky-way/lib/sonarr.libsonnet index 5394b95..c8e8e90 100644 --- a/milky-way/lib/sonarr.libsonnet +++ b/milky-way/lib/sonarr.libsonnet @@ -14,10 +14,17 @@ local images = import 'milky-way/lib/images.libsonnet'; // the SHARED `mdata` RWX-NFS PVC (the same one qbittorrent mounts), mounted here at the same path // so that downloads (/downloads/qbittorrent) and the library tree // (/library/tv) are one filesystem -- hardlinks and atomic moves require that. +// +// Config that we pin declaratively via servarr env overrides (SONARR__
__, double +// underscore; they win over config.xml on every boot): the API key (from a Secret, so it's stable +// and Prowlarr's link to Sonarr is reproducible -- not the random key Sonarr would otherwise mint +// on first boot), the explicit port, and the update settings that keep Sonarr from EVER updating +// itself (mechanism=Docker -> the in-app updater is disabled; updates happen by rolling the image). { new( tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net mediaVolumeClaimName, // required -> external shared RWX PVC (the `mdata` PVC in main.jsonnet) + apiKey, // required -> Sonarr API key (from sops; surfaced via the Secret below) name='sonarr', namespace='default', image=images.sonarr.fullyQualifiedImageReferencePinned, @@ -29,6 +36,16 @@ local images = import 'milky-way/lib/images.libsonnet'; ):: { local this = self, + // API key as an Opaque Secret (value from the sops-backed `apiKey` param). Mirrors the + // openclaw/gluetun stringData idiom; the env var below reads it via secretKeyRef. + secret: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: name + '-secrets', namespace: namespace }, + type: 'Opaque', + stringData: { apikey: apiKey }, + }, + configPvc: { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -62,6 +79,16 @@ local images = import 'milky-way/lib/images.libsonnet'; { name: 'PUID', value: '1000' }, { name: 'PGID', value: '1000' }, { name: 'TZ', value: timezone }, + // Servarr config overrides (SONARR__
__). The SONARR__ prefix is the + // application's, independent of the `name` param; these override config.xml each boot. + { name: 'SONARR__SERVER__PORT', value: std.toString(port) }, // explicit; same source as containerPort/Service + { name: 'SONARR__UPDATE__MECHANISM', value: 'Docker' }, // container-managed -> disables Sonarr's in-app updater + { name: 'SONARR__UPDATE__AUTOMATICALLY', value: 'false' }, // never auto-apply updates + // API key read from the Secret above -> stable across reboots / config resets. + { + name: 'SONARR__AUTH__APIKEY', + valueFrom: { secretKeyRef: { name: this.secret.metadata.name, key: 'apikey' } }, + }, ], ports: [{ name: 'webui', containerPort: port }], volumeMounts: [ diff --git a/milky-way/lib/utils.libsonnet b/milky-way/lib/utils.libsonnet index 6c5bdeb..88d528d 100644 --- a/milky-way/lib/utils.libsonnet +++ b/milky-way/lib/utils.libsonnet @@ -1,3 +1,6 @@ +// The cluster's DNS domain (k3s/Kubernetes default). Used to build in-cluster Service FQDNs. +local clusterDomain = 'cluster.local'; + { assertEqualAndReturn(got, expected):: ( assert got == expected : 'Expected ' + std.toString(expected) + ', got ' + std.toString(got); @@ -7,4 +10,27 @@ assert predicate(value) : message; value ), -} \ No newline at end of file + + // In-cluster DNS FQDN of a Kubernetes Service object: ..svc.. + // Validates that the argument really is a core/v1 Service with a name and namespace, throwing a + // descriptive error otherwise, so a wrong object passed at a wiring point fails at evaluation + // rather than silently producing a bogus hostname. + domainOfService(k8sObject):: ( + assert std.isObject(k8sObject) : + 'domainOfService: expected a Kubernetes Service object, got ' + std.toString(k8sObject); + assert std.objectHas(k8sObject, 'kind') && k8sObject.kind == 'Service' : + "domainOfService: expected kind 'Service', got " + + (if std.objectHas(k8sObject, 'kind') then std.toString(k8sObject.kind) else ''); + assert std.objectHas(k8sObject, 'apiVersion') && k8sObject.apiVersion == 'v1' : + "domainOfService: expected a core Service with apiVersion 'v1', got " + + (if std.objectHas(k8sObject, 'apiVersion') then std.toString(k8sObject.apiVersion) else ''); + assert std.objectHas(k8sObject, 'metadata') : + 'domainOfService: Service object has no metadata'; + local meta = k8sObject.metadata; + assert std.objectHas(meta, 'name') && std.isString(meta.name) && meta.name != '' : + 'domainOfService: Service metadata.name is not defined'; + assert std.objectHas(meta, 'namespace') && std.isString(meta.namespace) && meta.namespace != '' : + 'domainOfService: Service metadata.namespace is not defined'; + '%s.%s.svc.%s' % [meta.name, meta.namespace, clusterDomain] + ), +} diff --git a/secrets/k8s-config/k8s-secret-values.jsonnet b/secrets/k8s-config/k8s-secret-values.jsonnet index 934f1e7..02d632b 100644 --- a/secrets/k8s-config/k8s-secret-values.jsonnet +++ b/secrets/k8s-config/k8s-secret-values.jsonnet @@ -1,5 +1,5 @@ { - "data": "ENC[AES256_GCM,data:KD7l8d2eby3Yd2cndOdU3h87EJcmKn1uzzChdk+l4l4+Q2UKia2TKVRVf+ADHwuNv2eyB9Pi7g+pLCR4emmOBUMfIVjGtlzS/aAqZNqWWolVk04x/DcJP4Hb5FpNRmL2g3gk0eP9U4FXiDgJTqiPEgP5HhOYzzP44nOyII0fcUuiJBvX2ixb3EjgMdJyA0cvnSpE5wEvPwdldMhECQL2pKIb06YRijhbZ/V0YB9P2OdxGa8k4uiTcnyfrh2xPHMJzsy9XNS7xvOc7jeW0j07immsVi7njpm6LsZ6q+1c7tUFHK2+uYlQKlHc4vLbmo47uaw2gXqigSHQ4+eNCjap1K39FBn7gdNhZQcMdn4epLJS02Ib9rWm5W5e+ZYjJ+XI9L4lNETp/+SjBlAibEtvPyIi513AkGOL7mMh8bQpHYrgm00XjPDCaVgErnLMNynAYeeU3v4UNobwMF1AgTEo77foNH4ofbpAR7Hk2ltRk9+dgexU/Ujx3CZMQ28jZEK4Oub7EHozw7YZIettXhEo4seP0oabWE/A+HCsjE/h8HM38HJeK6U/TuzsPjnpwwPx9xtzDhYC5H8aHIJcnDPLPwfR14lSRfGDB1dRJ318gkMki8yWqV67DgAWnfVue6pL+EXJojwGPrQj58XX0G5vTBuW3b0AqWp5KFYBuDlv7En3RCEADLJ5P3jtLKqYY0SKUpJoBygLXbdOJvP1lzB3p5rodfPkdn1o66VVF2jhzfoRhbYjUMV3nkAK9Bh/tTv2VHyDpOLcINeHbt+NWgRcnuj2OY0MhVXMWgzjbEuJfh6RIhil/0cfLWptzhUvK9wDYBvZIbK0UPJHyKKa/lqFLuST1nSNHYsf4eBMUjJ8tnTV2w0bUOhKEL3To9S53HtlMBjWRgB2hyXbKynr3r6Oi5ybgbCcVtCJFPGgylwm5RWUdak4/L+xPyBsK0cj+hoc1y62AhnfPTprwdgS1tXkTSS/1BmN/Fsb30nSAUEldQuYVRL9Q5StHOlFNF5jgJjMmGz0MDundWDLu+SPQeF1Yg6kVjDw3ktkRvY3GWSngF0x5TxpECLhigGQPA/SUZ7Rnk6rx+pRtoceZUC/D5bja0XYCs0mt1nDdtdzkVpePvWV6MYUq3C8AQCJkqK31AaxK6TpE30G6wv91w37zMGUKOg+kxFKw6McxSHrqBci3S0JHOlZuIF+tZWdeS2oDm5zgWXciTcNiVSB6sJ6HsoTfhkFsblJdLlkhR14LCAjvAjNK1EnKc/Yze8JCk17xuyXAzaOEHiN3kZa5sQZM82zXSEiGuAjKNyhpt86QAplXSBqKpDH9yt5SOTHJpqPJLWruTqWsMJJ3qlE4wrft+ruFvogexQ5tU1cMJJYo3LmnEE+p1P9Hrgi1dS2FPMS9jy4jjlQA+JB0iUXUGmSFcb5jWawGqrs+6eIriRNlc12cZUABmvEo6pWe1eHTr75y3QIvDu6Rm9DMCz1ULuuT+otlaPsC0nWGuzQKLDO+GaCJNqBjlQiC5C+o1xQ2TYR7Ay1S9GQw2qwaJpyzb+1drEkAsl0Z//GIbxxSwOswx2I93XbjZfCxaYQ9Wc/pZwA2KUnPCvNXY9Ybeix8ufwoz1vcSD3LguIeyi4Wbk3gF93oA0L/Y44qS1n5E8RFLztF5hcfQWTHkZzVGmqiyMqngxR3/wMIarVjfMYLt47uhHmHzRbJZ54ngS4xfs/5rBZAx064qlsLtgCBZ84nVGD9yMS1tssvv2UKNoe7ApNW4wS4wWrh5Rhf2F220WM6a5N8iwfwwwjZol4WHmtETyYofixmC+yLBryfuniIOwKfvwgj7qDHilkrXHJ,iv:t6rZdhMVbWEb8qOpnx+zR/lP4RAhkZOSr3bfg3eo88c=,tag:G6rN1uQlUZV2vu5R2wf3sg==,type:str]", + "data": "ENC[AES256_GCM,data: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,iv:iIM9wo0/KnqDVJPqX5HeYdhPUGs78Rn60cyNX2c1iYk=,tag:gabZ0Sce6hqnMHbp2IVviw==,type:str]", "sops": { "age": [ { @@ -11,8 +11,8 @@ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZNjF5OUx2M1k2YlRiekda\nWHh0QWdQNGZrNFpiM1haZlBPeGRBZjJOOTJrCm9wOVE3cVZhYmNIVjgwWGtDVjRQ\neHNGWW81c0tLK0VYY0RnMk5PSjRzYlkKLS0tIGJQWEhaY0wwaVV0NUNXaXFraExy\na2NwZUhCbng1YWxpUGp6dU9OVXBTNVUKUtj4Ms9tlqFEXbT+cirIiFNZFD8oPbPx\nPu9zxmIl7BC2I1v/c9ijFft7XR+somx7wX5ISIIhBwTDq9Pr4fqovA==\n-----END AGE ENCRYPTED FILE-----\n" } ], - "lastmodified": "2026-06-08T01:06:43Z", - "mac": "ENC[AES256_GCM,data:KfNEl5YNhmhBmbMObvzu5qSMcjg/8mDbWw7thHA9ZJhDhj8FGy+BwqBXGjxtPQwAKoXkpto29Qs26xvt9EwphVgrgppuoMla0YrIIdgXSDVhzwDRFKmsUSPhB5zajpZYqjHcoq3avYR8WVSLG+kwuNczATaa1uGF63yfbgqbtVE=,iv:lVcOs0HyCAamyuNEQnuM1LiIK85p0eRqgnecvLUHwsI=,tag:y6Mzw1zrQEVqfB0ZotcKAg==,type:str]", + "lastmodified": "2026-06-20T19:00:19Z", + "mac": "ENC[AES256_GCM,data:6dX4woaEmDvuOFdSMPspNujpuEk8TMsZLWC6g/X7JRNlKfa/8DUyQ+L5AUNLVjqThGJ6hro0s6n/Uku+6Y5BiTToHslt/Q81kmJPiatboDMte9PM5fq8IN/vFpOmkZNDQToIbxg6+evH/g3g4Git6t7cm8EgUAPiqaw3ZNqT4cE=,iv:dHA9dkhrnoexvAmYo07PbybVnDJn3raBZ82eSTfsTC4=,tag:zP3NRt/W7Zrfn/PbPBVocw==,type:str]", "unencrypted_suffix": "_unencrypted", "version": "3.10.2" }