Skip to content

Commit b023205

Browse files
feat: add DNS service discovery for upstream groups (Phase 11.2)
Add DNS SRV-based service discovery that automatically resolves and reconciles upstream targets. Includes discovery source CRUD, periodic sync via Oban worker, manual refresh, auto-sync toggle, LiveView UI section on upstream group show page, and 5 REST API endpoints. 681 tests passing (33 new).
1 parent 5dfa607 commit b023205

14 files changed

Lines changed: 1487 additions & 54 deletions

File tree

config/test.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ config :sentinel_cp, :github_webhook, secret: "test_webhook_secret"
5151
# Use mock GitHub client in tests
5252
config :sentinel_cp, :github_client, SentinelCp.Webhooks.GitHubClient.Mock
5353

54+
# Use mock DNS resolver in tests
55+
config :sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Mock
56+
5457
# Wallaby E2E test configuration
5558
config :wallaby,
5659
otp_app: :sentinel_cp,

lib/sentinel_cp/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ defmodule SentinelCp.Application do
3030
SentinelCp.Nodes.DriftWorker.ensure_started()
3131
SentinelCp.Services.CertificateExpiryWorker.ensure_started()
3232
SentinelCp.Analytics.PruneWorker.ensure_started()
33+
SentinelCp.Services.DiscoverySyncWorker.ensure_started()
3334

3435
result
3536
end

lib/sentinel_cp/services.ex

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule SentinelCp.Services do
88

99
import Ecto.Query, warn: false
1010
alias SentinelCp.Repo
11-
alias SentinelCp.Services.{Service, ServiceTemplate, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy, OpenApiSpec}
11+
alias SentinelCp.Services.{Service, ServiceTemplate, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy, OpenApiSpec, DiscoverySource, DiscoverySync}
1212

1313
## Services
1414

@@ -483,6 +483,140 @@ defmodule SentinelCp.Services do
483483
end)
484484
end
485485

