Wire a full tenant deployment as one orchestrated, compensating saga: mark → create droplet → wait active → register in inventory → link to deployment → point DNS → activate. A failure anywhere rolls the whole thing back — droplet destroyed, DNS reverted, deployment moved to cancelled. - New lifecycle state `provisioning`; deployments created via the provision path enter here and only reach `active` once the saga's ActivateDeployment step runs. - Four new steps: MarkDeploymentProvisioning (owns the deployment's failure state), LinkDeploymentResource, PointDeploymentDns, ActivateDeployment. - Provisioning.provision_deployment/2 assembles + starts the saga. - DeploymentController: POST /deployments with provision:true creates in `provisioning` and kicks the saga (202); GET /deployments/:id now returns the provisioning saga + per-step progress. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
4.8 KiB
Elixir
162 lines
4.8 KiB
Elixir
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
|