New to Kestra?
Use blueprints to kickstart your first workflows.
Find Cloud Spanner tables keeping timestamped data with no row deletion policy, alert Slack, add the policy with DDL and re-check INFORMATION_SCHEMA.
Cloud Spanner can expire old rows for you with a row deletion policy (its TTL feature), but only on tables where someone declared one. A table that collects events or logs with a created_at timestamp and no policy grows without bound and nobody notices until storage and query cost climb. The policy is one line of DDL, so the gap is almost always an oversight.
This blueprint reads INFORMATION_SCHEMA and finds every base table that has the retention timestamp column but a null ROW_DELETION_POLICY_EXPRESSION. It alerts Slack and can add the missing policy with ALTER TABLE ADD ROW DELETION POLICY, one table at a time. It then reads INFORMATION_SCHEMA again, so the run only succeeds when each table carries a policy.
It runs against the Cloud Spanner emulator for a safe demo with no credentials and against managed Spanner in production.
This blueprint was created by zkasuran.
A table is in scope when it is a base table in the user schema and has a TIMESTAMP column named by retention_column (default created_at). A finding is such a table whose ROW_DELETION_POLICY_EXPRESSION is null. Tables with no retention column are never flagged, so reference and lookup tables are left alone.
Read-only by default. The ALTER runs only when auto_remediate is true and dry_run is false. The emulator accepts the policy DDL and reflects it in INFORMATION_SCHEMA but does not run the background deletion, so the re-check asserts the policy exists, never that rows were removed. On managed Spanner the background job then enforces it.
detect (io.kestra.plugin.gcp.spanner.Query, read-only) lists base tables in the user schema that have the retention timestamp column and no row deletion policy. Each row carries the table, the column and a ready ALTER TABLE ADD ROW DELETION POLICY statement.summary logs every finding.route (io.kestra.plugin.core.flow.If):alert posts to Slack.remediate_gate runs only when auto_remediate is true and dry_run is false.remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) adds one policy per task.reverify reads INFORMATION_SCHEMA again, then verify_result fails the run if any scoped table still has no policy.errors alerts Slack when a query or statement cannot run.INFORMATION_SCHEMA that each table carries a policy after the run.The catalog query finds the gaps once. Kestra runs it on a schedule and after every migration, turning the result into a Slack alert. It adds the policies behind auto_remediate and dry_run, one per task so one failure stays isolated, verifies the result and keeps a history of what changed and when.
On Kestra 2.0.5 OSS against the Cloud Spanner emulator (gcr.io/cloud-spanner-emulator/emulator), with a local HTTP sink standing in for Slack. The database held three base tables:
audit_events: created_at TIMESTAMP with a policy already (never flagged);session_logs: created_at TIMESTAMP with no policy (the gap);country_codes: no retention column (out of scope).| Run | Inputs | Result |
|---|---|---|
| 1 | defaults, degraded | 1 finding (session_logs), alert sent, no change |
| 2 | auto_remediate: true, dry_run: true |
Same report, policy not added |
| 3 | auto_remediate: true, dry_run: false |
Policy added and verified, session_logs now OLDER_THAN(created_at, INTERVAL 90 DAY) |
| 4 | defaults, after the fix | 0 findings, clean |
| 5 | unreachable database | errors alert sent |
Confirmed against the emulator's own INFORMATION_SCHEMA: session_logs had a null policy before run 3 and the OLDER_THAN expression after.
docker run -d --name spanner -p 9010:9010 -p 9020:9020 gcr.io/cloud-spanner-emulator/emulator, then create an instance and a database through the emulator admin API.created_at TIMESTAMP column and no row deletion policy, so there is a gap to find.SLACK_WEBHOOK_URL: used by alert and the errors block.GCP_SERVICE_ACCOUNT: a service account JSON for managed Spanner. Not read when emulator_host is set, because the emulator needs no credentials.SECRET_.| Input | Default | Purpose |
|---|---|---|
project_id |
test-project |
GCP project |
instance_id |
w26-inst |
Spanner instance |
database_id |
governance |
Spanner database |
emulator_host |
localhost:9010 |
Emulator address. An empty value is not valid, see How to extend for managed Spanner |
retention_column |
created_at |
Timestamp column that marks a table for retention |
retention_days |
90 |
OLDER_THAN interval used by the fix |
auto_remediate |
false |
Allow the policy to be added |
dry_run |
true |
Must be false for the ALTER to run |
SLACK_WEBHOOK_URL secret.emulator_host set to the emulator address.auto_remediate: true and dry_run: false.weekly trigger, which always runs in safe mode.emulatorHost line from the three Spanner tasks (the plugin treats an empty value as a bad host, so the property must be absent) and set GCP_SERVICE_ACCOUNT.Pause before adding policies on busy tables.