Skip to content

Commit 5ee096a

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

4 files changed

Lines changed: 149 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: 16 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,21 @@ 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+
|> Console.throttle(count: 100, pause: 1)
307+
|> Stream.chunk_every(100)
308+
|> Stream.each(fn bindings ->
309+
ids = Enum.map(bindings, & &1.id)
310+
Logger.info "pruning #{length(ids)} dangling policy bindings"
311+
PolicyBinding.for_ids(ids)
312+
|> Repo.delete_all(timeout: 10_000)
313+
end)
314+
|> Stream.run()
315+
end
316+
301317
def add_ignore_crds(search) do
302318
Service.search(search)
303319
|> Repo.stream(method: :keyset)

lib/console/schema/policy_binding.ex

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

57
schema "policy_bindings" do
68
field :policy_id, :binary_id
@@ -10,6 +12,109 @@ defmodule Console.Schema.PolicyBinding do
1012
timestamps()
1113
end
1214

15+
@doc """
16+
Fetches all tables and their policy-related columns from the database schema.
17+
Returns a map of table_name => [column_names] for uuid columns ending in '_policy_id' or 'bindings_id'.
18+
"""
19+
def policy_columns do
20+
from(c in "columns",
21+
prefix: "information_schema",
22+
where: like(c.column_name, "%policy_id") or c.column_name == "bindings_id",
23+
where: c.table_schema == "public",
24+
where: c.table_name != "policy_bindings",
25+
where: c.data_type == "uuid",
26+
select: {c.table_name, c.column_name},
27+
order_by: [c.table_name, c.column_name]
28+
)
29+
|> Repo.all(timeout: 30_000)
30+
|> Enum.group_by(fn {table, _col} -> table end, fn {_table, col} -> col end)
31+
end
32+
33+
@doc """
34+
Returns IDs of policy bindings not referenced by any of the given tables.
35+
Uses a database-side subquery with LEFT JOIN to avoid loading all policy_ids into memory.
36+
37+
## Parameters
38+
- `table_columns` - A map of table_name => [column_names] to check for references.
39+
If not provided, defaults to all policy columns discovered via `policy_columns/0`.
40+
41+
## Examples
42+
43+
# Check against all tables with policy columns
44+
PolicyBinding.dangling_ids()
45+
46+
# Check against specific tables only
47+
PolicyBinding.dangling_ids(%{"clusters" => ["read_policy_id", "write_policy_id"]})
48+
49+
"""
50+
def dangling_ids(table_columns \\ nil) do
51+
table_columns = table_columns || policy_columns()
52+
53+
case build_referenced_subquery(table_columns) do
54+
nil ->
55+
# No tables to check against, all bindings are considered "dangling"
56+
from(pb in __MODULE__, select: pb.id, order_by: [asc: pb.id])
57+
|> Repo.all(timeout: 300_000)
58+
59+
referenced_subquery ->
60+
from(pb in __MODULE__,
61+
left_join: r in subquery(referenced_subquery),
62+
on: pb.policy_id == r.policy_id,
63+
where: is_nil(r.policy_id),
64+
select: pb.id,
65+
order_by: [asc: pb.id]
66+
)
67+
|> Repo.all(timeout: 300_000)
68+
end
69+
end
70+
71+
@doc """
72+
Builds a subquery that unions all referenced policy_ids from the given tables.
73+
Returns nil if there are no tables/columns to check.
74+
"""
75+
def build_referenced_subquery(table_columns) when map_size(table_columns) == 0, do: nil
76+
77+
def build_referenced_subquery(table_columns) do
78+
queries =
79+
table_columns
80+
|> Enum.flat_map(fn {table, columns} ->
81+
Enum.map(columns, fn col ->
82+
col_atom = String.to_atom(col)
83+
84+
from(t in table,
85+
select: %{policy_id: field(t, ^col_atom)},
86+
where: not is_nil(field(t, ^col_atom))
87+
)
88+
end)
89+
end)
90+
91+
case queries do
92+
[] -> nil
93+
[single] -> single
94+
[first | rest] ->
95+
Enum.reduce(rest, first, fn query, acc ->
96+
from(q in acc, union_all: ^query)
97+
end)
98+
end
99+
end
100+
101+
@doc """
102+
Returns an Ecto query for dangling policy bindings.
103+
104+
## Parameters
105+
- `query` - Base query to filter (defaults to PolicyBinding)
106+
- `table_columns` - Optional map of table_name => [column_names] to check against
107+
108+
"""
109+
def dangling(query \\ __MODULE__, table_columns \\ nil) do
110+
ids = dangling_ids(table_columns)
111+
from(p in query, where: p.id in ^ids)
112+
end
113+
114+
def ordered(query \\ __MODULE__, order \\ [asc: :id]) do
115+
from(p in query, order_by: ^order)
116+
end
117+
13118
@valid ~w(user_id group_id policy_id)a
14119

15120
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)