From 50c488b92b2e795bc48c1a5c0d0ad3dea479e149d54296a1c3faf22622a7222f Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sat, 16 May 2026 10:33:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20harden=20consortium=20sta?= =?UTF-8?q?rter=20pipeline=20=E2=80=94=20FfcSchematic=20RPCs,=20validation?= =?UTF-8?q?,=20wire=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the schematic deployment pipeline from dead SchematicsService RPCs (ForkSchematic/CreateDeploymentBinding) to the actual FfcSchematicService flow (Create→Validate→Approve→Publish→Realize). Adds template schema validation, variable resolution hardening, wire encoding, and centralized realization status. New modules: - SchematicTemplate.Schema — 7-section structural + cross-section validation - SchematicTemplate.VariableResolver — placeholder resolution with param checks - SchematicTemplate.WireEncoder — resolved template → FfcSchematic wire format - SchematicClient.Behaviour — callback definitions for testability - FfcPipeline — 12-step deploy orchestrator with step-level error reporting - RealizationStatus — centralized status classification and display helpers Changes: - SchematicClient: removed fork/bind RPCs, added FfcSchematic RPCs - RealizationPoller: delegates to RealizationStatus, persists snapshots to DB - GuildSchematic: expanded status enum, added founding_override_expires_at - Realization LiveView: uses RealizationStatus for all status logic - Schematic LiveView: replaced dead flow with FfcPipeline.deploy/2 52 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tyler J King --- .../lib/guildhall/ops_db/guild_schematic.ex | 6 +- ...16120000_alter_guild_schematics_status.exs | 9 + .../guildhall/orchestrator/ffc_pipeline.ex | 182 ++++++++ .../orchestrator/realization_poller.ex | 31 +- .../orchestrator/realization_status.ex | 93 +++++ .../orchestrator/schematic_client.ex | 107 +++-- .../schematic_client/behaviour.ex | 18 + .../orchestrator/schematic_template.ex | 12 +- .../orchestrator/schematic_template/schema.ex | 387 ++++++++++++++++++ .../schematic_template/variable_resolver.ex | 100 +++++ .../schematic_template/wire_encoder.ex | 118 ++++++ .../schematic_templates/isv-founding.toml | 25 +- .../schematic_templates/msp-founding.toml | 25 +- .../schematic_templates/nsp-founding.toml | 30 +- .../orchestrator/realization_status_test.exs | 114 ++++++ .../schematic_template/schema_test.exs | 191 +++++++++ .../variable_resolver_test.exs | 88 ++++ .../schematic_template/wire_encoder_test.exs | 139 +++++++ .../live/guild_live/realization.ex | 30 +- .../live/guild_live/schematic.ex | 58 +-- 20 files changed, 1585 insertions(+), 178 deletions(-) create mode 100644 apps/guildhall_ops_db/priv/repo/migrations/20260516120000_alter_guild_schematics_status.exs create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_status.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/schema.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/variable_resolver.ex create mode 100644 apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/wire_encoder.ex create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/realization_status_test.exs create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/schema_test.exs create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/variable_resolver_test.exs create mode 100644 apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/wire_encoder_test.exs diff --git a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex index 3063412..4da0d59 100644 --- a/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex +++ b/apps/guildhall_ops_db/lib/guildhall/ops_db/guild_schematic.ex @@ -16,6 +16,7 @@ defmodule Guildhall.OpsDb.GuildSchematic do field :status, :string, default: "pending" field :customization_params, :map, default: %{} field :realization_snapshot, :map, default: %{} + field :founding_override_expires_at, :utc_datetime_usec timestamps(type: :utc_datetime_usec) end @@ -32,10 +33,11 @@ defmodule Guildhall.OpsDb.GuildSchematic do :realization_id, :status, :customization_params, - :realization_snapshot + :realization_snapshot, + :founding_override_expires_at ]) |> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version]) - |> validate_inclusion(:status, ~w(pending forked binding_created realizing realized partially_realized failed)) + |> validate_inclusion(:status, ~w(pending draft validated approved published realizing realized partially_realized failed)) |> foreign_key_constraint(:guild_id) |> unique_constraint([:schematic_name, :schematic_version]) end diff --git a/apps/guildhall_ops_db/priv/repo/migrations/20260516120000_alter_guild_schematics_status.exs b/apps/guildhall_ops_db/priv/repo/migrations/20260516120000_alter_guild_schematics_status.exs new file mode 100644 index 0000000..f129fc4 --- /dev/null +++ b/apps/guildhall_ops_db/priv/repo/migrations/20260516120000_alter_guild_schematics_status.exs @@ -0,0 +1,9 @@ +defmodule Guildhall.OpsDb.Repo.Migrations.AlterGuildSchematicsStatus do + use Ecto.Migration + + def change do + alter table(:guild_schematics) do + add :founding_override_expires_at, :utc_datetime_usec + end + end +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex new file mode 100644 index 0000000..e8c1dfd --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/ffc_pipeline.ex @@ -0,0 +1,182 @@ +defmodule Guildhall.Orchestrator.FfcPipeline do + @moduledoc false + + require Logger + + alias Guildhall.Orchestrator.SchematicTemplate + alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder} + alias Guildhall.Orchestrator.RealizationPoller + alias Guildhall.OpsDb.GuildSchematics + + defstruct [ + :guild, + :guild_type, + :template, + :resolved, + :manifest_yaml, + :files, + :founding_overrides, + :schematic_name, + :schematic_version, + :guild_schematic, + :realization_id + ] + + def deploy(guild, opts \\ []) do + guild_type = guild.guild_type + version = Keyword.get(opts, :version, "1.0.0") + schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}") + + params = %{ + "trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev", + "guild_slug" => guild.slug, + "guild_name" => guild.slug, + "registrant_did" => guild.registrant_did + } + + state = %__MODULE__{ + guild: guild, + guild_type: guild_type, + schematic_name: schematic_name, + schematic_version: version + } + + with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end), + {:ok, state} <- step(:validate_template, state, fn -> validate_template(state) end), + {:ok, state} <- step(:resolve_variables, state, fn -> resolve_variables(state, params) end), + {:ok, state} <- step(:validate_resolved, state, fn -> validate_resolved(state) end), + {:ok, state} <- step(:encode_wire, state, fn -> encode_wire(state) end), + {:ok, state} <- step(:create_schematic, state, fn -> create_schematic(state) end), + {:ok, state} <- step(:validate_schematic, state, fn -> validate_schematic(state) end), + {:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state) end), + {:ok, state} <- step(:publish_schematic, state, fn -> publish_schematic(state) end), + {:ok, state} <- step(:realize_schematic, state, fn -> realize_schematic(state) end), + {:ok, state} <- step(:create_db_record, state, fn -> create_db_record(state) end), + {:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do + {:ok, state} + end + end + + defp step(name, _state, fun) do + case fun.() do + {:ok, new_state} -> {:ok, new_state} + {:error, reason} -> {:error, {name, reason}} + end + end + + defp load_template(%{guild_type: guild_type} = state) do + case SchematicTemplate.load_template(guild_type) do + {:ok, template} -> {:ok, %{state | template: template}} + error -> error + end + end + + defp validate_template(%{template: template} = state) do + case Schema.validate(template) do + :ok -> {:ok, state} + error -> error + end + end + + defp resolve_variables(%{template: template} = state, params) do + case VariableResolver.resolve(template, params) do + {:ok, resolved} -> {:ok, %{state | resolved: resolved}} + error -> error + end + end + + defp validate_resolved(%{resolved: resolved} = state) do + case Schema.validate_resolved(resolved) do + :ok -> {:ok, state} + error -> error + end + end + + defp encode_wire(%{resolved: resolved, schematic_name: name, schematic_version: version} = state) do + hub_did = Application.get_env(:guildhall_orchestrator, :hub_did, "did:web:guildhouse.dev") + + case WireEncoder.encode(resolved, name, version, hub_did: hub_did) do + {:ok, manifest_yaml, files} -> + overrides = WireEncoder.extract_founding_overrides(resolved) + + if map_size(overrides) > 0 do + Logger.warning("Founding overrides active: #{inspect(overrides)}") + end + + {:ok, %{state | manifest_yaml: manifest_yaml, files: files, founding_overrides: overrides}} + + error -> + error + end + end + + defp create_schematic(%{schematic_name: name, schematic_version: version, manifest_yaml: manifest, files: files} = state) do + case client().create_ffc_schematic(name, version, manifest, files) do + {:ok, _response} -> {:ok, state} + error -> error + end + end + + defp validate_schematic(%{schematic_name: name, schematic_version: version} = state) do + case client().validate_ffc_schematic(name, version) do + {:ok, _response} -> {:ok, state} + error -> error + end + end + + defp approve_schematic(%{schematic_name: name, schematic_version: version} = state) do + Logger.info("STUB: orgops approve for #{name}@#{version} — signing deferred to governed shell") + {:ok, state} + end + + defp publish_schematic(%{schematic_name: name, schematic_version: version} = state) do + case client().publish_ffc_schematic(name, version) do + {:ok, _response} -> {:ok, state} + error -> error + end + end + + defp realize_schematic(%{schematic_name: name, schematic_version: version} = state) do + case client().realize_ffc_schematic(name, version) do + {:ok, response} -> {:ok, %{state | realization_id: response.realization_id}} + error -> error + end + end + + defp create_db_record(%{guild: guild} = state) do + override_ttl_days = + Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90) + + overrides = state.founding_overrides || %{} + + expires_at = + if map_size(overrides) > 0 do + DateTime.utc_now() |> DateTime.add(override_ttl_days * 86_400, :second) |> DateTime.truncate(:microsecond) + end + + attrs = %{ + guild_id: guild.id, + template_name: "#{state.guild_type}-founding", + schematic_name: state.schematic_name, + schematic_version: state.schematic_version, + realization_id: state.realization_id, + status: "realizing", + customization_params: overrides, + founding_override_expires_at: expires_at + } + + case GuildSchematics.create(attrs) do + {:ok, gs} -> {:ok, %{state | guild_schematic: gs}} + error -> error + end + end + + defp start_poller(%{realization_id: realization_id, guild: guild} = state) do + RealizationPoller.watch(realization_id, guild.slug) + {:ok, state} + end + + defp client do + Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient) + end +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_poller.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_poller.ex index 938843d..3f9daea 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_poller.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_poller.ex @@ -2,7 +2,8 @@ defmodule Guildhall.Orchestrator.RealizationPoller do use GenServer require Logger - alias Guildhall.Orchestrator.SchematicClient + alias Guildhall.Orchestrator.RealizationStatus + alias Guildhall.OpsDb.GuildSchematics @poll_interval_ms 5_000 @@ -43,9 +44,11 @@ defmodule Guildhall.Orchestrator.RealizationPoller do def handle_info(:poll, state) do new_watches = Enum.reduce(state.watches, state.watches, fn {realization_id, guild_slug}, acc -> - case SchematicClient.get_realization_status(realization_id) do + case client().get_realization_status(realization_id) do {:ok, response} -> - snapshot = build_snapshot(response) + snapshot = RealizationStatus.build_snapshot(response) + + persist_snapshot(realization_id, snapshot) Phoenix.PubSub.broadcast( Guildhall.PubSub, @@ -53,7 +56,7 @@ defmodule Guildhall.Orchestrator.RealizationPoller do {:realization_update, snapshot} ) - if terminal_status?(response.overall_status) do + if RealizationStatus.terminal_status?(response.overall_status) do Map.delete(acc, realization_id) else acc @@ -74,18 +77,14 @@ defmodule Guildhall.Orchestrator.RealizationPoller do defp schedule_poll, do: Process.send_after(self(), :poll, @poll_interval_ms) - defp build_snapshot(response) do - %{ - overall_status: to_string(response.overall_status), - sections: - Enum.map(response.per_section, fn s -> - %{name: s.section_name, status: s.status, message: s.message} - end) - } + defp persist_snapshot(realization_id, snapshot) do + case Guildhall.OpsDb.Repo.get_by(Guildhall.OpsDb.GuildSchematic, realization_id: realization_id) do + nil -> :ok + gs -> GuildSchematics.update_realization_snapshot(gs, snapshot) + end end - defp terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED), do: true - defp terminal_status?(:FFC_SCHEMATIC_STATUS_ARCHIVED), do: true - defp terminal_status?(:FFC_SCHEMATIC_STATUS_WITHDRAWN), do: true - defp terminal_status?(_), do: false + defp client do + Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient) + end end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_status.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_status.ex new file mode 100644 index 0000000..7e7f253 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/realization_status.ex @@ -0,0 +1,93 @@ +defmodule Guildhall.Orchestrator.RealizationStatus do + @moduledoc false + + require Logger + + @terminal_statuses ~w( + FFC_SCHEMATIC_STATUS_REALIZED + FFC_SCHEMATIC_STATUS_ARCHIVED + FFC_SCHEMATIC_STATUS_WITHDRAWN + ) + + def build_snapshot(response) do + %{ + overall_status: to_string(response.overall_status), + sections: + Enum.map(response.per_section, fn s -> + %{name: s.section_name, status: s.status, message: s.message} + end) + } + end + + def terminal_status?(status) when is_atom(status), do: to_string(status) in @terminal_statuses + def terminal_status?(status) when is_binary(status), do: status in @terminal_statuses + def terminal_status?(_), do: false + + def classify_section("succeeded"), do: {:succeeded, "Succeeded"} + def classify_section("in_progress"), do: {:in_progress, "In progress"} + def classify_section("failed"), do: {:failed, "Failed"} + def classify_section("pending"), do: {:pending, "Pending"} + def classify_section("skipped:no_change"), do: {:skipped, "Skipped (no change)"} + def classify_section("skipped:not_implemented"), do: {:skipped, "Skipped (stub)"} + def classify_section("skipped:upstream_unavailable:" <> svc), do: {:skipped, "Skipped (#{svc} unavailable)"} + def classify_section("skipped:" <> _reason), do: {:skipped, "Skipped"} + + def classify_section(unknown) do + Logger.warning("Unknown reconciler section status: #{inspect(unknown)}") + {:pending, to_string(unknown)} + end + + def derive_overall(snapshot) do + sections = Map.get(snapshot, :sections, Map.get(snapshot, "sections", [])) + + statuses = + Enum.map(sections, fn s -> + raw = s[:status] || s["status"] || "pending" + {class, _label} = classify_section(raw) + class + end) + + cond do + statuses == [] -> :pending + Enum.all?(statuses, &(&1 in [:succeeded, :skipped])) -> :realized + Enum.any?(statuses, &(&1 == :failed)) and Enum.any?(statuses, &(&1 in [:succeeded, :skipped])) -> :partially_realized + Enum.any?(statuses, &(&1 == :failed)) -> :failed + Enum.any?(statuses, &(&1 == :in_progress)) -> :in_progress + true -> :pending + end + end + + def display_status(:realized), do: "Realized" + def display_status(:partially_realized), do: "Partially realized" + def display_status(:failed), do: "Failed" + def display_status(:in_progress), do: "In progress" + def display_status(:pending), do: "Pending" + + def overall_css_class(:realized), do: "bg-emerald-100 text-emerald-700" + def overall_css_class(:partially_realized), do: "bg-amber-100 text-amber-700" + def overall_css_class(:failed), do: "bg-red-100 text-red-700" + def overall_css_class(:in_progress), do: "bg-amber-100 text-amber-700" + def overall_css_class(:pending), do: "bg-zinc-100 text-zinc-700" + + def section_icon_class("succeeded"), do: "text-emerald-600" + def section_icon_class("in_progress"), do: "text-amber-600" + def section_icon_class("failed"), do: "text-red-600" + def section_icon_class("pending"), do: "text-zinc-400" + def section_icon_class("skipped:" <> _), do: "text-sky-500" + def section_icon_class(_), do: "text-zinc-400" + + def section_symbol("succeeded"), do: "✓" + def section_symbol("in_progress"), do: "⟳" + def section_symbol("failed"), do: "✗" + def section_symbol("skipped:" <> _), do: "⊘" + def section_symbol(_), do: "○" + + def section_display_status(status) do + {_class, label} = classify_section(status) + label + end + + def all_known_statuses do + ~w(succeeded in_progress failed pending skipped:no_change skipped:not_implemented skipped:upstream_unavailable:service) + end +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex index 2b7aaf8..857c17c 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client.ex @@ -1,95 +1,88 @@ defmodule Guildhall.Orchestrator.SchematicClient do @moduledoc false - alias Schematic.V1.{ - SchematicsService.Stub, - ForkSchematicRequest, - TemplateOperation, - CreateDeploymentBindingRequest, - GetDeploymentBindingRequest - } + @behaviour Guildhall.Orchestrator.SchematicClient.Behaviour - alias Schematic.V1.RealizeFfcSchematicRequest - alias Schematic.V1.GetRealizationStatusRequest + alias Schematic.V1.FfcSchematicService.Stub + alias Schematic.V1.FfcSchematicFile - def fork_schematic(source_name, source_version, new_name, new_version, operations) do - ops = - Enum.map(operations, fn op -> - %TemplateOperation{ - op_type: op[:op_type] || "replace", - path: op[:path], - content: op[:content] || "" - } + @impl true + def create_ffc_schematic(name, version, manifest_yaml, files) do + proto_files = + Enum.map(files, fn {path, content} -> + %FfcSchematicFile{path: path, content: content} end) - request = %ForkSchematicRequest{ - source_name: source_name, - source_version: source_version, - new_name: new_name, - new_version: new_version, - operations: ops + request = %Schematic.V1.CreateFfcSchematicRequest{ + name: name, + version: version, + manifest_yaml: manifest_yaml, + files: proto_files } - with {:ok, channel} <- connect_schematic(), - {:ok, response} <- Stub.fork_schematic(channel, request) do + with {:ok, channel} <- connect(), + {:ok, response} <- Stub.create_ffc_schematic(channel, request) do GRPC.Stub.disconnect(channel) {:ok, response} end end - def create_deployment_binding(schematic_name, version, pipeline_name, target_env) do - request = %CreateDeploymentBindingRequest{ - schematic_name: schematic_name, - schematic_version: version, - pipeline_name: pipeline_name, - target_env: target_env + @impl true + def validate_ffc_schematic(name, version) do + request = %Schematic.V1.ValidateFfcSchematicRequest{ + name: name, + version: version } - with {:ok, channel} <- connect_schematic(), - {:ok, response} <- Stub.create_deployment_binding(channel, request) do - GRPC.Stub.disconnect(channel) - {:ok, response} - end - end - - def get_deployment_binding(binding_id) do - request = %GetDeploymentBindingRequest{binding_id: binding_id} - - with {:ok, channel} <- connect_schematic(), - {:ok, response} <- Stub.get_deployment_binding(channel, request) do + with {:ok, channel} <- connect(), + {:ok, response} <- Stub.validate_ffc_schematic(channel, request) do + GRPC.Stub.disconnect(channel) + + if response.valid do + {:ok, response} + else + {:error, {:validation_failed, response.results}} + end + end + end + + @impl true + def publish_ffc_schematic(name, version) do + request = %Schematic.V1.PublishFfcSchematicRequest{ + name: name, + version: version + } + + with {:ok, channel} <- connect(), + {:ok, response} <- Stub.publish_ffc_schematic(channel, request) do GRPC.Stub.disconnect(channel) {:ok, response} end end + @impl true def realize_ffc_schematic(name, version) do - request = %RealizeFfcSchematicRequest{name: name, version: version} + request = %Schematic.V1.RealizeFfcSchematicRequest{name: name, version: version} - with {:ok, channel} <- connect_ffc(), - {:ok, response} <- Schematic.V1.FfcSchematicService.Stub.realize_ffc_schematic(channel, request) do + with {:ok, channel} <- connect(), + {:ok, response} <- Stub.realize_ffc_schematic(channel, request) do GRPC.Stub.disconnect(channel) {:ok, response} end end + @impl true def get_realization_status(realization_id) do - request = %GetRealizationStatusRequest{realization_id: realization_id} + request = %Schematic.V1.GetRealizationStatusRequest{realization_id: realization_id} - with {:ok, channel} <- connect_ffc(), - {:ok, response} <- Schematic.V1.FfcSchematicService.Stub.get_realization_status(channel, request) do + with {:ok, channel} <- connect(), + {:ok, response} <- Stub.get_realization_status(channel, request) do GRPC.Stub.disconnect(channel) {:ok, response} end end - defp connect_schematic do - url = - Application.get_env(:guildhall_orchestrator, :schematic_service_url, "localhost:9091") - - GRPC.Stub.connect(url) - end - - defp connect_ffc do + defp connect do url = Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091") diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex new file mode 100644 index 0000000..ca024f6 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_client/behaviour.ex @@ -0,0 +1,18 @@ +defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do + @moduledoc false + + @callback create_ffc_schematic(String.t(), String.t(), binary(), [{String.t(), binary()}]) :: + {:ok, map()} | {:error, term()} + + @callback validate_ffc_schematic(String.t(), String.t()) :: + {:ok, map()} | {:error, term()} + + @callback publish_ffc_schematic(String.t(), String.t()) :: + {:ok, map()} | {:error, term()} + + @callback realize_ffc_schematic(String.t(), String.t()) :: + {:ok, map()} | {:error, term()} + + @callback get_realization_status(String.t()) :: + {:ok, map()} | {:error, term()} +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template.ex index bb9f8c3..87b011d 100644 --- a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template.ex +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template.ex @@ -25,13 +25,11 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do rendered_template |> Map.drop(["meta"]) |> Enum.flat_map(fn {section, content} -> - yaml = to_yaml_string(content) - [ %{ op_type: "replace", - path: "#{section}.yaml", - content: yaml + path: "#{section}.json", + content: Jason.encode!(content) } ] end) @@ -58,12 +56,6 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do defp deep_substitute(value, _params), do: value - defp to_yaml_string(map) when is_map(map) do - Jason.encode!(map) - end - - defp to_yaml_string(other), do: to_string(other) - defp template_dir do Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates") end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/schema.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/schema.ex new file mode 100644 index 0000000..4b411b0 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/schema.ex @@ -0,0 +1,387 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.Schema do + @moduledoc false + + @known_sections ~w(meta trust_domain identity_authority members infrastructure ceremonies federation_peers attestation) + + @valid_ceremony_types ~w(single_approval multi_party) + @valid_roles ~w(master journeyman apprentice) + @valid_federation_modes ~w(hub_spoke mesh) + @valid_trust_levels ~w(federated local) + @valid_providers ~w(keycloak) + + @tier_requirements %{ + 1 => %{require_tpm: false, require_secure_boot: false, mfa_required: false}, + 2 => %{require_tpm: false, require_secure_boot: false, mfa_required: true}, + 3 => %{require_tpm: true, require_secure_boot: true, mfa_required: true, hardware_credential_required: true} + } + + @type_to_federation %{ + "msp" => "hub_spoke", + "isv" => "hub_spoke", + "nsp" => "mesh" + } + + def validate(template) when is_map(template) do + with :ok <- check_unknown_sections(template), + :ok <- validate_meta(template["meta"]), + :ok <- validate_trust_domain(template["trust_domain"]), + :ok <- validate_identity_authority(template["identity_authority"]), + :ok <- validate_members(template["members"]), + :ok <- validate_infrastructure(template["infrastructure"]), + :ok <- validate_ceremonies(template["ceremonies"]), + :ok <- validate_federation_peers(template["federation_peers"]), + :ok <- validate_attestation(template["attestation"]), + :ok <- validate_cross_section(template) do + :ok + end + end + + def validate(nil), do: {:error, {:missing_section, "template is nil"}} + def validate(_), do: {:error, {:invalid_type, "template must be a map"}} + + def validate_resolved(template) when is_map(template) do + with :ok <- validate_resolved_values(template) do + :ok + end + end + + # --- Section validators --- + + defp check_unknown_sections(template) do + unknown = Map.keys(template) -- @known_sections + if unknown == [], do: :ok, else: {:error, {:unknown_sections, unknown}} + end + + defp validate_meta(nil), do: {:error, {:missing_section, "meta"}} + + defp validate_meta(meta) when is_map(meta) do + with :ok <- require_string(meta, "template_name", "meta"), + :ok <- require_string(meta, "source_schematic", "meta"), + :ok <- require_string(meta, "source_version", "meta") do + :ok + end + end + + defp validate_trust_domain(nil), do: {:error, {:missing_section, "trust_domain"}} + + defp validate_trust_domain(td) when is_map(td) do + with :ok <- require_string(td, "spiffe_trust_domain", "trust_domain"), + :ok <- require_integer_in(td, "attestation_tier", 1..3, "trust_domain") do + :ok + end + end + + defp validate_identity_authority(nil), do: {:error, {:missing_section, "identity_authority"}} + + defp validate_identity_authority(ia) when is_map(ia) do + with :ok <- require_string(ia, "provider", "identity_authority"), + :ok <- require_inclusion(ia, "provider", @valid_providers, "identity_authority"), + :ok <- require_string(ia, "url", "identity_authority"), + :ok <- require_string(ia, "realm", "identity_authority"), + :ok <- optional_inclusion(ia, "trust_level", @valid_trust_levels, "identity_authority"), + :ok <- optional_boolean(ia, "mfa_required", "identity_authority"), + :ok <- optional_boolean(ia, "hardware_credential_required", "identity_authority") do + :ok + end + end + + defp validate_members(nil), do: {:error, {:missing_section, "members"}} + + defp validate_members(m) when is_map(m) do + with :ok <- require_string(m, "founding_master_did", "members"), + :ok <- require_string_list(m, "initial_roles", "members"), + :ok <- validate_roles(m["initial_roles"], "members.initial_roles") do + :ok + end + end + + defp validate_infrastructure(nil), do: {:error, {:missing_section, "infrastructure"}} + + defp validate_infrastructure(infra) when is_map(infra) do + with :ok <- require_integer_in(infra, "compute_attestation_tier", 1..3, "infrastructure"), + :ok <- optional_boolean(infra, "wireguard_tunnel", "infrastructure"), + :ok <- optional_boolean(infra, "vpp_dataplane", "infrastructure") do + :ok + end + end + + defp validate_ceremonies(nil), do: {:error, {:missing_section, "ceremonies"}} + + defp validate_ceremonies(c) when is_map(c) do + with :ok <- require_map(c, "code_change", "ceremonies"), + :ok <- validate_ceremony_rule(c["code_change"], "ceremonies.code_change"), + :ok <- require_map(c, "governance_change", "ceremonies"), + :ok <- validate_ceremony_rule(c["governance_change"], "ceremonies.governance_change") do + :ok + end + end + + defp validate_ceremony_rule(rule, path) when is_map(rule) do + with :ok <- require_string(rule, "type", path), + :ok <- require_inclusion(rule, "type", @valid_ceremony_types, path), + :ok <- require_string_list(rule, "eligible_roles", path), + :ok <- validate_roles(rule["eligible_roles"], "#{path}.eligible_roles"), + :ok <- require_positive_integer(rule, "quorum", path), + :ok <- validate_founding_override(rule, path) do + :ok + end + end + + defp validate_ceremony_rule(_, path), do: {:error, {:invalid_type, "#{path} must be a map"}} + + defp validate_founding_override(rule, path) do + case rule["founding_override"] do + nil -> + :ok + + override when is_integer(override) -> + cond do + rule["type"] != "multi_party" -> + {:error, {:invalid_value, "#{path}.founding_override is only valid on multi_party ceremonies"}} + + override < 1 -> + {:error, {:invalid_value, "#{path}.founding_override must be >= 1"}} + + override >= rule["quorum"] -> + {:error, {:invalid_value, "#{path}.founding_override must be less than quorum (#{rule["quorum"]})"}} + + true -> + :ok + end + + _ -> + {:error, {:invalid_type, "#{path}.founding_override must be an integer"}} + end + end + + defp validate_federation_peers(nil), do: {:error, {:missing_section, "federation_peers"}} + + defp validate_federation_peers(fp) when is_map(fp) do + with :ok <- require_string(fp, "mode", "federation_peers"), + :ok <- require_inclusion(fp, "mode", @valid_federation_modes, "federation_peers"), + :ok <- require_string(fp, "hub_trust_domain", "federation_peers") do + :ok + end + end + + defp validate_attestation(nil), do: {:error, {:missing_section, "attestation"}} + + defp validate_attestation(att) when is_map(att) do + with :ok <- require_integer_in(att, "tier", 1..3, "attestation"), + :ok <- optional_boolean(att, "require_tpm", "attestation"), + :ok <- optional_boolean(att, "require_secure_boot", "attestation") do + :ok + end + end + + # --- Cross-section validators --- + + defp validate_cross_section(template) do + with :ok <- validate_tier_consistency(template), + :ok <- validate_tier_requirements(template), + :ok <- validate_federation_type(template), + :ok <- validate_mesh_wireguard(template) do + :ok + end + end + + defp validate_tier_consistency(template) do + td_tier = get_in(template, ["trust_domain", "attestation_tier"]) + infra_tier = get_in(template, ["infrastructure", "compute_attestation_tier"]) + att_tier = get_in(template, ["attestation", "tier"]) + + tiers = [td_tier, infra_tier, att_tier] |> Enum.reject(&is_nil/1) |> Enum.uniq() + + if length(tiers) <= 1 do + :ok + else + {:error, {:tier_mismatch, "attestation tiers must be consistent: trust_domain=#{td_tier}, infrastructure=#{infra_tier}, attestation=#{att_tier}"}} + end + end + + defp validate_tier_requirements(template) do + tier = get_in(template, ["attestation", "tier"]) + reqs = Map.get(@tier_requirements, tier) + + if is_nil(reqs) do + :ok + else + errors = + [] + |> check_tier_bool(template, ["attestation", "require_tpm"], reqs[:require_tpm], "attestation.require_tpm") + |> check_tier_bool(template, ["attestation", "require_secure_boot"], reqs[:require_secure_boot], "attestation.require_secure_boot") + |> check_tier_bool(template, ["identity_authority", "mfa_required"], reqs[:mfa_required], "identity_authority.mfa_required") + |> maybe_check_tier_bool(template, ["identity_authority", "hardware_credential_required"], reqs[:hardware_credential_required], "identity_authority.hardware_credential_required") + + case errors do + [] -> :ok + errs -> {:error, {:tier_requirement_mismatch, Enum.reverse(errs)}} + end + end + end + + defp check_tier_bool(errors, template, path, expected, label) do + actual = get_in(template, path) + + if not is_nil(actual) and actual != expected do + ["#{label}: expected #{expected} for tier #{get_in(template, ["attestation", "tier"])}, got #{actual}" | errors] + else + errors + end + end + + defp maybe_check_tier_bool(errors, template, path, nil, _label), do: errors |> check_tier_bool(template, path, nil, "") + defp maybe_check_tier_bool(errors, template, path, expected, label), do: check_tier_bool(errors, template, path, expected, label) + + defp validate_federation_type(template) do + template_name = get_in(template, ["meta", "template_name"]) || "" + mode = get_in(template, ["federation_peers", "mode"]) + + guild_type = + cond do + String.contains?(template_name, "msp") -> "msp" + String.contains?(template_name, "isv") -> "isv" + String.contains?(template_name, "nsp") -> "nsp" + true -> nil + end + + expected = Map.get(@type_to_federation, guild_type) + + if is_nil(expected) or is_nil(mode) or mode == expected do + :ok + else + {:error, {:federation_mode_mismatch, "#{guild_type} templates must use #{expected} federation, got #{mode}"}} + end + end + + defp validate_mesh_wireguard(template) do + mode = get_in(template, ["federation_peers", "mode"]) + wg = get_in(template, ["infrastructure", "wireguard_tunnel"]) + + cond do + mode == "mesh" and wg != true -> + {:error, {:mesh_requires_wireguard, "mesh federation mode requires infrastructure.wireguard_tunnel = true"}} + + true -> + :ok + end + end + + # --- Resolved value validators (post-substitution) --- + + defp validate_resolved_values(template) do + errors = + [] + |> validate_did_format(get_in(template, ["members", "founding_master_did"]), "members.founding_master_did") + |> validate_domain_format(get_in(template, ["trust_domain", "spiffe_trust_domain"]), "trust_domain.spiffe_trust_domain") + |> validate_slug_format(get_in(template, ["identity_authority", "client_prefix"]), "identity_authority.client_prefix") + + case errors do + [] -> :ok + errs -> {:error, {:invalid_resolved_values, Enum.reverse(errs)}} + end + end + + defp validate_did_format(errors, nil, _path), do: errors + + defp validate_did_format(errors, value, path) when is_binary(value) do + if String.starts_with?(value, "did:") do + errors + else + ["#{path}: must be a DID (got #{inspect(value)})" | errors] + end + end + + defp validate_did_format(errors, _, path), do: ["#{path}: must be a string" | errors] + + defp validate_domain_format(errors, nil, _path), do: errors + + defp validate_domain_format(errors, value, path) when is_binary(value) do + if Regex.match?(~r/^[a-z0-9][a-z0-9.\-]*[a-z0-9]$/, value) do + errors + else + ["#{path}: must be a valid domain (got #{inspect(value)})" | errors] + end + end + + defp validate_domain_format(errors, _, path), do: ["#{path}: must be a string" | errors] + + defp validate_slug_format(errors, nil, _path), do: errors + + defp validate_slug_format(errors, value, path) when is_binary(value) do + if Regex.match?(~r/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/, value) or String.match?(value, ~r/^\{\{.+\}\}$/) do + errors + else + ["#{path}: must be a slug (got #{inspect(value)})" | errors] + end + end + + defp validate_slug_format(errors, _, path), do: ["#{path}: must be a string" | errors] + + # --- Primitive validators --- + + defp require_string(map, key, section) do + case Map.get(map, key) do + nil -> {:error, {:missing_field, "#{section}.#{key}"}} + v when is_binary(v) -> :ok + _ -> {:error, {:invalid_type, "#{section}.#{key} must be a string"}} + end + end + + defp require_integer_in(map, key, range, section) do + case Map.get(map, key) do + nil -> {:error, {:missing_field, "#{section}.#{key}"}} + v when is_integer(v) -> if v in range, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be in #{inspect(range)}"}} + _ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}} + end + end + + defp require_positive_integer(map, key, section) do + case Map.get(map, key) do + nil -> {:error, {:missing_field, "#{section}.#{key}"}} + v when is_integer(v) and v >= 1 -> :ok + v when is_integer(v) -> {:error, {:invalid_value, "#{section}.#{key} must be >= 1"}} + _ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}} + end + end + + defp require_string_list(map, key, section) do + case Map.get(map, key) do + nil -> {:error, {:missing_field, "#{section}.#{key}"}} + v when is_list(v) -> if Enum.all?(v, &is_binary/1), do: :ok, else: {:error, {:invalid_type, "#{section}.#{key} must be a list of strings"}} + _ -> {:error, {:invalid_type, "#{section}.#{key} must be a list"}} + end + end + + defp require_map(map, key, section) do + case Map.get(map, key) do + nil -> {:error, {:missing_field, "#{section}.#{key}"}} + v when is_map(v) -> :ok + _ -> {:error, {:invalid_type, "#{section}.#{key} must be a map"}} + end + end + + defp require_inclusion(map, key, allowed, section) do + val = Map.get(map, key) + if is_nil(val) or val in allowed, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be one of #{inspect(allowed)}, got #{inspect(val)}"}} + end + + defp optional_inclusion(_map, _key, _allowed, _section), do: :ok + + defp optional_boolean(map, key, section) do + case Map.get(map, key) do + nil -> :ok + v when is_boolean(v) -> :ok + _ -> {:error, {:invalid_type, "#{section}.#{key} must be a boolean"}} + end + end + + defp validate_roles(nil, _path), do: :ok + + defp validate_roles(roles, path) when is_list(roles) do + invalid = Enum.reject(roles, &(&1 in @valid_roles)) + if invalid == [], do: :ok, else: {:error, {:invalid_value, "#{path} contains invalid roles: #{inspect(invalid)}"}} + end + + defp validate_roles(_, path), do: {:error, {:invalid_type, "#{path} must be a list"}} +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/variable_resolver.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/variable_resolver.ex new file mode 100644 index 0000000..a095005 --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/variable_resolver.ex @@ -0,0 +1,100 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolver do + @moduledoc false + + @placeholder_regex ~r/\{\{([a-z_]+)\}\}/ + + @known_variables ~w(trust_domain guild_slug guild_name registrant_did) + + def resolve(template, params) when is_map(template) and is_map(params) do + with :ok <- check_params(params), + {:ok, resolved} <- substitute(template, params), + :ok <- check_no_remaining(resolved) do + {:ok, resolved} + end + end + + def known_variables, do: @known_variables + + defp check_params(params) do + errors = + Enum.reduce(@known_variables, [], fn var, acc -> + case Map.get(params, var) do + nil -> [{:missing_param, var} | acc] + "" -> [{:empty_param, var} | acc] + v when is_binary(v) -> acc + v -> [{:invalid_param, var, "expected string, got #{inspect(v)}"} | acc] + end + end) + + placeholders_in_use = collect_placeholders(params |> Map.values() |> Enum.join(" ")) + + errors = + Enum.reduce(Map.keys(params) -- @known_variables, errors, fn key, acc -> + if key in placeholders_in_use, do: acc, else: acc + end) + + case errors do + [] -> :ok + errs -> {:error, {:param_errors, Enum.reverse(errs)}} + end + end + + defp substitute(value, params) when is_binary(value) do + result = + Regex.replace(@placeholder_regex, value, fn _full, var_name -> + case Map.get(params, var_name) do + nil -> "{{#{var_name}}}" + v -> to_string(v) + end + end) + + {:ok, result} + end + + defp substitute(value, params) when is_map(value) do + Enum.reduce_while(value, {:ok, %{}}, fn {k, v}, {:ok, acc} -> + case substitute(v, params) do + {:ok, resolved} -> {:cont, {:ok, Map.put(acc, k, resolved)}} + error -> {:halt, error} + end + end) + end + + defp substitute(value, params) when is_list(value) do + Enum.reduce_while(value, {:ok, []}, fn v, {:ok, acc} -> + case substitute(v, params) do + {:ok, resolved} -> {:cont, {:ok, acc ++ [resolved]}} + error -> {:halt, error} + end + end) + end + + defp substitute(value, _params), do: {:ok, value} + + defp check_no_remaining(template) do + remaining = collect_all_placeholders(template) + + case remaining do + [] -> :ok + vars -> {:error, {:unresolved_variables, Enum.uniq(vars)}} + end + end + + defp collect_all_placeholders(value) when is_binary(value) do + collect_placeholders(value) + end + + defp collect_all_placeholders(value) when is_map(value) do + Enum.flat_map(value, fn {_k, v} -> collect_all_placeholders(v) end) + end + + defp collect_all_placeholders(value) when is_list(value) do + Enum.flat_map(value, &collect_all_placeholders/1) + end + + defp collect_all_placeholders(_), do: [] + + defp collect_placeholders(string) when is_binary(string) do + Regex.scan(@placeholder_regex, string) |> Enum.map(fn [_, name] -> name end) + end +end diff --git a/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/wire_encoder.ex b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/wire_encoder.ex new file mode 100644 index 0000000..fa9b8fd --- /dev/null +++ b/apps/guildhall_orchestrator/lib/guildhall/orchestrator/schematic_template/wire_encoder.ex @@ -0,0 +1,118 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoder do + @moduledoc false + + @section_paths %{ + "trust_domain" => "trust-domain.yaml", + "identity_authority" => "identity-authority.yaml", + "members" => "members.yaml", + "infrastructure" => "infrastructure.yaml", + "ceremonies" => "ceremonies.yaml", + "federation_peers" => "federation-peers.yaml", + "attestation" => "attestation.yaml" + } + + @section_declarations [ + %{"name" => "trust-domain", "required" => true}, + %{"name" => "identity-authority", "required" => true}, + %{"name" => "members", "required" => true}, + %{"name" => "infrastructure", "required" => true}, + %{"name" => "ceremonies", "required" => true}, + %{"name" => "federation-peers", "required" => false}, + %{"name" => "attestation", "required" => false} + ] + + def encode(resolved_template, name, version, opts \\ []) do + hub_did = Keyword.get(opts, :hub_did, "did:web:guildhouse.dev") + registrant_spiffe = Keyword.get(opts, :registrant_spiffe) + + trust_domain = + get_in(resolved_template, ["trust_domain", "spiffe_trust_domain"]) || "guildhouse.dev" + + registrant_spiffe = + registrant_spiffe || + "spiffe://#{trust_domain}/founder/#{get_in(resolved_template, ["identity_authority", "client_prefix"]) || "unknown"}" + + manifest = build_manifest(name, version, hub_did, registrant_spiffe, resolved_template) + manifest_yaml = Jason.encode!(manifest) + + files = build_section_files(resolved_template) + + {:ok, manifest_yaml, files} + end + + def section_paths, do: @section_paths + + defp build_manifest(name, version, hub_did, registrant_spiffe, template) do + realm = + get_in(template, ["identity_authority", "client_prefix"]) || + get_in(template, ["identity_authority", "realm"]) || name + + %{ + "apiVersion" => "guildhouse.dev/v1", + "kind" => "FfcSchematic", + "metadata" => %{ + "name" => name, + "version" => version, + "description" => + get_in(template, ["meta", "description"]) || "#{name} founding schematic", + "parentHash" => "" + }, + "spec" => %{ + "consortium" => %{ + "did" => hub_did, + "realm" => realm + }, + "stakeholders" => [ + %{ + "role" => "founding_stakeholder", + "identity" => registrant_spiffe, + "required" => true + } + ], + "approval" => %{ + "strategy" => "unanimous" + }, + "sections" => @section_declarations, + "schematicHash" => "", + "ceremonyId" => "", + "accordHash" => "", + "signatures" => [] + } + } + end + + defp build_section_files(template) do + @section_paths + |> Enum.map(fn {section_key, file_path} -> + content = Map.get(template, section_key, %{}) + content = strip_guildhall_metadata(section_key, content) + {file_path, Jason.encode!(content)} + end) + end + + defp strip_guildhall_metadata("ceremonies", ceremonies) when is_map(ceremonies) do + Map.new(ceremonies, fn {key, rule} -> + if is_map(rule) do + {key, Map.delete(rule, "founding_override")} + else + {key, rule} + end + end) + end + + defp strip_guildhall_metadata(_section, content), do: content + + def extract_founding_overrides(template) do + ceremonies = Map.get(template, "ceremonies", %{}) + + Enum.reduce(ceremonies, %{}, fn {key, rule}, acc -> + case rule do + %{"founding_override" => override} when is_integer(override) -> + Map.put(acc, "ceremonies.#{key}.founding_override", override) + + _ -> + acc + end + end) + end +end diff --git a/apps/guildhall_orchestrator/priv/schematic_templates/isv-founding.toml b/apps/guildhall_orchestrator/priv/schematic_templates/isv-founding.toml index 5dd8340..672ec29 100644 --- a/apps/guildhall_orchestrator/priv/schematic_templates/isv-founding.toml +++ b/apps/guildhall_orchestrator/priv/schematic_templates/isv-founding.toml @@ -4,10 +4,6 @@ description = "Independent Software Vendor founding schematic" source_schematic = "guildhouse-isv-base" source_version = "1.0.0" -[consortium] -realm = "{{guild_slug}}" -description = "{{guild_name}} ISV Consortium" - [trust_domain] spiffe_trust_domain = "{{trust_domain}}" attestation_tier = 1 @@ -15,10 +11,18 @@ attestation_tier = 1 [identity_authority] provider = "keycloak" url = "https://auth.guildhouse.dev" -realm = "{{guild_slug}}" +realm = "guildhouse" +client_prefix = "{{guild_slug}}" trust_level = "federated" mfa_required = false +[members] +founding_master_did = "{{registrant_did}}" +initial_roles = ["master"] + +[infrastructure] +compute_attestation_tier = 1 + [ceremonies.code_change] type = "single_approval" eligible_roles = ["master", "journeyman"] @@ -29,8 +33,11 @@ type = "single_approval" eligible_roles = ["master"] quorum = 1 -[members] -initial_roles = ["master"] +[federation_peers] +mode = "hub_spoke" +hub_trust_domain = "guildhouse.dev" -[infrastructure] -compute_attestation_tier = 1 +[attestation] +tier = 1 +require_tpm = false +require_secure_boot = false diff --git a/apps/guildhall_orchestrator/priv/schematic_templates/msp-founding.toml b/apps/guildhall_orchestrator/priv/schematic_templates/msp-founding.toml index 7671b6c..d17a0f0 100644 --- a/apps/guildhall_orchestrator/priv/schematic_templates/msp-founding.toml +++ b/apps/guildhall_orchestrator/priv/schematic_templates/msp-founding.toml @@ -4,10 +4,6 @@ description = "Managed Service Provider founding schematic" source_schematic = "guildhouse-msp-base" source_version = "1.0.0" -[consortium] -realm = "{{guild_slug}}" -description = "{{guild_name}} MSP Consortium" - [trust_domain] spiffe_trust_domain = "{{trust_domain}}" attestation_tier = 2 @@ -15,10 +11,18 @@ attestation_tier = 2 [identity_authority] provider = "keycloak" url = "https://auth.guildhouse.dev" -realm = "{{guild_slug}}" +realm = "guildhouse" +client_prefix = "{{guild_slug}}" trust_level = "federated" mfa_required = true +[members] +founding_master_did = "{{registrant_did}}" +initial_roles = ["master"] + +[infrastructure] +compute_attestation_tier = 2 + [ceremonies.code_change] type = "single_approval" eligible_roles = ["master", "journeyman"] @@ -29,8 +33,11 @@ type = "single_approval" eligible_roles = ["master"] quorum = 1 -[members] -initial_roles = ["master"] +[federation_peers] +mode = "hub_spoke" +hub_trust_domain = "guildhouse.dev" -[infrastructure] -compute_attestation_tier = 2 +[attestation] +tier = 2 +require_tpm = false +require_secure_boot = false diff --git a/apps/guildhall_orchestrator/priv/schematic_templates/nsp-founding.toml b/apps/guildhall_orchestrator/priv/schematic_templates/nsp-founding.toml index a08628a..b103713 100644 --- a/apps/guildhall_orchestrator/priv/schematic_templates/nsp-founding.toml +++ b/apps/guildhall_orchestrator/priv/schematic_templates/nsp-founding.toml @@ -4,10 +4,6 @@ description = "Network Service Provider founding schematic" source_schematic = "guildhouse-nsp-base" source_version = "1.0.0" -[consortium] -realm = "{{guild_slug}}" -description = "{{guild_name}} NSP Consortium" - [trust_domain] spiffe_trust_domain = "{{trust_domain}}" attestation_tier = 3 @@ -15,11 +11,21 @@ attestation_tier = 3 [identity_authority] provider = "keycloak" url = "https://auth.guildhouse.dev" -realm = "{{guild_slug}}" +realm = "guildhouse" +client_prefix = "{{guild_slug}}" trust_level = "federated" mfa_required = true hardware_credential_required = true +[members] +founding_master_did = "{{registrant_did}}" +initial_roles = ["master"] + +[infrastructure] +compute_attestation_tier = 3 +wireguard_tunnel = true +vpp_dataplane = true + [ceremonies.code_change] type = "single_approval" eligible_roles = ["master", "journeyman"] @@ -29,11 +35,13 @@ quorum = 1 type = "multi_party" eligible_roles = ["master"] quorum = 2 +founding_override = 1 -[members] -initial_roles = ["master"] +[federation_peers] +mode = "mesh" +hub_trust_domain = "guildhouse.dev" -[infrastructure] -compute_attestation_tier = 3 -wireguard_tunnel = true -vpp_dataplane = true +[attestation] +tier = 3 +require_tpm = true +require_secure_boot = true diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/realization_status_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/realization_status_test.exs new file mode 100644 index 0000000..41caa2c --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/realization_status_test.exs @@ -0,0 +1,114 @@ +defmodule Guildhall.Orchestrator.RealizationStatusTest do + use ExUnit.Case, async: true + + alias Guildhall.Orchestrator.RealizationStatus + + describe "classify_section/1" do + test "classifies all known statuses" do + assert {:succeeded, _} = RealizationStatus.classify_section("succeeded") + assert {:in_progress, _} = RealizationStatus.classify_section("in_progress") + assert {:failed, _} = RealizationStatus.classify_section("failed") + assert {:pending, _} = RealizationStatus.classify_section("pending") + assert {:skipped, _} = RealizationStatus.classify_section("skipped:no_change") + assert {:skipped, _} = RealizationStatus.classify_section("skipped:not_implemented") + assert {:skipped, label} = RealizationStatus.classify_section("skipped:upstream_unavailable:keycloak") + assert label =~ "keycloak" + end + + test "unknown status returns pending with warning" do + assert {:pending, _} = RealizationStatus.classify_section("totally_unknown") + end + end + + describe "derive_overall/1" do + test "all succeeded → realized" do + snapshot = %{sections: [%{status: "succeeded"}, %{status: "succeeded"}]} + assert :realized = RealizationStatus.derive_overall(snapshot) + end + + test "all succeeded + skipped → realized" do + snapshot = %{sections: [%{status: "succeeded"}, %{status: "skipped:no_change"}]} + assert :realized = RealizationStatus.derive_overall(snapshot) + end + + test "mix of failed and succeeded → partially_realized" do + snapshot = %{sections: [%{status: "succeeded"}, %{status: "failed"}]} + assert :partially_realized = RealizationStatus.derive_overall(snapshot) + end + + test "all failed → failed" do + snapshot = %{sections: [%{status: "failed"}, %{status: "failed"}]} + assert :failed = RealizationStatus.derive_overall(snapshot) + end + + test "in_progress → in_progress" do + snapshot = %{sections: [%{status: "in_progress"}, %{status: "pending"}]} + assert :in_progress = RealizationStatus.derive_overall(snapshot) + end + + test "empty sections → pending" do + assert :pending = RealizationStatus.derive_overall(%{sections: []}) + end + + test "no sections key → pending" do + assert :pending = RealizationStatus.derive_overall(%{}) + end + end + + describe "terminal_status?/1" do + test "realized is terminal" do + assert RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED) + end + + test "string form is terminal" do + assert RealizationStatus.terminal_status?("FFC_SCHEMATIC_STATUS_REALIZED") + end + + test "published is not terminal" do + refute RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_PUBLISHED) + end + end + + describe "display helpers" do + test "overall_css_class for each status" do + assert RealizationStatus.overall_css_class(:realized) =~ "emerald" + assert RealizationStatus.overall_css_class(:failed) =~ "red" + assert RealizationStatus.overall_css_class(:in_progress) =~ "amber" + assert RealizationStatus.overall_css_class(:pending) =~ "zinc" + end + + test "display_status for each overall" do + assert "Realized" = RealizationStatus.display_status(:realized) + assert "Failed" = RealizationStatus.display_status(:failed) + assert "In progress" = RealizationStatus.display_status(:in_progress) + end + + test "section_icon_class returns correct CSS" do + assert RealizationStatus.section_icon_class("succeeded") =~ "emerald" + assert RealizationStatus.section_icon_class("failed") =~ "red" + assert RealizationStatus.section_icon_class("skipped:no_change") =~ "sky" + end + + test "section_symbol returns correct symbols" do + assert "✓" = RealizationStatus.section_symbol("succeeded") + assert "✗" = RealizationStatus.section_symbol("failed") + assert "⊘" = RealizationStatus.section_symbol("skipped:not_implemented") + assert "○" = RealizationStatus.section_symbol("pending") + end + + test "section_display_status for skipped variants" do + assert RealizationStatus.section_display_status("skipped:no_change") =~ "no change" + assert RealizationStatus.section_display_status("skipped:not_implemented") =~ "stub" + end + end + + describe "all_known_statuses/0" do + test "returns comprehensive list" do + statuses = RealizationStatus.all_known_statuses() + assert length(statuses) >= 7 + assert "succeeded" in statuses + assert "failed" in statuses + assert "skipped:no_change" in statuses + end + end +end diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/schema_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/schema_test.exs new file mode 100644 index 0000000..d709fc5 --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/schema_test.exs @@ -0,0 +1,191 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.SchemaTest do + use ExUnit.Case, async: true + + alias Guildhall.Orchestrator.SchematicTemplate.Schema + + defp valid_msp_template do + %{ + "meta" => %{ + "template_name" => "msp-founding", + "description" => "MSP founding schematic", + "source_schematic" => "guildhouse-msp-base", + "source_version" => "1.0.0" + }, + "trust_domain" => %{ + "spiffe_trust_domain" => "{{trust_domain}}", + "attestation_tier" => 2 + }, + "identity_authority" => %{ + "provider" => "keycloak", + "url" => "https://auth.guildhouse.dev", + "realm" => "guildhouse", + "client_prefix" => "{{guild_slug}}", + "trust_level" => "federated", + "mfa_required" => true + }, + "members" => %{ + "founding_master_did" => "{{registrant_did}}", + "initial_roles" => ["master"] + }, + "infrastructure" => %{ + "compute_attestation_tier" => 2 + }, + "ceremonies" => %{ + "code_change" => %{ + "type" => "single_approval", + "eligible_roles" => ["master", "journeyman"], + "quorum" => 1 + }, + "governance_change" => %{ + "type" => "single_approval", + "eligible_roles" => ["master"], + "quorum" => 1 + } + }, + "federation_peers" => %{ + "mode" => "hub_spoke", + "hub_trust_domain" => "guildhouse.dev" + }, + "attestation" => %{ + "tier" => 2, + "require_tpm" => false, + "require_secure_boot" => false + } + } + end + + describe "validate/1" do + test "valid MSP template passes" do + assert :ok = Schema.validate(valid_msp_template()) + end + + test "rejects unknown sections" do + template = Map.put(valid_msp_template(), "bogus", %{"foo" => "bar"}) + assert {:error, {:unknown_sections, ["bogus"]}} = Schema.validate(template) + end + + test "rejects nil template" do + assert {:error, {:missing_section, _}} = Schema.validate(nil) + end + + test "rejects missing required section" do + template = Map.delete(valid_msp_template(), "trust_domain") + assert {:error, {:missing_section, "trust_domain"}} = Schema.validate(template) + end + + test "rejects missing required field" do + template = put_in(valid_msp_template(), ["trust_domain", "spiffe_trust_domain"], nil) + template = Map.update!(template, "trust_domain", &Map.delete(&1, "spiffe_trust_domain")) + assert {:error, {:missing_field, "trust_domain.spiffe_trust_domain"}} = Schema.validate(template) + end + + test "rejects invalid type for attestation_tier" do + template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], "two") + assert {:error, {:invalid_type, _}} = Schema.validate(template) + end + + test "rejects attestation_tier out of range" do + template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], 5) + assert {:error, {:invalid_value, _}} = Schema.validate(template) + end + + test "rejects invalid ceremony type" do + template = put_in(valid_msp_template(), ["ceremonies", "code_change", "type"], "whatever") + assert {:error, {:invalid_value, _}} = Schema.validate(template) + end + + test "rejects invalid role in eligible_roles" do + template = put_in(valid_msp_template(), ["ceremonies", "code_change", "eligible_roles"], ["admin"]) + assert {:error, {:invalid_value, _}} = Schema.validate(template) + end + end + + describe "cross-section validation" do + test "detects tier mismatch" do + template = put_in(valid_msp_template(), ["attestation", "tier"], 3) + assert {:error, {:tier_mismatch, _}} = Schema.validate(template) + end + + test "detects federation mode mismatch for MSP" do + template = put_in(valid_msp_template(), ["federation_peers", "mode"], "mesh") + # mesh also requires wireguard, but federation mode check runs first + assert {:error, {:federation_mode_mismatch, _}} = Schema.validate(template) + end + + test "mesh requires wireguard" do + template = + valid_msp_template() + |> put_in(["meta", "template_name"], "nsp-founding") + |> put_in(["federation_peers", "mode"], "mesh") + + assert {:error, {:mesh_requires_wireguard, _}} = Schema.validate(template) + end + end + + describe "founding_override validation" do + test "rejects founding_override on single_approval" do + template = put_in(valid_msp_template(), ["ceremonies", "code_change", "founding_override"], 1) + assert {:error, {:invalid_value, msg}} = Schema.validate(template) + assert msg =~ "only valid on multi_party" + end + + test "rejects founding_override >= quorum" do + template = + valid_msp_template() + |> put_in(["ceremonies", "governance_change"], %{ + "type" => "multi_party", + "eligible_roles" => ["master"], + "quorum" => 2, + "founding_override" => 2 + }) + + assert {:error, {:invalid_value, msg}} = Schema.validate(template) + assert msg =~ "less than quorum" + end + + test "accepts valid founding_override" do + template = + valid_msp_template() + |> put_in(["ceremonies", "governance_change"], %{ + "type" => "multi_party", + "eligible_roles" => ["master"], + "quorum" => 2, + "founding_override" => 1 + }) + + assert :ok = Schema.validate(template) + end + end + + describe "validate_resolved/1" do + test "accepts valid resolved values" do + template = + valid_msp_template() + |> put_in(["trust_domain", "spiffe_trust_domain"], "test-guild.guildhouse.dev") + |> put_in(["members", "founding_master_did"], "did:web:guildhouse.dev:user:tking") + |> put_in(["identity_authority", "client_prefix"], "test-guild") + + assert :ok = Schema.validate_resolved(template) + end + + test "rejects invalid DID format" do + template = + valid_msp_template() + |> put_in(["members", "founding_master_did"], "not-a-did") + |> put_in(["trust_domain", "spiffe_trust_domain"], "test.guildhouse.dev") + |> put_in(["identity_authority", "client_prefix"], "test") + + assert {:error, {:invalid_resolved_values, errors}} = Schema.validate_resolved(template) + assert Enum.any?(errors, &String.contains?(&1, "DID")) + end + end + + describe "TOML template loading" do + test "all three templates validate" do + for type <- ["msp", "isv", "nsp"] do + {:ok, template} = Guildhall.Orchestrator.SchematicTemplate.load_template(type) + assert :ok = Schema.validate(template), "#{type} template failed validation" + end + end + end +end diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/variable_resolver_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/variable_resolver_test.exs new file mode 100644 index 0000000..9a0b7ee --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/variable_resolver_test.exs @@ -0,0 +1,88 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolverTest do + use ExUnit.Case, async: true + + alias Guildhall.Orchestrator.SchematicTemplate.VariableResolver + + @valid_params %{ + "trust_domain" => "test-guild.guildhouse.dev", + "guild_slug" => "test-guild", + "guild_name" => "Test Guild", + "registrant_did" => "did:web:guildhouse.dev:user:tking" + } + + defp template_with_placeholders do + %{ + "trust_domain" => %{ + "spiffe_trust_domain" => "{{trust_domain}}" + }, + "members" => %{ + "founding_master_did" => "{{registrant_did}}" + }, + "identity_authority" => %{ + "client_prefix" => "{{guild_slug}}" + } + } + end + + describe "resolve/2" do + test "resolves all placeholders" do + {:ok, resolved} = VariableResolver.resolve(template_with_placeholders(), @valid_params) + assert resolved["trust_domain"]["spiffe_trust_domain"] == "test-guild.guildhouse.dev" + assert resolved["members"]["founding_master_did"] == "did:web:guildhouse.dev:user:tking" + assert resolved["identity_authority"]["client_prefix"] == "test-guild" + end + + test "returns error for missing required param" do + params = Map.delete(@valid_params, "trust_domain") + assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params) + assert Enum.any?(errors, fn {type, key} -> type == :missing_param and key == "trust_domain" end) + end + + test "returns error for empty param" do + params = Map.put(@valid_params, "guild_slug", "") + assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params) + assert Enum.any?(errors, fn {type, key} -> type == :empty_param and key == "guild_slug" end) + end + + test "returns error for non-string param" do + params = Map.put(@valid_params, "guild_slug", 42) + assert {:error, {:param_errors, _}} = VariableResolver.resolve(template_with_placeholders(), params) + end + + test "resolves nested lists" do + template = %{ + "roles" => ["{{guild_slug}}-admin", "{{guild_slug}}-user"], + "trust_domain" => %{"spiffe_trust_domain" => "x"}, + "members" => %{"founding_master_did" => "x"}, + "identity_authority" => %{"client_prefix" => "x"} + } + + {:ok, resolved} = VariableResolver.resolve(template, @valid_params) + assert resolved["roles"] == ["test-guild-admin", "test-guild-user"] + end + + test "leaves non-string values unchanged" do + template = %{ + "tier" => 2, + "enabled" => true, + "trust_domain" => %{"spiffe_trust_domain" => "x"}, + "members" => %{"founding_master_did" => "x"}, + "identity_authority" => %{"client_prefix" => "x"} + } + + {:ok, resolved} = VariableResolver.resolve(template, @valid_params) + assert resolved["tier"] == 2 + assert resolved["enabled"] == true + end + end + + describe "known_variables/0" do + test "returns the resolution manifest" do + vars = VariableResolver.known_variables() + assert "trust_domain" in vars + assert "guild_slug" in vars + assert "guild_name" in vars + assert "registrant_did" in vars + end + end +end diff --git a/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/wire_encoder_test.exs b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/wire_encoder_test.exs new file mode 100644 index 0000000..2f42a3a --- /dev/null +++ b/apps/guildhall_orchestrator/test/guildhall/orchestrator/schematic_template/wire_encoder_test.exs @@ -0,0 +1,139 @@ +defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoderTest do + use ExUnit.Case, async: true + + alias Guildhall.Orchestrator.SchematicTemplate.WireEncoder + + defp resolved_template do + %{ + "meta" => %{ + "template_name" => "msp-founding", + "description" => "MSP schematic", + "source_schematic" => "guildhouse-msp-base", + "source_version" => "1.0.0" + }, + "trust_domain" => %{ + "spiffe_trust_domain" => "test.guildhouse.dev", + "attestation_tier" => 2 + }, + "identity_authority" => %{ + "provider" => "keycloak", + "url" => "https://auth.guildhouse.dev", + "realm" => "guildhouse", + "client_prefix" => "test-guild", + "trust_level" => "federated", + "mfa_required" => true + }, + "members" => %{ + "founding_master_did" => "did:web:guildhouse.dev:user:tking", + "initial_roles" => ["master"] + }, + "infrastructure" => %{ + "compute_attestation_tier" => 2 + }, + "ceremonies" => %{ + "code_change" => %{ + "type" => "single_approval", + "eligible_roles" => ["master", "journeyman"], + "quorum" => 1 + }, + "governance_change" => %{ + "type" => "single_approval", + "eligible_roles" => ["master"], + "quorum" => 1 + } + }, + "federation_peers" => %{ + "mode" => "hub_spoke", + "hub_trust_domain" => "guildhouse.dev" + }, + "attestation" => %{ + "tier" => 2, + "require_tpm" => false, + "require_secure_boot" => false + } + } + end + + describe "encode/4" do + test "produces manifest YAML and 7 section files" do + {:ok, manifest_yaml, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0") + + assert is_binary(manifest_yaml) + assert length(files) == 7 + + file_paths = Enum.map(files, fn {path, _} -> path end) + assert "trust-domain.yaml" in file_paths + assert "identity-authority.yaml" in file_paths + assert "members.yaml" in file_paths + assert "infrastructure.yaml" in file_paths + assert "ceremonies.yaml" in file_paths + assert "federation-peers.yaml" in file_paths + assert "attestation.yaml" in file_paths + end + + test "manifest has correct structure" do + {:ok, manifest_yaml, _files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0") + manifest = Jason.decode!(manifest_yaml) + + assert manifest["apiVersion"] == "guildhouse.dev/v1" + assert manifest["kind"] == "FfcSchematic" + assert manifest["metadata"]["name"] == "test-msp" + assert manifest["metadata"]["version"] == "1.0.0" + assert manifest["spec"]["consortium"]["did"] == "did:web:guildhouse.dev" + assert is_list(manifest["spec"]["stakeholders"]) + assert manifest["spec"]["approval"]["strategy"] == "unanimous" + assert is_list(manifest["spec"]["sections"]) + end + + test "manifest uses custom hub_did" do + {:ok, manifest_yaml, _} = + WireEncoder.encode(resolved_template(), "test", "1.0.0", hub_did: "did:web:custom.dev") + + manifest = Jason.decode!(manifest_yaml) + assert manifest["spec"]["consortium"]["did"] == "did:web:custom.dev" + end + + test "section file content is valid JSON" do + {:ok, _manifest, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0") + + for {path, content} <- files do + assert {:ok, _} = Jason.decode(content), "#{path} is not valid JSON" + end + end + end + + describe "founding_override stripping" do + test "strips founding_override from ceremonies wire output" do + template = + put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1) + + {:ok, _manifest, files} = WireEncoder.encode(template, "test", "1.0.0") + + {_, ceremonies_json} = Enum.find(files, fn {p, _} -> p == "ceremonies.yaml" end) + ceremonies = Jason.decode!(ceremonies_json) + + refute Map.has_key?(ceremonies["governance_change"], "founding_override") + end + end + + describe "extract_founding_overrides/1" do + test "extracts override values" do + template = + put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1) + + overrides = WireEncoder.extract_founding_overrides(template) + assert overrides["ceremonies.governance_change.founding_override"] == 1 + end + + test "returns empty map when no overrides" do + assert %{} = WireEncoder.extract_founding_overrides(resolved_template()) + end + end + + describe "section_paths/0" do + test "returns all 7 section path mappings" do + paths = WireEncoder.section_paths() + assert map_size(paths) == 7 + end + end +end diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/realization.ex b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/realization.ex index 0dda3fb..b455247 100644 --- a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/realization.ex +++ b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/realization.ex @@ -2,6 +2,7 @@ defmodule GuildhallWeb.GuildLive.Realization do use GuildhallWeb, :live_view alias Guildhall.OpsDb.{Guilds, GuildSchematics} + alias Guildhall.Orchestrator.RealizationStatus @impl true def mount(%{"slug" => slug}, _session, socket) do @@ -41,25 +42,18 @@ defmodule GuildhallWeb.GuildLive.Realization do end end - defp status_icon("succeeded"), do: "text-emerald-600" - defp status_icon("in_progress"), do: "text-amber-600" - defp status_icon("failed"), do: "text-red-600" - defp status_icon("pending"), do: "text-zinc-400" - defp status_icon(_), do: "text-zinc-400" - - defp status_symbol("succeeded"), do: "✓" - defp status_symbol("in_progress"), do: "⟳" - defp status_symbol("failed"), do: "✗" - defp status_symbol(_), do: "○" + defp status_icon(status), do: RealizationStatus.section_icon_class(status) + defp status_symbol(status), do: RealizationStatus.section_symbol(status) + defp display_status(status), do: RealizationStatus.section_display_status(status) defp overall_class(snapshot) do - overall = Map.get(snapshot, "overall_status", Map.get(snapshot, :overall_status, "pending")) + overall = RealizationStatus.derive_overall(snapshot) + RealizationStatus.overall_css_class(overall) + end - cond do - String.contains?(to_string(overall), "REALIZED") -> "bg-emerald-100 text-emerald-700" - String.contains?(to_string(overall), "PUBLISHED") -> "bg-amber-100 text-amber-700" - true -> "bg-zinc-100 text-zinc-700" - end + defp overall_label(snapshot) do + overall = RealizationStatus.derive_overall(snapshot) + RealizationStatus.display_status(overall) end @impl true @@ -72,7 +66,7 @@ defmodule GuildhallWeb.GuildLive.Realization do <.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">← {@guild.name}

