defmodule ArcadiaCloud.Provisioning.Steps.PointDeploymentDns do @moduledoc """ Points a deployment's hostname at its droplet by upserting an A record. Reads the droplet's public IPv4 from context (`droplet_public_ip`, set by WaitDropletActive) — the IP isn't known at saga-start, so this can't be a generic UpsertDnsRecord with the data baked into inputs. Saga inputs: dns_domain — the zone (e.g. "sky-ai.com"); when absent the step is a no-op (deployment opted out of DNS) dns_record_name — subdomain; defaults handled by the assembler dns_record_ttl — optional, defaults 1800 Behaviour mirrors UpsertDnsRecord: create / no-op / update, with the prior record stashed for compensation. """ @behaviour ArcadiaCloud.Provisioning.Step alias ArcadiaCloud.DigitalOcean.Client alias ArcadiaCloud.Provisioning.SagaState @default_ttl 1800 @type_a "A" @impl true def name, do: "point_deployment_dns" @impl true def execute(state) do domain = SagaState.get_input(state, :dns_domain) rname = SagaState.get_input(state, :dns_record_name) ip = SagaState.get_output(state, :droplet_public_ip) ttl = SagaState.get_input(state, :dns_record_ttl) || @default_ttl cond do is_nil(domain) or domain == "" -> {:ok, SagaState.put_output(state, :dns_outcome, "skipped")} is_nil(rname) or rname == "" -> {:error, :missing_dns_record_name} is_nil(ip) -> {:error, :no_droplet_public_ip_in_context} true -> upsert(state, domain, rname, ip, ttl) end end @impl true def compensate(state) do domain = SagaState.get_input(state, :dns_domain) outcome = SagaState.get_output(state, :dns_outcome) record_id = SagaState.get_output(state, :dns_record_id) case outcome do "created" -> delete_record(domain, record_id) "updated" -> restore_record(state, domain, record_id) _ -> :ok end end # ---- execute helpers ------------------------------------------------------ defp upsert(state, domain, rname, ip, ttl) do case find_record(domain, rname) do {:ok, nil} -> create(state, domain, rname, ip, ttl) {:ok, existing} -> if normalize(existing["data"]) == normalize(ip) and existing["ttl"] == ttl do {:ok, record_outcome(state, existing["id"], "noop", nil)} else update(state, domain, existing, rname, ip, ttl) end {:error, reason} -> {:error, reason} end end defp create(state, domain, rname, ip, ttl) do case Client.create_domain_record(domain, %{type: @type_a, name: rname, data: ip, ttl: ttl}) do {:ok, %{"id" => id}} -> {:ok, record_outcome(state, id, "created", nil)} {:error, reason} -> {:error, reason} end end defp update(state, domain, existing, rname, ip, ttl) do prior = %{ "type" => existing["type"], "name" => existing["name"], "data" => existing["data"], "ttl" => existing["ttl"] } case Client.update_domain_record(domain, existing["id"], %{ type: @type_a, name: rname, data: ip, ttl: ttl }) do {:ok, %{"id" => id}} -> {:ok, record_outcome(state, id, "updated", prior)} {:error, reason} -> {:error, reason} end end defp record_outcome(state, record_id, outcome, prior) do state |> SagaState.put_output(:dns_record_id, record_id) |> SagaState.put_output(:dns_outcome, outcome) |> SagaState.put_output(:dns_prior, prior) end # ---- compensate helpers --------------------------------------------------- defp delete_record(domain, record_id) do case Client.delete_domain_record(domain, record_id) do {:ok, _} -> :ok {:error, {:http, 404, _}} -> :ok {:error, reason} -> {:error, reason} end end defp restore_record(state, domain, record_id) do case SagaState.get_output(state, :dns_prior) do nil -> :ok prior -> case Client.update_domain_record(domain, record_id, %{ type: prior["type"], name: prior["name"], data: prior["data"], ttl: prior["ttl"] }) do {:ok, _} -> :ok {:error, {:http, 404, _}} -> :ok {:error, reason} -> {:error, reason} end end end # ---- shared --------------------------------------------------------------- defp find_record(domain, rname) do case Client.list_domain_records(domain) do {:ok, records} -> {:ok, Enum.find(records, &(&1["type"] == @type_a and &1["name"] == rname))} {:error, reason} -> {:error, reason} end end defp normalize(nil), do: "" defp normalize(v) when is_binary(v), do: v |> String.trim() |> String.trim_trailing(".") defp normalize(v), do: to_string(v) end