Skip to content

Commit c7ee50e

Browse files
committed
gc cron job to clean up dangling policy_bindings entries
1 parent 28bb18e commit c7ee50e

4 files changed

Lines changed: 104 additions & 0 deletions

File tree

config/prod.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ config :console, Console.Cron.Scheduler,
6868
{"15 * * * *", {Console.Deployments.Cron, :prune_vuln_reports, []}},
6969
{"*/15 * * * *", {Console.Deployments.Cron, :pr_governance, []}},
7070
{"15 3 * * *", {Console.Deployments.Cron, :prune_dangling_templates, []}},
71+
{"20 3 * * *", {Console.Deployments.Cron, :prune_dangling_policy_bindings, []}},
7172
{"30 3 * * *", {Console.Deployments.Cron, :prune_insight_components, []}},
7273
{"0 4 * * *", {Console.Deployments.Cron, :prune_helm_repositories, []}},
7374
{"0 5 * * *", {Console.Deployments.Cron, :prune_agent_run_repositories, []}},

lib/console/deployments/cron.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ defmodule Console.Deployments.Cron do
1818
AppNotification,
1919
Alert,
2020
ClusterAuditLog,
21+
PolicyBinding,
2122
PolicyConstraint,
2223
VulnerabilityReport,
2324
ServiceTemplate,
@@ -298,6 +299,20 @@ defmodule Console.Deployments.Cron do
298299
|> Stream.run()
299300
end
300301

302+
def prune_dangling_policy_bindings() do
303+
PolicyBinding.dangling()
304+
|> PolicyBinding.ordered(asc: :id)
305+
|> Repo.stream(method: :keyset)
306+
|> Stream.chunk_every(100)
307+
|> Stream.each(fn bindings ->
308+
ids = Enum.map(bindings, & &1.id)
309+
Logger.info "pruning #{length(ids)} dangling policy bindings"
310+
PolicyBinding.for_ids(ids)
311+
|> Repo.delete_all(timeout: 10_000)
312+
end)
313+
|> Stream.run()
314+
end
315+
301316
def add_ignore_crds(search) do
302317
Service.search(search)
303318
|> Repo.stream(method: :keyset)

lib/console/schema/policy_binding.ex

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule Console.Schema.PolicyBinding do
22
use Piazza.Ecto.Schema
33
alias Console.Schema.{User, Group}
4+
alias Console.Repo
45

56
schema "policy_bindings" do
67
field :policy_id, :binary_id
@@ -10,6 +11,66 @@ defmodule Console.Schema.PolicyBinding do
1011
timestamps()
1112
end
1213

14+
@doc """
15+
Fetches all tables and their policy-related columns from the database schema.
16+
Returns a map of table_name => [column_names] for uuid columns ending in '_policy_id' or 'bindings_id'.
17+
"""
18+
def policy_columns do
19+
query = """
20+
SELECT table_name, column_name
21+
FROM information_schema.columns
22+
WHERE (column_name LIKE '%policy_id' OR column_name = 'bindings_id')
23+
AND table_schema = 'public'
24+
AND table_name != 'policy_bindings'
25+
AND data_type = 'uuid'
26+
ORDER BY table_name, column_name
27+
"""
28+
29+
Repo.query!(query, [], timeout: 30_000).rows
30+
|> Enum.group_by(fn [table, _col] -> table end, fn [_table, col] -> col end)
31+
end
32+
33+
@doc """
34+
Returns IDs of dangling policy bindings (bindings whose policy_id is not referenced anywhere).
35+
Uses database introspection to dynamically find all tables with policy references.
36+
"""
37+
def dangling_ids do
38+
table_columns = policy_columns()
39+
where_clause = build_where_clause(table_columns)
40+
41+
sql = """
42+
SELECT pb.id FROM policy_bindings pb
43+
WHERE #{where_clause}
44+
ORDER BY pb.id ASC
45+
"""
46+
47+
Repo.query!(sql, [], timeout: 300_000).rows
48+
|> List.flatten()
49+
|> Enum.map(&Ecto.UUID.load!/1)
50+
end
51+
52+
@doc """
53+
Returns an Ecto query for dangling policy bindings.
54+
Note: This loads dangling IDs first, so for very large datasets consider using dangling_ids/0 directly.
55+
"""
56+
def dangling(query \\ __MODULE__) do
57+
ids = dangling_ids()
58+
from(p in query, where: p.id in ^ids)
59+
end
60+
61+
defp build_where_clause(table_columns) do
62+
table_columns
63+
|> Enum.map(fn {table, columns} ->
64+
conditions = Enum.map_join(columns, " OR ", fn col -> "#{col} = pb.policy_id" end)
65+
"NOT EXISTS(SELECT 1 FROM #{table} WHERE #{conditions})"
66+
end)
67+
|> Enum.join(" AND ")
68+
end
69+
70+
def ordered(query \\ __MODULE__, order \\ [asc: :id]) do
71+
from(p in query, order_by: ^order)
72+
end
73+
1374
@valid ~w(user_id group_id policy_id)a
1475

1576
def changeset(model, attrs \\ %{}) do

test/console/deployments/cron_test.exs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,4 +349,31 @@ defmodule Console.Deployments.CronTest do
349349
assert refetch(keep)
350350
end
351351
end
352+
353+
describe "#prune_dangling_policy_bindings/0" do
354+
test "it will prune dangling policy bindings" do
355+
user = insert(:user)
356+
357+
# Create a project with write_bindings - these should be kept
358+
project = insert(:project, write_bindings: [%{user_id: user.id}])
359+
%{write_bindings: [kept_binding]} = Console.Repo.preload(project, [:write_bindings])
360+
361+
# Create orphaned policy bindings with random policy_ids that don't exist anywhere
362+
orphaned = for _ <- 1..3 do
363+
%Console.Schema.PolicyBinding{}
364+
|> Console.Schema.PolicyBinding.changeset(%{
365+
policy_id: Ecto.UUID.generate(),
366+
user_id: user.id
367+
})
368+
|> Console.Repo.insert!()
369+
end
370+
:ok = Cron.prune_dangling_policy_bindings()
371+
372+
# Referenced binding should still exist
373+
assert refetch(kept_binding)
374+
375+
# Orphaned bindings should be deleted
376+
for binding <- orphaned, do: refute refetch(binding)
377+
end
378+
end
352379
end

0 commit comments

Comments
 (0)