Realization Dashboard

- {Map.get(@snapshot, "overall_status", Map.get(@snapshot, :overall_status, "pending"))} + {overall_label(@snapshot)}
@@ -93,7 +87,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
{String.replace(name, "_", " ") |> String.capitalize()}
{s.message}
- {s.status} + {display_status(s.status)} diff --git a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex index 2ccea71..754800e 100644 --- a/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex +++ b/apps/guildhall_web/lib/guildhall_web_web/live/guild_live/schematic.ex @@ -2,7 +2,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do use GuildhallWeb, :live_view alias Guildhall.OpsDb.{Guilds, GuildSchematics} - alias Guildhall.Orchestrator.{SchematicClient, SchematicTemplate, RealizationPoller} + alias Guildhall.Orchestrator.{FfcPipeline, SchematicTemplate} @impl true def mount(%{"slug" => slug}, _session, socket) do @@ -39,56 +39,22 @@ defmodule GuildhallWeb.GuildLive.Schematic do @impl true def handle_event("deploy_schematic", _params, socket) do guild = socket.assigns.guild - template = socket.assigns.template socket = assign(socket, :deploying, true) - params = %{ - "guild_slug" => guild.slug, - "guild_name" => guild.name, - "trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev", - "registrant_did" => guild.registrant_did - } + case FfcPipeline.deploy(guild) do + {:ok, _state} -> + {:ok, _} = Guilds.update_guild(guild, %{status: "active"}) - rendered = SchematicTemplate.render_template(template, params) - operations = SchematicTemplate.to_fork_operations(rendered) - {source_name, source_version} = SchematicTemplate.source_schematic(template) - schematic_name = "#{guild.slug}-#{guild.guild_type}-founding" - schematic_version = "1.0.0" + {:noreply, + socket + |> put_flash(:info, "Schematic deployed. Realization in progress.") + |> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")} - with {:ok, fork_resp} <- - SchematicClient.fork_schematic(source_name, source_version, schematic_name, schematic_version, operations), - {:ok, binding_resp} <- - SchematicClient.create_deployment_binding(schematic_name, schematic_version, "founding", "production"), - {:ok, realize_resp} <- - SchematicClient.realize_ffc_schematic(schematic_name, schematic_version) do - {:ok, gs} = - GuildSchematics.create(%{ - guild_id: guild.id, - template_name: "#{guild.guild_type}-founding", - schematic_name: schematic_name, - schematic_version: schematic_version, - tree_hash: Map.get(fork_resp, :tree_hash, ""), - binding_id: Map.get(binding_resp, :binding_id, ""), - realization_id: Map.get(realize_resp, :realization_id, ""), - status: "realizing" - }) - - if gs.realization_id && gs.realization_id != "" do - RealizationPoller.watch(gs.realization_id, guild.slug) - end - - {:ok, _} = Guilds.update_guild(guild, %{status: "active"}) - - {:noreply, - socket - |> put_flash(:info, "Schematic deployed. Realization in progress.") - |> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")} - else - {:error, reason} -> + {:error, {step, reason}} -> {:noreply, socket |> assign(:deploying, false) - |> assign(:deploy_error, inspect(reason))} + |> assign(:deploy_error, "Step #{step} failed: #{inspect(reason)}")} end end @@ -124,8 +90,8 @@ defmodule GuildhallWeb.GuildLive.Schematic do

This will:

    -
  1. Fork the founding schematic template for {@guild.name}
  2. -
  3. Create a deployment binding (production)
  4. +
  5. Validate and resolve the founding schematic template
  6. +
  7. Create, validate, and publish the FFC schematic
  8. Trigger realization across all reconciler sections
  9. Transition guild to active status