486+
## Discovery Sources
487+
488+
@doc """
489+
Lists discovery sources for a project, preloading upstream_group.
490+
"""
491+
def list_discovery_sources(project_id) do
492+
from(d in DiscoverySource,
493+
where: d.project_id == ^project_id,
494+
preload: [:upstream_group]
495+
)
496+
|> Repo.all()
497+
end
498+
499+
@doc """
500+
Gets a single discovery source by ID.
501+
"""
502+
def get_discovery_source(id), do: Repo.get(DiscoverySource, id)
503+
504+
@doc """
505+
Gets a single discovery source by ID, raises if not found.
506+
"""
507+
def get_discovery_source!(id), do: Repo.get!(DiscoverySource, id)
508+
509+
@doc """
510+
Gets the discovery source for an upstream group, if any.
511+
"""
512+
def get_discovery_source_for_group(upstream_group_id) do
513+
from(d in DiscoverySource, where: d.upstream_group_id == ^upstream_group_id)
514+
|> Repo.one()
515+
end
516+
517+
@doc """
518+
Creates a discovery source.
519+
"""
520+
def create_discovery_source(attrs) do
521+
%DiscoverySource{}
522+
|> DiscoverySource.changeset(attrs)
523+
|> Repo.insert()
524+
end
525+
526+
@doc """
527+
Updates a discovery source (hostname, interval, auto_sync).
528+
"""
529+
def update_discovery_source(%DiscoverySource{} = source, attrs) do
530+
source
531+
|> DiscoverySource.update_changeset(attrs)
532+
|> Repo.update()
533+
end
534+
535+
@doc """
536+
Deletes a discovery source.
537+
"""
538+
def delete_discovery_source(%DiscoverySource{} = source) do
539+
Repo.delete(source)
540+
end
541+
542+
@doc """
543+
Lists all discovery sources with auto_sync enabled (across all projects).
544+
"""
545+
def list_auto_sync_sources do
546+
from(d in DiscoverySource, where: d.auto_sync == true)
547+
|> Repo.all()
548+
end
549+
550+
@doc """
551+
Synchronizes a discovery source by resolving DNS SRV records and reconciling targets.
552+
553+
Returns `{:ok, %{added: N, removed: N, kept: N}}` on success or `{:error, reason}` on failure.
554+
"""
555+
def sync_discovery_source(%DiscoverySource{} = source) do
556+
# Mark as syncing
557+
{:ok, source} =
558+
source
559+
|> DiscoverySource.sync_changeset(%{last_sync_status: "syncing"})
560+
|> Repo.update()
561+
562+
case dns_resolver().resolve_srv(source.hostname) do
563+
{:ok, records} ->
564+
group = get_upstream_group!(source.upstream_group_id)
565+
current_targets = group.targets || []
566+
567+
result = DiscoverySync.reconcile(current_targets, records)
568+
569+
# Apply additions
570+
for target_attrs <- result.add do
571+
add_upstream_target(
572+
Map.merge(target_attrs, %{upstream_group_id: source.upstream_group_id})
573+
)
574+
end
575+
576+
# Apply removals
577+
for target <- result.remove do
578+
remove_upstream_target(target)
579+
end
580+
581+
total_count = length(result.add) + length(result.keep)
582+
583+
{:ok, source} =
584+
source
585+
|> DiscoverySource.sync_changeset(%{
586+
last_synced_at: DateTime.utc_now() |> DateTime.truncate(:second),
587+
last_sync_status: "synced",
588+
last_sync_error: nil,
589+
last_sync_targets_count: total_count
590+
})
591+
|> Repo.update()
592+
593+
Phoenix.PubSub.broadcast(
594+
SentinelCp.PubSub,
595+
"discovery:#{source.id}",
596+
{:discovery_synced, source.id}
597+
)
598+
599+
{:ok, %{added: length(result.add), removed: length(result.remove), kept: length(result.keep)}}
600+
601+
{:error, reason} ->
602+
error_msg = if is_binary(reason), do: reason, else: inspect(reason)
603+
604+
source
605+
|> DiscoverySource.sync_changeset(%{
606+
last_synced_at: DateTime.utc_now() |> DateTime.truncate(:second),
607+
last_sync_status: "error",
608+
last_sync_error: error_msg
609+
})
610+
|> Repo.update()
611+
612+
{:error, error_msg}
613+
end
614+
end
615+
616+
defp dns_resolver do
617+
Application.get_env(:sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Inet)
618+
end
619+
486620
defp resolve_auth_policy_id(%{security_refs: refs}, policy_map) when is_list(refs) do
487621
Enum.find_value(refs, fn ref -> Map.get(policy_map, ref) end)
488622
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
defmodule SentinelCp.Services.DiscoverySource do
2+
@moduledoc """
3+
Schema for DNS-based service discovery sources.
4+
5+
A discovery source attaches to an upstream group and periodically resolves
6+
SRV records to reconcile upstream targets automatically.
7+
"""
8+
use Ecto.Schema
9+
import Ecto.Changeset
10+
11+
@primary_key {:id, :binary_id, autogenerate: true}
12+
@foreign_key_type :binary_id
13+
14+
@source_types ~w(dns_srv)
15+
@sync_statuses ~w(pending syncing synced error)
16+
17+
schema "discovery_sources" do
18+
field :source_type, :string, default: "dns_srv"
19+
field :hostname, :string
20+
field :sync_interval_seconds, :integer, default: 60
21+
field :auto_sync, :boolean, default: true
22+
field :last_synced_at, :utc_datetime
23+
field :last_sync_status, :string, default: "pending"
24+
field :last_sync_error, :string
25+
field :last_sync_targets_count, :integer, default: 0
26+
27+
belongs_to :upstream_group, SentinelCp.Services.UpstreamGroup
28+
belongs_to :project, SentinelCp.Projects.Project
29+
30+
timestamps(type: :utc_datetime)
31+
end
32+
33+
def changeset(source, attrs) do
34+
source
35+
|> cast(attrs, [
36+
:source_type,
37+
:hostname,
38+
:sync_interval_seconds,
39+
:auto_sync,
40+
:upstream_group_id,
41+
:project_id
42+
])
43+
|> validate_required([:hostname, :upstream_group_id, :project_id])
44+
|> validate_inclusion(:source_type, @source_types)
45+
|> validate_number(:sync_interval_seconds, greater_than_or_equal_to: 10)
46+
|> unique_constraint(:upstream_group_id)
47+
|> foreign_key_constraint(:upstream_group_id)
48+
|> foreign_key_constraint(:project_id)
49+
end
50+
51+
def update_changeset(source, attrs) do
52+
source
53+
|> cast(attrs, [:hostname, :sync_interval_seconds, :auto_sync])
54+
|> validate_required([:hostname])
55+
|> validate_number(:sync_interval_seconds, greater_than_or_equal_to: 10)
56+
end
57+
58+
def sync_changeset(source, attrs) do
59+
source
60+
|> cast(attrs, [
61+
:last_synced_at,
62+
:last_sync_status,
63+
:last_sync_error,
64+
:last_sync_targets_count
65+
])
66+
|> validate_inclusion(:last_sync_status, @sync_statuses)
67+
end
68+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
defmodule SentinelCp.Services.DiscoverySync do
2+
@moduledoc """
3+
Pure-function module for reconciling upstream targets with DNS SRV records.
4+
5+
No database access — operates on in-memory data structures.
6+
"""
7+
8+
@doc """
9+
Reconciles current upstream targets against resolved SRV records.
10+
11+
Returns a map with:
12+
- `:add` — list of `%{host, port, weight}` maps for new targets
13+
- `:remove` — list of target structs to remove
14+
- `:keep` — list of target structs to keep
15+
"""
16+
def reconcile(current_targets, srv_records) do
17+
resolved = Enum.map(srv_records, &srv_to_target/1)
18+
resolved_set = MapSet.new(resolved, fn t -> {t.host, t.port} end)
19+
current_set = MapSet.new(current_targets, fn t -> {t.host, t.port} end)
20+
21+
add =
22+
resolved
23+
|> Enum.filter(fn t -> not MapSet.member?(current_set, {t.host, t.port}) end)
24+
25+
remove =
26+
current_targets
27+
|> Enum.filter(fn t -> not MapSet.member?(resolved_set, {t.host, t.port}) end)
28+
29+
keep =
30+
current_targets
31+
|> Enum.filter(fn t -> MapSet.member?(resolved_set, {t.host, t.port}) end)
32+
33+
%{add: add, remove: remove, keep: keep}
34+
end
35+
36+
defp srv_to_target({_priority, weight, port, host}) do
37+
%{
38+
host: to_string(host),
39+
port: port,
40+
weight: max(weight, 1)
41+
}
42+
end
43+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
defmodule SentinelCp.Services.DiscoverySyncWorker do
2+
@moduledoc """
3+
Oban worker that periodically syncs DNS-based discovery sources.
4+
5+
Checks all auto_sync sources and triggers sync when the configured
6+
interval has elapsed since the last sync.
7+
"""
8+
use Oban.Worker,
9+
queue: :maintenance,
10+
max_attempts: 1,
11+
unique: [period: 30]
12+
13+
require Logger
14+
15+
alias SentinelCp.Services
16+
17+
@default_interval_seconds 30
18+
19+
@impl Oban.Worker
20+
def perform(%Oban.Job{}) do
21+
Logger.debug("DiscoverySyncWorker: checking discovery sources")
22+
23+
sources = Services.list_auto_sync_sources()
24+
25+
for source <- sources do
26+
if sync_due?(source) do
27+
case Services.sync_discovery_source(source) do
28+
{:ok, result} ->
29+
Logger.info(
30+
"DiscoverySyncWorker: synced #{source.hostname} — " <>
31+
"added: #{result.added}, removed: #{result.removed}, kept: #{result.kept}"
32+
)
33+
34+
{:error, reason} ->
35+
Logger.warning("DiscoverySyncWorker: failed to sync #{source.hostname}: #{reason}")
36+
end
37+
end
38+
end
39+
40+
reschedule()
41+
:ok
42+
end
43+
44+
defp sync_due?(%{last_synced_at: nil}), do: true
45+
46+
defp sync_due?(source) do
47+
elapsed = DateTime.diff(DateTime.utc_now(), source.last_synced_at, :second)
48+
elapsed >= source.sync_interval_seconds
49+
end
50+
51+
defp reschedule do
52+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
53+
54+
unless oban_config[:testing] do
55+
%{}
56+
|> __MODULE__.new(schedule_in: @default_interval_seconds)
57+
|> Oban.insert()
58+
end
59+
end
60+
61+
@doc """
62+
Starts the discovery sync worker if not already running.
63+
Called during application startup.
64+
"""
65+
def ensure_started do
66+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
67+
68+
unless oban_config[:testing] do
69+
%{}
70+
|> __MODULE__.new()
71+
|> Oban.insert()
72+
end
73+
end
74+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
defmodule SentinelCp.Services.DnsResolver do
2+
@moduledoc """
3+
Behaviour for DNS SRV record resolution.
4+
5+
Implementations resolve SRV records for service discovery.
6+
"""
7+
8+
@callback resolve_srv(hostname :: String.t()) ::
9+
{:ok, [{priority :: integer, weight :: integer, port :: integer, host :: charlist}]}
10+
| {:error, term()}
11+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule SentinelCp.Services.DnsResolver.Inet do
2+
@moduledoc """
3+
DNS SRV resolver using Erlang's `:inet_res` module.
4+
"""
5+
6+
@behaviour SentinelCp.Services.DnsResolver
7+
8+
@impl true
9+
def resolve_srv(hostname) do
10+
records = :inet_res.lookup(to_charlist(hostname), :in, :srv)
11+
{:ok, records}
12+
rescue
13+
e -> {:error, Exception.message(e)}
14+
end
15+
end

0 commit comments

Comments
 (0)