Compare commits
2 commits
main
...
feat/conso
| Author | SHA256 | Date | |
|---|---|---|---|
| 38cf2b7c6b | |||
| 50c488b92b |
42 changed files with 2877 additions and 206 deletions
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -44,8 +44,9 @@ Pre-existing: `governed_artifacts`, `deployment_states`, `verification_results`
|
||||||
|
|
||||||
New guild tables:
|
New guild tables:
|
||||||
- **guilds** — `guild_id` (10-bit, 0x010–0x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did`
|
- **guilds** — `guild_id` (10-bit, 0x010–0x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did`
|
||||||
- **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/forked/binding_created/realizing/realized/failed), `realization_snapshot` (map)
|
- **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/draft/validated/hosting_agreed/awaiting_approval/approved/published/realizing/realized/partially_realized/failed), `realization_snapshot` (map), `bootstrap_mode` (self_sovereign/partner_hosted), `founding_override_expires_at`, `founding_override_revoked_at`, `founding_override_revocation_reason`, `approval_requested_at`, `approved_at`, `approval_metadata` (map)
|
||||||
- **guild_memberships** — FK to guilds, `user_did`, `role` (apprentice/journeyman/master), `status` (pending/approved/denied/active/suspended/removed), `membership_ceremony_id`, unique on (guild_id, user_did)
|
- **guild_memberships** — FK to guilds, `user_did`, `role` (apprentice/journeyman/master), `status` (pending/approved/denied/active/suspended/removed), `membership_ceremony_id`, unique on (guild_id, user_did)
|
||||||
|
- **hosting_agreements** — FK to guild_schematics + guilds (partner), `hosting_did`, `terms` (map), `partner_signature` (binary, Ed25519), `status` (proposed/ratified/active/handoff_pending/completed/expired), `auto_ratified`, `expires_at`
|
||||||
|
|
||||||
## Key flows
|
## Key flows
|
||||||
|
|
||||||
|
|
@ -53,11 +54,22 @@ New guild tables:
|
||||||
1. User fills form at `/guilds/register` → creates guild row (status: pending_approval) + ceremony via `CeremonyClient.create_guild_registration_ceremony`
|
1. User fills form at `/guilds/register` → creates guild row (status: pending_approval) + ceremony via `CeremonyClient.create_guild_registration_ceremony`
|
||||||
2. Hub operator (tking) sees pending guild at `/guilds/:slug`, clicks Approve → `CeremonyClient.approve_ceremony` + `Guilds.approve_guild/1` (Ecto.Multi: updates guild to "approved" + creates registrant as guild master)
|
2. Hub operator (tking) sees pending guild at `/guilds/:slug`, clicks Approve → `CeremonyClient.approve_ceremony` + `Guilds.approve_guild/1` (Ecto.Multi: updates guild to "approved" + creates registrant as guild master)
|
||||||
|
|
||||||
### Schematic deployment
|
### Schematic deployment (FfcPipeline)
|
||||||
1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type from `priv/schematic_templates/`
|
1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type
|
||||||
2. Click Deploy → `SchematicClient.fork_schematic` → `create_deployment_binding` → `realize_ffc_schematic`
|
2. Click Deploy → `FfcPipeline.deploy/2` runs 11 steps: load → validate → resolve → validate_resolved → encode → create_on_server → validate_on_server → create_db_record → fetch_schematic_hash → check_founding_override → check_hosting_agreement
|
||||||
3. Creates `guild_schematics` row, starts `RealizationPoller.watch/2`
|
3. Pipeline pauses at `awaiting_approval` — returns `{:paused, state}` with `schematic_hash` for signing
|
||||||
4. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates
|
4. External signer POSTs Ed25519 signature to `POST /api/approvals` (bearer token auth)
|
||||||
|
5. `ApprovalCoordinator` validates signer + calls `FfcPipeline.resume_after_approval/2`: approve_rpc → publish → realize → update_db → start_poller
|
||||||
|
6. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates
|
||||||
|
|
||||||
|
### Bootstrap modes
|
||||||
|
- **self_sovereign** — Guild has own FFC. Founding master signs from their own GSH.
|
||||||
|
- **partner_hosted** — Partner guild (default: Guildhouse `guildhouse-ops`) hosts FFC during bootstrap. Requires ratified `HostingAgreement`. Guildhouse auto-ratifies for NSP tiers.
|
||||||
|
|
||||||
|
### Founding override lifecycle
|
||||||
|
- NSP templates use `founding_override` to reduce multi-party quorum to 1 during bootstrap
|
||||||
|
- 90-day TTL enforced by `FoundingOverrideGuard` (inline check) + `FoundingOverrideMonitor` (hourly sweep)
|
||||||
|
- Revoked automatically when second master onboards, or manually by founding master on GuildLive.Show
|
||||||
|
|
||||||
### Member onboarding
|
### Member onboarding
|
||||||
1. User visits `/guilds/:slug/join` → creates membership (pending) + ceremony for guild master approval
|
1. User visits `/guilds/:slug/join` → creates membership (pending) + ceremony for guild master approval
|
||||||
|
|
@ -81,10 +93,18 @@ All guild routes are under authenticated `live_session :authenticated`:
|
||||||
|
|
||||||
Public: `/auth/login`, `/auth/callback`, `/auth/logout`, `/health`
|
Public: `/auth/login`, `/auth/callback`, `/auth/logout`, `/health`
|
||||||
|
|
||||||
|
API (bearer token `GUILDHALL_APPROVAL_WEBHOOK_SECRET`):
|
||||||
|
```
|
||||||
|
POST /api/approvals — receive Ed25519 signature for pending schematic
|
||||||
|
POST /api/hosting-agreements/:id/ratify — partner ratifies hosting agreement
|
||||||
|
```
|
||||||
|
|
||||||
## Orchestrator supervision tree
|
## Orchestrator supervision tree
|
||||||
|
|
||||||
- `Guildhall.Orchestrator.CeremonyOrchestrator` — existing ceremony workflow coordinator
|
- `Guildhall.Orchestrator.CeremonyOrchestrator` — existing ceremony workflow coordinator
|
||||||
- `Guildhall.Orchestrator.RealizationPoller` — GenServer, polls realization status every 5s for watched IDs, broadcasts on `"realization:#{guild_slug}"` PubSub topic
|
- `Guildhall.Orchestrator.RealizationPoller` — GenServer, polls realization status every 5s for watched IDs, broadcasts on `"realization:#{guild_slug}"` PubSub topic
|
||||||
|
- `Guildhall.Orchestrator.FoundingOverrideMonitor` — GenServer, hourly sweep of expired overrides, broadcasts `{:founding_override_revoked, ...}` on `"override:#{slug}"`
|
||||||
|
- `Guildhall.Orchestrator.ApprovalCoordinator` — GenServer, tracks pending approvals by `"name:version"`, dispatches `resume_after_approval` when signature received
|
||||||
|
|
||||||
## Schematic templates
|
## Schematic templates
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,23 @@ defmodule Guildhall.OpsDb.GuildMemberships do
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve_membership(%GuildMembership{} = membership, approver_did) do
|
def approve_membership(%GuildMembership{} = membership, approver_did) do
|
||||||
membership
|
result =
|
||||||
|> GuildMembership.changeset(%{
|
membership
|
||||||
status: "active",
|
|> GuildMembership.changeset(%{
|
||||||
approved_by_did: approver_did,
|
status: "active",
|
||||||
approved_at: DateTime.utc_now()
|
approved_by_did: approver_did,
|
||||||
})
|
approved_at: DateTime.utc_now()
|
||||||
|> Repo.update()
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, m} ->
|
||||||
|
maybe_revoke_founding_override(m.guild_id)
|
||||||
|
{:ok, m}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def deny_membership(%GuildMembership{} = membership, approver_did) do
|
def deny_membership(%GuildMembership{} = membership, approver_did) do
|
||||||
|
|
@ -51,8 +61,29 @@ defmodule Guildhall.OpsDb.GuildMemberships do
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_role(%GuildMembership{} = membership, new_role) do
|
def update_role(%GuildMembership{} = membership, new_role) do
|
||||||
membership
|
result =
|
||||||
|> GuildMembership.changeset(%{role: new_role})
|
membership
|
||||||
|> Repo.update()
|
|> GuildMembership.changeset(%{role: new_role})
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, m} ->
|
||||||
|
if new_role == "master", do: maybe_revoke_founding_override(m.guild_id)
|
||||||
|
{:ok, m}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_revoke_founding_override(guild_id) do
|
||||||
|
masters = masters_for_guild(guild_id)
|
||||||
|
|
||||||
|
if length(masters) >= 2 do
|
||||||
|
Guildhall.OpsDb.GuildSchematics.revoke_founding_override(
|
||||||
|
guild_id,
|
||||||
|
"second_master_onboarded"
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,21 @@ defmodule Guildhall.OpsDb.GuildSchematic do
|
||||||
field :status, :string, default: "pending"
|
field :status, :string, default: "pending"
|
||||||
field :customization_params, :map, default: %{}
|
field :customization_params, :map, default: %{}
|
||||||
field :realization_snapshot, :map, default: %{}
|
field :realization_snapshot, :map, default: %{}
|
||||||
|
field :founding_override_expires_at, :utc_datetime_usec
|
||||||
|
field :founding_override_revoked_at, :utc_datetime_usec
|
||||||
|
field :founding_override_revocation_reason, :string
|
||||||
|
field :bootstrap_mode, :string, default: "self_sovereign"
|
||||||
|
field :approval_requested_at, :utc_datetime_usec
|
||||||
|
field :approved_at, :utc_datetime_usec
|
||||||
|
field :approval_metadata, :map, default: %{}
|
||||||
|
|
||||||
|
has_many :hosting_agreements, Guildhall.OpsDb.HostingAgreement
|
||||||
|
|
||||||
timestamps(type: :utc_datetime_usec)
|
timestamps(type: :utc_datetime_usec)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@statuses ~w(pending draft validated hosting_agreed awaiting_approval approved published realizing realized partially_realized failed)
|
||||||
|
|
||||||
def changeset(schematic, attrs) do
|
def changeset(schematic, attrs) do
|
||||||
schematic
|
schematic
|
||||||
|> cast(attrs, [
|
|> cast(attrs, [
|
||||||
|
|
@ -32,10 +43,18 @@ defmodule Guildhall.OpsDb.GuildSchematic do
|
||||||
:realization_id,
|
:realization_id,
|
||||||
:status,
|
:status,
|
||||||
:customization_params,
|
:customization_params,
|
||||||
:realization_snapshot
|
:realization_snapshot,
|
||||||
|
:founding_override_expires_at,
|
||||||
|
:founding_override_revoked_at,
|
||||||
|
:founding_override_revocation_reason,
|
||||||
|
:bootstrap_mode,
|
||||||
|
:approval_requested_at,
|
||||||
|
:approved_at,
|
||||||
|
:approval_metadata
|
||||||
])
|
])
|
||||||
|> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version])
|
|> 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, @statuses)
|
||||||
|
|> validate_inclusion(:bootstrap_mode, ~w(self_sovereign partner_hosted))
|
||||||
|> foreign_key_constraint(:guild_id)
|
|> foreign_key_constraint(:guild_id)
|
||||||
|> unique_constraint([:schematic_name, :schematic_version])
|
|> unique_constraint([:schematic_name, :schematic_version])
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,46 @@ defmodule Guildhall.OpsDb.GuildSchematics do
|
||||||
def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do
|
def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do
|
||||||
update_schematic(gs, %{realization_snapshot: snapshot})
|
update_schematic(gs, %{realization_snapshot: snapshot})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoke_founding_override(guild_id, reason) do
|
||||||
|
case latest_with_active_override(guild_id) do
|
||||||
|
nil ->
|
||||||
|
{:ok, :no_override}
|
||||||
|
|
||||||
|
gs ->
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
||||||
|
|
||||||
|
update_schematic(gs, %{
|
||||||
|
founding_override_revoked_at: now,
|
||||||
|
founding_override_revocation_reason: reason
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def schematics_with_expired_overrides do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
Repo.all(
|
||||||
|
from(gs in GuildSchematic,
|
||||||
|
where:
|
||||||
|
not is_nil(gs.founding_override_expires_at) and
|
||||||
|
is_nil(gs.founding_override_revoked_at) and
|
||||||
|
gs.founding_override_expires_at < ^now,
|
||||||
|
preload: [:guild]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp latest_with_active_override(guild_id) do
|
||||||
|
Repo.one(
|
||||||
|
from(gs in GuildSchematic,
|
||||||
|
where:
|
||||||
|
gs.guild_id == ^guild_id and
|
||||||
|
not is_nil(gs.founding_override_expires_at) and
|
||||||
|
is_nil(gs.founding_override_revoked_at),
|
||||||
|
order_by: [desc: gs.inserted_at],
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
defmodule Guildhall.OpsDb.HostingAgreement do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@statuses ~w(proposed ratified active handoff_pending completed expired)
|
||||||
|
|
||||||
|
schema "hosting_agreements" do
|
||||||
|
belongs_to :guild_schematic, Guildhall.OpsDb.GuildSchematic
|
||||||
|
belongs_to :partner_guild, Guildhall.OpsDb.Guild
|
||||||
|
|
||||||
|
field :hosting_did, :string
|
||||||
|
field :terms, :map, default: %{}
|
||||||
|
field :partner_signature, :binary
|
||||||
|
field :partner_signed_at, :utc_datetime_usec
|
||||||
|
field :auto_ratified, :boolean, default: false
|
||||||
|
field :status, :string, default: "proposed"
|
||||||
|
field :expires_at, :utc_datetime_usec
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(agreement, attrs) do
|
||||||
|
agreement
|
||||||
|
|> cast(attrs, [
|
||||||
|
:guild_schematic_id,
|
||||||
|
:partner_guild_id,
|
||||||
|
:hosting_did,
|
||||||
|
:terms,
|
||||||
|
:partner_signature,
|
||||||
|
:partner_signed_at,
|
||||||
|
:auto_ratified,
|
||||||
|
:status,
|
||||||
|
:expires_at
|
||||||
|
])
|
||||||
|
|> validate_required([:guild_schematic_id, :partner_guild_id, :hosting_did, :expires_at])
|
||||||
|
|> validate_inclusion(:status, @statuses)
|
||||||
|
|> validate_signature_length()
|
||||||
|
|> foreign_key_constraint(:guild_schematic_id)
|
||||||
|
|> foreign_key_constraint(:partner_guild_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_signature_length(changeset) do
|
||||||
|
case get_change(changeset, :partner_signature) do
|
||||||
|
nil -> changeset
|
||||||
|
sig when byte_size(sig) == 64 -> changeset
|
||||||
|
_ -> add_error(changeset, :partner_signature, "must be exactly 64 bytes (Ed25519)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule Guildhall.OpsDb.HostingAgreements do
|
||||||
|
import Ecto.Query, only: [from: 2]
|
||||||
|
alias Guildhall.OpsDb.{Repo, HostingAgreement}
|
||||||
|
|
||||||
|
def get!(id), do: Repo.get!(HostingAgreement, id)
|
||||||
|
|
||||||
|
def get_for_schematic(guild_schematic_id) do
|
||||||
|
Repo.one(
|
||||||
|
from(ha in HostingAgreement,
|
||||||
|
where: ha.guild_schematic_id == ^guild_schematic_id,
|
||||||
|
order_by: [desc: ha.inserted_at],
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_ratified_for_schematic(guild_schematic_id) do
|
||||||
|
Repo.one(
|
||||||
|
from(ha in HostingAgreement,
|
||||||
|
where: ha.guild_schematic_id == ^guild_schematic_id and ha.status in ["ratified", "active"],
|
||||||
|
order_by: [desc: ha.inserted_at],
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create(attrs) do
|
||||||
|
%HostingAgreement{}
|
||||||
|
|> HostingAgreement.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(%HostingAgreement{} = ha, attrs) do
|
||||||
|
ha
|
||||||
|
|> HostingAgreement.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
def ratify(%HostingAgreement{status: "proposed"} = ha, attrs) do
|
||||||
|
update(ha, Map.merge(attrs, %{status: "ratified", partner_signed_at: DateTime.utc_now()}))
|
||||||
|
end
|
||||||
|
|
||||||
|
def ratify(%HostingAgreement{}, _attrs), do: {:error, :not_proposed}
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
defmodule Guildhall.OpsDb.Repo.Migrations.AddOverrideRevocationAndBootstrap do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:guild_schematics) do
|
||||||
|
add :founding_override_revoked_at, :utc_datetime_usec
|
||||||
|
add :founding_override_revocation_reason, :string
|
||||||
|
add :bootstrap_mode, :string, default: "self_sovereign"
|
||||||
|
add :approval_requested_at, :utc_datetime_usec
|
||||||
|
add :approved_at, :utc_datetime_usec
|
||||||
|
add :approval_metadata, :map, default: %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
create table(:hosting_agreements) do
|
||||||
|
add :guild_schematic_id, references(:guild_schematics, on_delete: :delete_all), null: false
|
||||||
|
add :partner_guild_id, references(:guilds, on_delete: :restrict), null: false
|
||||||
|
add :hosting_did, :string, null: false
|
||||||
|
add :terms, :map, default: %{}
|
||||||
|
add :partner_signature, :binary
|
||||||
|
add :partner_signed_at, :utc_datetime_usec
|
||||||
|
add :auto_ratified, :boolean, default: false
|
||||||
|
add :status, :string, default: "proposed"
|
||||||
|
add :expires_at, :utc_datetime_usec, null: false
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:hosting_agreements, [:guild_schematic_id])
|
||||||
|
create index(:hosting_agreements, [:partner_guild_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
alias Guildhall.OpsDb.{
|
alias Guildhall.OpsDb.{
|
||||||
Repo,
|
Repo,
|
||||||
|
Guild,
|
||||||
AccordBinding,
|
AccordBinding,
|
||||||
GovernedArtifact,
|
GovernedArtifact,
|
||||||
CustodyTransition,
|
CustodyTransition,
|
||||||
|
|
@ -15,6 +16,26 @@ alias Guildhall.OpsDb.{
|
||||||
|
|
||||||
now = DateTime.utc_now()
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────
|
||||||
|
# 0. Guildhouse operational guild (default hosting partner)
|
||||||
|
# ────────────────────────────────────────────────────────
|
||||||
|
_guildhouse =
|
||||||
|
Repo.insert!(
|
||||||
|
%Guild{}
|
||||||
|
|> Guild.changeset(%{
|
||||||
|
guild_id: 0x001,
|
||||||
|
name: "Guildhouse Operations",
|
||||||
|
slug: "guildhouse-ops",
|
||||||
|
guild_type: "nsp",
|
||||||
|
status: "active",
|
||||||
|
registrant_did: "did:web:guildhouse.dev:user:tking",
|
||||||
|
contact_did: "did:web:guildhouse.dev:user:tking",
|
||||||
|
trust_domain: "guildhouse.dev"
|
||||||
|
}),
|
||||||
|
on_conflict: :nothing,
|
||||||
|
conflict_target: :slug
|
||||||
|
)
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────
|
||||||
# 1. Accord binding (shared by all artifacts below)
|
# 1. Accord binding (shared by all artifacts below)
|
||||||
# ────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ defmodule Guildhall.Orchestrator.Application do
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
Guildhall.Orchestrator.CeremonyOrchestrator,
|
Guildhall.Orchestrator.CeremonyOrchestrator,
|
||||||
Guildhall.Orchestrator.RealizationPoller
|
Guildhall.Orchestrator.RealizationPoller,
|
||||||
|
Guildhall.Orchestrator.FoundingOverrideMonitor,
|
||||||
|
Guildhall.Orchestrator.ApprovalCoordinator
|
||||||
]
|
]
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]
|
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
defmodule Guildhall.Orchestrator.ApprovalCoordinator do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.{ApprovalPayload, FfcPipeline}
|
||||||
|
alias Guildhall.OpsDb.{GuildMemberships, HostingAgreements}
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_pending(guild_schematic) do
|
||||||
|
GenServer.call(__MODULE__, {:register, guild_schematic})
|
||||||
|
end
|
||||||
|
|
||||||
|
def receive_approval(%ApprovalPayload{} = payload) do
|
||||||
|
GenServer.call(__MODULE__, {:approve, payload}, 30_000)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pending_count do
|
||||||
|
GenServer.call(__MODULE__, :pending_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
{:ok, %{pending: %{}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:register, gs}, _from, state) do
|
||||||
|
key = "#{gs.schematic_name}:#{gs.schematic_version}"
|
||||||
|
pending = Map.put(state.pending, key, gs)
|
||||||
|
Logger.info("Registered pending approval: #{key}")
|
||||||
|
{:reply, :ok, %{state | pending: pending}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:approve, payload}, _from, state) do
|
||||||
|
key = "#{payload.schematic_name}:#{payload.schematic_version}"
|
||||||
|
|
||||||
|
case Map.get(state.pending, key) do
|
||||||
|
nil ->
|
||||||
|
{:reply, {:error, :not_pending}, state}
|
||||||
|
|
||||||
|
gs ->
|
||||||
|
case process_approval(gs, payload) do
|
||||||
|
{:ok, result_state} ->
|
||||||
|
pending = Map.delete(state.pending, key)
|
||||||
|
slug = gs.schematic_name
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Guildhall.PubSub,
|
||||||
|
"approval:#{slug}",
|
||||||
|
{:approval_complete,
|
||||||
|
%{schematic_name: gs.schematic_name, schematic_version: gs.schematic_version}}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:reply, {:ok, result_state}, %{state | pending: pending}}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:pending_count, _from, state) do
|
||||||
|
{:reply, map_size(state.pending), state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_approval(gs, payload) do
|
||||||
|
with :ok <- verify_signer_authorized(gs, payload),
|
||||||
|
:ok <- verify_hosting_if_needed(gs),
|
||||||
|
:ok <- ApprovalPayload.validate(payload) do
|
||||||
|
FfcPipeline.resume_after_approval(gs, payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_signer_authorized(gs, payload) do
|
||||||
|
masters = GuildMemberships.masters_for_guild(gs.guild_id)
|
||||||
|
|
||||||
|
if Enum.any?(masters, fn m -> m.user_did == payload.signer_did end) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, :signer_not_authorized}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_hosting_if_needed(%{bootstrap_mode: "partner_hosted"} = gs) do
|
||||||
|
case HostingAgreements.get_ratified_for_schematic(gs.id) do
|
||||||
|
nil -> {:error, :hosting_agreement_not_ratified}
|
||||||
|
_ha -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_hosting_if_needed(_gs), do: :ok
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
defmodule Guildhall.Orchestrator.ApprovalPayload do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@enforce_keys [:schematic_name, :schematic_version, :signer_did, :signature, :accord_hash]
|
||||||
|
defstruct [
|
||||||
|
:schematic_name,
|
||||||
|
:schematic_version,
|
||||||
|
:signer_did,
|
||||||
|
:signature,
|
||||||
|
:accord_hash,
|
||||||
|
:key_ref,
|
||||||
|
:hosting_agreement_id,
|
||||||
|
role: :FFC_ROLE_FOUNDING_STAKEHOLDER
|
||||||
|
]
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
schematic_name: String.t(),
|
||||||
|
schematic_version: String.t(),
|
||||||
|
signer_did: String.t(),
|
||||||
|
role: atom(),
|
||||||
|
signature: binary(),
|
||||||
|
accord_hash: binary(),
|
||||||
|
key_ref: String.t() | nil,
|
||||||
|
hosting_agreement_id: integer() | nil
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate(%__MODULE__{} = payload) do
|
||||||
|
errors =
|
||||||
|
[]
|
||||||
|
|> check_signature(payload.signature)
|
||||||
|
|> check_signer_did(payload.signer_did)
|
||||||
|
|> check_non_empty(:schematic_name, payload.schematic_name)
|
||||||
|
|> check_non_empty(:schematic_version, payload.schematic_version)
|
||||||
|
|> check_non_empty(:accord_hash, payload.accord_hash)
|
||||||
|
|
||||||
|
case errors do
|
||||||
|
[] -> :ok
|
||||||
|
errors -> {:error, {:validation_errors, Enum.reverse(errors)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_signature(errors, sig) when is_binary(sig) and byte_size(sig) == 64, do: errors
|
||||||
|
defp check_signature(errors, _), do: [{:signature, "must be exactly 64 bytes"} | errors]
|
||||||
|
|
||||||
|
defp check_signer_did(errors, "did:" <> _), do: errors
|
||||||
|
defp check_signer_did(errors, _), do: [{:signer_did, "must start with did:"} | errors]
|
||||||
|
|
||||||
|
defp check_non_empty(errors, _field, value) when is_binary(value) and byte_size(value) > 0,
|
||||||
|
do: errors
|
||||||
|
|
||||||
|
defp check_non_empty(errors, field, _), do: [{field, "must be non-empty"} | errors]
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
defmodule Guildhall.Orchestrator.FfcPipeline do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder}
|
||||||
|
alias Guildhall.Orchestrator.{FoundingOverrideGuard, RealizationPoller}
|
||||||
|
alias Guildhall.OpsDb.{GuildSchematics, HostingAgreements}
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:guild,
|
||||||
|
:guild_type,
|
||||||
|
:template,
|
||||||
|
:resolved,
|
||||||
|
:manifest_yaml,
|
||||||
|
:files,
|
||||||
|
:founding_overrides,
|
||||||
|
:schematic_name,
|
||||||
|
:schematic_version,
|
||||||
|
:guild_schematic,
|
||||||
|
:realization_id,
|
||||||
|
:schematic_hash,
|
||||||
|
:accord_hash,
|
||||||
|
:bootstrap_mode
|
||||||
|
]
|
||||||
|
|
||||||
|
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}")
|
||||||
|
bootstrap_mode = Keyword.get(opts, :bootstrap_mode, "self_sovereign")
|
||||||
|
|
||||||
|
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,
|
||||||
|
bootstrap_mode: bootstrap_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
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(:create_db_record, state, fn -> create_db_record(state) end),
|
||||||
|
{:ok, state} <- step(:fetch_schematic_hash, state, fn -> fetch_schematic_hash(state) end),
|
||||||
|
{:ok, state} <- step(:check_founding_override, state, fn -> check_founding_override(state) end),
|
||||||
|
{:ok, state} <- step(:check_hosting_agreement, state, fn -> check_hosting_agreement(state) end) do
|
||||||
|
request_approval(state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume_after_approval(guild_schematic, %{} = approval_payload) do
|
||||||
|
if guild_schematic.status != "awaiting_approval" do
|
||||||
|
{:error, {:resume, :not_awaiting_approval}}
|
||||||
|
else
|
||||||
|
state = %__MODULE__{
|
||||||
|
guild_schematic: guild_schematic,
|
||||||
|
schematic_name: guild_schematic.schematic_name,
|
||||||
|
schematic_version: guild_schematic.schematic_version
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state, approval_payload) 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(:update_db_realized, state, fn -> update_db_realized(state) end),
|
||||||
|
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
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 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,
|
||||||
|
status: "draft",
|
||||||
|
customization_params: overrides,
|
||||||
|
founding_override_expires_at: expires_at,
|
||||||
|
bootstrap_mode: state.bootstrap_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
case GuildSchematics.create(attrs) do
|
||||||
|
{:ok, gs} -> {:ok, %{state | guild_schematic: gs}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_schematic_hash(%{schematic_name: name, schematic_version: version} = state) do
|
||||||
|
case client().get_ffc_schematic(name, version) do
|
||||||
|
{:ok, response} ->
|
||||||
|
artifact = response.artifact
|
||||||
|
{:ok, %{state | schematic_hash: artifact.schematic_hash, accord_hash: artifact.accord_hash}}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_founding_override(%{guild_schematic: gs} = state) do
|
||||||
|
case FoundingOverrideGuard.check(gs) do
|
||||||
|
:ok -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_hosting_agreement(%{bootstrap_mode: "self_sovereign"} = state), do: {:ok, state}
|
||||||
|
|
||||||
|
defp check_hosting_agreement(%{bootstrap_mode: "partner_hosted", guild_schematic: gs} = state) do
|
||||||
|
case HostingAgreements.get_ratified_for_schematic(gs.id) do
|
||||||
|
nil -> {:error, :hosting_agreement_required}
|
||||||
|
_ha -> {:ok, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_approval(%{guild_schematic: gs} = state) do
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
||||||
|
|
||||||
|
case GuildSchematics.update_schematic(gs, %{
|
||||||
|
status: "awaiting_approval",
|
||||||
|
approval_requested_at: now
|
||||||
|
}) do
|
||||||
|
{:ok, updated_gs} ->
|
||||||
|
{:paused, %{state | guild_schematic: updated_gs}}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
{:error, {:request_approval, error}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp approve_schematic(%{schematic_name: name, schematic_version: version} = state, payload) do
|
||||||
|
attrs = %{
|
||||||
|
schematic_name: name,
|
||||||
|
schematic_version: version,
|
||||||
|
signer_did: payload.signer_did,
|
||||||
|
role: payload.role,
|
||||||
|
signature: payload.signature,
|
||||||
|
accord_hash: payload.accord_hash,
|
||||||
|
key_ref: payload.key_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
case client().approve_ffc_schematic(attrs) do
|
||||||
|
{:ok, _response} ->
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
||||||
|
|
||||||
|
GuildSchematics.update_schematic(state.guild_schematic, %{
|
||||||
|
status: "approved",
|
||||||
|
approved_at: now,
|
||||||
|
approval_metadata: %{
|
||||||
|
"signer_did" => payload.signer_did,
|
||||||
|
"hosting_agreement_id" => payload.hosting_agreement_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, state}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
GuildSchematics.update_schematic(state.guild_schematic, %{status: "failed"})
|
||||||
|
error
|
||||||
|
end
|
||||||
|
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 update_db_realized(%{guild_schematic: gs, realization_id: rid} = state) do
|
||||||
|
case GuildSchematics.update_schematic(gs, %{
|
||||||
|
realization_id: rid,
|
||||||
|
status: "realizing"
|
||||||
|
}) do
|
||||||
|
{:ok, updated} -> {:ok, %{state | guild_schematic: updated}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_poller(%{realization_id: realization_id, guild_schematic: gs} = state) do
|
||||||
|
slug = gs.schematic_name
|
||||||
|
RealizationPoller.watch(realization_id, slug)
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp client do
|
||||||
|
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
defmodule Guildhall.Orchestrator.FoundingOverrideGuard do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
def check(%{customization_params: params} = gs) when is_map(params) do
|
||||||
|
if has_overrides?(params) do
|
||||||
|
check_override_state(gs)
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check(%{}), do: :ok
|
||||||
|
|
||||||
|
defp check_override_state(%{founding_override_revoked_at: revoked_at} = gs)
|
||||||
|
when not is_nil(revoked_at) do
|
||||||
|
{:error, {:override_revoked, gs.founding_override_revocation_reason || "revoked"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_override_state(%{founding_override_expires_at: nil}) do
|
||||||
|
{:error, :override_missing_expiry}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_override_state(%{founding_override_expires_at: expires_at}) do
|
||||||
|
if DateTime.compare(DateTime.utc_now(), expires_at) == :gt do
|
||||||
|
{:error, :override_ttl_expired}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_quorum(%{customization_params: params} = gs, ceremony_key) when is_map(params) do
|
||||||
|
override_key = "ceremonies.#{ceremony_key}.founding_override"
|
||||||
|
|
||||||
|
case {Map.get(params, override_key), check(gs)} do
|
||||||
|
{nil, _} -> :no_override
|
||||||
|
{value, :ok} -> {:override, value}
|
||||||
|
{_value, {:error, _}} -> :no_override
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_quorum(%{}, _ceremony_key), do: :no_override
|
||||||
|
|
||||||
|
defp has_overrides?(params) do
|
||||||
|
Enum.any?(params, fn {key, _} -> String.contains?(key, "founding_override") end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
defmodule Guildhall.Orchestrator.FoundingOverrideMonitor do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.GuildSchematics
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
interval = Keyword.get(opts, :interval_ms, default_interval())
|
||||||
|
Process.send_after(self(), :sweep, interval)
|
||||||
|
{:ok, %{interval: interval}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:sweep, %{interval: interval} = state) do
|
||||||
|
sweep()
|
||||||
|
Process.send_after(self(), :sweep, interval)
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sweep do
|
||||||
|
expired = GuildSchematics.schematics_with_expired_overrides()
|
||||||
|
|
||||||
|
Enum.each(expired, fn gs ->
|
||||||
|
Logger.warning("Revoking expired founding override for schematic #{gs.schematic_name}")
|
||||||
|
|
||||||
|
case GuildSchematics.update_schematic(gs, %{
|
||||||
|
founding_override_revoked_at:
|
||||||
|
DateTime.utc_now() |> DateTime.truncate(:microsecond),
|
||||||
|
founding_override_revocation_reason: "ttl_expired"
|
||||||
|
}) do
|
||||||
|
{:ok, _} ->
|
||||||
|
slug = if gs.guild, do: gs.guild.slug, else: gs.schematic_name
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Guildhall.PubSub,
|
||||||
|
"override:#{slug}",
|
||||||
|
{:founding_override_revoked,
|
||||||
|
%{guild_schematic_id: gs.id, reason: "ttl_expired"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to revoke override for #{gs.schematic_name}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
length(expired)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_interval do
|
||||||
|
Application.get_env(:guildhall_orchestrator, :founding_override_sweep_interval_ms, 3_600_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
defmodule Guildhall.Orchestrator.HostingCeremony do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.{GuildMemberships, HostingAgreements}
|
||||||
|
|
||||||
|
def propose(guild_schematic, partner_guild) do
|
||||||
|
default_duration_days = 180
|
||||||
|
expires_at =
|
||||||
|
DateTime.utc_now()
|
||||||
|
|> DateTime.add(default_duration_days * 86_400, :second)
|
||||||
|
|> DateTime.truncate(:microsecond)
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
guild_schematic_id: guild_schematic.id,
|
||||||
|
partner_guild_id: partner_guild.id,
|
||||||
|
hosting_did: partner_guild.registrant_did,
|
||||||
|
terms: %{
|
||||||
|
"duration_days" => default_duration_days,
|
||||||
|
"max_namespaces" => 1,
|
||||||
|
"handoff_trigger" => "manual"
|
||||||
|
},
|
||||||
|
status: "proposed",
|
||||||
|
expires_at: expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
case HostingAgreements.create(attrs) do
|
||||||
|
{:ok, ha} ->
|
||||||
|
if auto_ratify?(partner_guild, guild_schematic) do
|
||||||
|
HostingAgreements.update(ha, %{
|
||||||
|
status: "ratified",
|
||||||
|
auto_ratified: true,
|
||||||
|
partner_signed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond)
|
||||||
|
})
|
||||||
|
else
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Guildhall.PubSub,
|
||||||
|
"hosting:#{guild_schematic.schematic_name}",
|
||||||
|
{:hosting_proposed, %{hosting_agreement_id: ha.id}}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, ha}
|
||||||
|
end
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ratify(hosting_agreement, %{signer_did: signer_did, signature: signature}) do
|
||||||
|
partner_guild_id = hosting_agreement.partner_guild_id
|
||||||
|
masters = GuildMemberships.masters_for_guild(partner_guild_id)
|
||||||
|
is_master = Enum.any?(masters, fn m -> m.user_did == signer_did end)
|
||||||
|
|
||||||
|
if is_master do
|
||||||
|
case HostingAgreements.ratify(hosting_agreement, %{partner_signature: signature}) do
|
||||||
|
{:ok, ha} ->
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Guildhall.PubSub,
|
||||||
|
"hosting:#{hosting_agreement.guild_schematic_id}",
|
||||||
|
{:hosting_ratified, %{hosting_agreement_id: ha.id}}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, ha}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_partner_master}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp auto_ratify?(partner_guild, _guild_schematic) do
|
||||||
|
guildhouse_slug =
|
||||||
|
Application.get_env(:guildhall_orchestrator, :guildhouse_partner_slug, "guildhouse-ops")
|
||||||
|
|
||||||
|
partner_guild.slug == guildhouse_slug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -2,7 +2,8 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
|
||||||
use GenServer
|
use GenServer
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Guildhall.Orchestrator.SchematicClient
|
alias Guildhall.Orchestrator.RealizationStatus
|
||||||
|
alias Guildhall.OpsDb.GuildSchematics
|
||||||
|
|
||||||
@poll_interval_ms 5_000
|
@poll_interval_ms 5_000
|
||||||
|
|
||||||
|
|
@ -43,9 +44,11 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
|
||||||
def handle_info(:poll, state) do
|
def handle_info(:poll, state) do
|
||||||
new_watches =
|
new_watches =
|
||||||
Enum.reduce(state.watches, state.watches, fn {realization_id, guild_slug}, acc ->
|
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} ->
|
{:ok, response} ->
|
||||||
snapshot = build_snapshot(response)
|
snapshot = RealizationStatus.build_snapshot(response)
|
||||||
|
|
||||||
|
persist_snapshot(realization_id, snapshot)
|
||||||
|
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
Guildhall.PubSub,
|
Guildhall.PubSub,
|
||||||
|
|
@ -53,7 +56,7 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
|
||||||
{:realization_update, snapshot}
|
{:realization_update, snapshot}
|
||||||
)
|
)
|
||||||
|
|
||||||
if terminal_status?(response.overall_status) do
|
if RealizationStatus.terminal_status?(response.overall_status) do
|
||||||
Map.delete(acc, realization_id)
|
Map.delete(acc, realization_id)
|
||||||
else
|
else
|
||||||
acc
|
acc
|
||||||
|
|
@ -74,18 +77,14 @@ defmodule Guildhall.Orchestrator.RealizationPoller do
|
||||||
|
|
||||||
defp schedule_poll, do: Process.send_after(self(), :poll, @poll_interval_ms)
|
defp schedule_poll, do: Process.send_after(self(), :poll, @poll_interval_ms)
|
||||||
|
|
||||||
defp build_snapshot(response) do
|
defp persist_snapshot(realization_id, snapshot) do
|
||||||
%{
|
case Guildhall.OpsDb.Repo.get_by(Guildhall.OpsDb.GuildSchematic, realization_id: realization_id) do
|
||||||
overall_status: to_string(response.overall_status),
|
nil -> :ok
|
||||||
sections:
|
gs -> GuildSchematics.update_realization_snapshot(gs, snapshot)
|
||||||
Enum.map(response.per_section, fn s ->
|
end
|
||||||
%{name: s.section_name, status: s.status, message: s.message}
|
|
||||||
end)
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED), do: true
|
defp client do
|
||||||
defp terminal_status?(:FFC_SCHEMATIC_STATUS_ARCHIVED), do: true
|
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
|
||||||
defp terminal_status?(:FFC_SCHEMATIC_STATUS_WITHDRAWN), do: true
|
end
|
||||||
defp terminal_status?(_), do: false
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,95 +1,123 @@
|
||||||
defmodule Guildhall.Orchestrator.SchematicClient do
|
defmodule Guildhall.Orchestrator.SchematicClient do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias Schematic.V1.{
|
@behaviour Guildhall.Orchestrator.SchematicClient.Behaviour
|
||||||
SchematicsService.Stub,
|
|
||||||
ForkSchematicRequest,
|
|
||||||
TemplateOperation,
|
|
||||||
CreateDeploymentBindingRequest,
|
|
||||||
GetDeploymentBindingRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
alias Schematic.V1.RealizeFfcSchematicRequest
|
alias Schematic.V1.FfcSchematicService.Stub
|
||||||
alias Schematic.V1.GetRealizationStatusRequest
|
alias Schematic.V1.FfcSchematicFile
|
||||||
|
|
||||||
def fork_schematic(source_name, source_version, new_name, new_version, operations) do
|
@impl true
|
||||||
ops =
|
def create_ffc_schematic(name, version, manifest_yaml, files) do
|
||||||
Enum.map(operations, fn op ->
|
proto_files =
|
||||||
%TemplateOperation{
|
Enum.map(files, fn {path, content} ->
|
||||||
op_type: op[:op_type] || "replace",
|
%FfcSchematicFile{path: path, content: content}
|
||||||
path: op[:path],
|
|
||||||
content: op[:content] || ""
|
|
||||||
}
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
request = %ForkSchematicRequest{
|
request = %Schematic.V1.CreateFfcSchematicRequest{
|
||||||
source_name: source_name,
|
name: name,
|
||||||
source_version: source_version,
|
version: version,
|
||||||
new_name: new_name,
|
manifest_yaml: manifest_yaml,
|
||||||
new_version: new_version,
|
files: proto_files
|
||||||
operations: ops
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with {:ok, channel} <- connect_schematic(),
|
with {:ok, channel} <- connect(),
|
||||||
{:ok, response} <- Stub.fork_schematic(channel, request) do
|
{:ok, response} <- Stub.create_ffc_schematic(channel, request) do
|
||||||
GRPC.Stub.disconnect(channel)
|
GRPC.Stub.disconnect(channel)
|
||||||
{:ok, response}
|
{:ok, response}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_deployment_binding(schematic_name, version, pipeline_name, target_env) do
|
@impl true
|
||||||
request = %CreateDeploymentBindingRequest{
|
def validate_ffc_schematic(name, version) do
|
||||||
schematic_name: schematic_name,
|
request = %Schematic.V1.ValidateFfcSchematicRequest{
|
||||||
schematic_version: version,
|
name: name,
|
||||||
pipeline_name: pipeline_name,
|
version: version
|
||||||
target_env: target_env
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with {:ok, channel} <- connect_schematic(),
|
with {:ok, channel} <- connect(),
|
||||||
{:ok, response} <- Stub.create_deployment_binding(channel, request) do
|
{:ok, response} <- Stub.validate_ffc_schematic(channel, request) do
|
||||||
GRPC.Stub.disconnect(channel)
|
GRPC.Stub.disconnect(channel)
|
||||||
{:ok, response}
|
|
||||||
end
|
if response.valid do
|
||||||
end
|
{:ok, response}
|
||||||
|
else
|
||||||
def get_deployment_binding(binding_id) do
|
{:error, {:validation_failed, response.results}}
|
||||||
request = %GetDeploymentBindingRequest{binding_id: binding_id}
|
end
|
||||||
|
end
|
||||||
with {:ok, channel} <- connect_schematic(),
|
end
|
||||||
{:ok, response} <- Stub.get_deployment_binding(channel, request) do
|
|
||||||
|
@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)
|
GRPC.Stub.disconnect(channel)
|
||||||
{:ok, response}
|
{:ok, response}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def realize_ffc_schematic(name, version) do
|
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(),
|
with {:ok, channel} <- connect(),
|
||||||
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.realize_ffc_schematic(channel, request) do
|
{:ok, response} <- Stub.realize_ffc_schematic(channel, request) do
|
||||||
GRPC.Stub.disconnect(channel)
|
GRPC.Stub.disconnect(channel)
|
||||||
{:ok, response}
|
{:ok, response}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def get_realization_status(realization_id) do
|
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(),
|
with {:ok, channel} <- connect(),
|
||||||
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.get_realization_status(channel, request) do
|
{:ok, response} <- Stub.get_realization_status(channel, request) do
|
||||||
GRPC.Stub.disconnect(channel)
|
GRPC.Stub.disconnect(channel)
|
||||||
{:ok, response}
|
{:ok, response}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp connect_schematic do
|
@impl true
|
||||||
url =
|
def get_ffc_schematic(name, version) do
|
||||||
Application.get_env(:guildhall_orchestrator, :schematic_service_url, "localhost:9091")
|
request = %Schematic.V1.GetFfcSchematicRequest{name: name, version: version}
|
||||||
|
|
||||||
GRPC.Stub.connect(url)
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.get_ffc_schematic(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
{:ok, response}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp connect_ffc do
|
@impl true
|
||||||
|
def approve_ffc_schematic(attrs) when is_map(attrs) do
|
||||||
|
request = %Schematic.V1.ApproveFfcSchematicRequest{
|
||||||
|
name: attrs.schematic_name,
|
||||||
|
version: attrs.schematic_version,
|
||||||
|
signer_did: attrs.signer_did,
|
||||||
|
role: attrs.role,
|
||||||
|
signature: attrs.signature,
|
||||||
|
accord_hash: attrs.accord_hash,
|
||||||
|
key_ref: attrs.key_ref || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.approve_ffc_schematic(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
|
||||||
|
if response.accepted do
|
||||||
|
{:ok, response}
|
||||||
|
else
|
||||||
|
{:error, {:approval_rejected, response.message}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp connect do
|
||||||
url =
|
url =
|
||||||
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")
|
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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()}
|
||||||
|
|
||||||
|
@callback get_ffc_schematic(String.t(), String.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
|
||||||
|
@callback approve_ffc_schematic(map()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
end
|
||||||
|
|
@ -25,13 +25,11 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do
|
||||||
rendered_template
|
rendered_template
|
||||||
|> Map.drop(["meta"])
|
|> Map.drop(["meta"])
|
||||||
|> Enum.flat_map(fn {section, content} ->
|
|> Enum.flat_map(fn {section, content} ->
|
||||||
yaml = to_yaml_string(content)
|
|
||||||
|
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
op_type: "replace",
|
op_type: "replace",
|
||||||
path: "#{section}.yaml",
|
path: "#{section}.json",
|
||||||
content: yaml
|
content: Jason.encode!(content)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
end)
|
end)
|
||||||
|
|
@ -58,12 +56,6 @@ defmodule Guildhall.Orchestrator.SchematicTemplate do
|
||||||
|
|
||||||
defp deep_substitute(value, _params), do: value
|
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
|
defp template_dir do
|
||||||
Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates")
|
Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
defmodule Mix.Tasks.Guildhall.DevApprove do
|
||||||
|
@moduledoc "Generate a test Ed25519 signature and POST to the local approval webhook."
|
||||||
|
@shortdoc "Dev-only: approve a pending schematic with a test signature"
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def run([name, version | rest]) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
signer_did = List.first(rest) || "did:web:guildhouse.dev:user:tking"
|
||||||
|
|
||||||
|
{pub, priv} = :crypto.generate_key(:eddsa, :ed25519)
|
||||||
|
|
||||||
|
hash = :crypto.strong_rand_bytes(32)
|
||||||
|
accord_hash = :crypto.strong_rand_bytes(32)
|
||||||
|
|
||||||
|
signature = :crypto.sign(:eddsa, :none, hash, [priv, :ed25519])
|
||||||
|
|
||||||
|
secret = Application.get_env(:guildhall_orchestrator, :approval_webhook_secret, "dev-secret")
|
||||||
|
port = Application.get_env(:guildhall_web, GuildhallWeb.Endpoint)[:http][:port] || 4000
|
||||||
|
|
||||||
|
body =
|
||||||
|
Jason.encode!(%{
|
||||||
|
schematic_name: name,
|
||||||
|
schematic_version: version,
|
||||||
|
signer_did: signer_did,
|
||||||
|
signature: Base.encode64(signature),
|
||||||
|
accord_hash: Base.encode64(accord_hash),
|
||||||
|
key_ref: "dev-key-#{Base.encode16(:binary.part(pub, 0, 4), case: :lower)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
url = "http://localhost:#{port}/api/approvals"
|
||||||
|
|
||||||
|
Mix.shell().info("POSTing approval to #{url}")
|
||||||
|
Mix.shell().info("Signer: #{signer_did}")
|
||||||
|
Mix.shell().info("Public key: #{Base.encode16(pub, case: :lower)}")
|
||||||
|
|
||||||
|
case :httpc.request(
|
||||||
|
:post,
|
||||||
|
{~c"#{url}", [{~c"authorization", ~c"Bearer #{secret}"}, {~c"content-type", ~c"application/json"}], ~c"application/json", body},
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
) do
|
||||||
|
{:ok, {{_, status, _}, _headers, resp_body}} ->
|
||||||
|
Mix.shell().info("Response: #{status} #{resp_body}")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mix.shell().error("Request failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(_) do
|
||||||
|
Mix.shell().error("Usage: mix guildhall.dev_approve <schematic_name> <version> [signer_did]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
defmodule Mix.Tasks.Guildhall.DevRatifyHosting do
|
||||||
|
@moduledoc "Dev-only: ratify a hosting agreement with a test signature."
|
||||||
|
@shortdoc "Dev-only: ratify a hosting agreement"
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def run([id | rest]) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
signer_did = List.first(rest) || "did:web:guildhouse.dev:user:tking"
|
||||||
|
|
||||||
|
{_pub, priv} = :crypto.generate_key(:eddsa, :ed25519)
|
||||||
|
signature = :crypto.sign(:eddsa, :none, "ratify-#{id}", [priv, :ed25519])
|
||||||
|
|
||||||
|
secret = Application.get_env(:guildhall_orchestrator, :approval_webhook_secret, "dev-secret")
|
||||||
|
port = Application.get_env(:guildhall_web, GuildhallWeb.Endpoint)[:http][:port] || 4000
|
||||||
|
|
||||||
|
body =
|
||||||
|
Jason.encode!(%{
|
||||||
|
signer_did: signer_did,
|
||||||
|
signature: Base.encode64(signature)
|
||||||
|
})
|
||||||
|
|
||||||
|
url = "http://localhost:#{port}/api/hosting-agreements/#{id}/ratify"
|
||||||
|
|
||||||
|
Mix.shell().info("POSTing ratification to #{url}")
|
||||||
|
|
||||||
|
case :httpc.request(
|
||||||
|
:post,
|
||||||
|
{~c"#{url}", [{~c"authorization", ~c"Bearer #{secret}"}, {~c"content-type", ~c"application/json"}], ~c"application/json", body},
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
) do
|
||||||
|
{:ok, {{_, status, _}, _headers, resp_body}} ->
|
||||||
|
Mix.shell().info("Response: #{status} #{resp_body}")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mix.shell().error("Request failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(_) do
|
||||||
|
Mix.shell().error("Usage: mix guildhall.dev_ratify_hosting <agreement_id> [signer_did]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,10 +4,6 @@ description = "Independent Software Vendor founding schematic"
|
||||||
source_schematic = "guildhouse-isv-base"
|
source_schematic = "guildhouse-isv-base"
|
||||||
source_version = "1.0.0"
|
source_version = "1.0.0"
|
||||||
|
|
||||||
[consortium]
|
|
||||||
realm = "{{guild_slug}}"
|
|
||||||
description = "{{guild_name}} ISV Consortium"
|
|
||||||
|
|
||||||
[trust_domain]
|
[trust_domain]
|
||||||
spiffe_trust_domain = "{{trust_domain}}"
|
spiffe_trust_domain = "{{trust_domain}}"
|
||||||
attestation_tier = 1
|
attestation_tier = 1
|
||||||
|
|
@ -15,10 +11,18 @@ attestation_tier = 1
|
||||||
[identity_authority]
|
[identity_authority]
|
||||||
provider = "keycloak"
|
provider = "keycloak"
|
||||||
url = "https://auth.guildhouse.dev"
|
url = "https://auth.guildhouse.dev"
|
||||||
realm = "{{guild_slug}}"
|
realm = "guildhouse"
|
||||||
|
client_prefix = "{{guild_slug}}"
|
||||||
trust_level = "federated"
|
trust_level = "federated"
|
||||||
mfa_required = false
|
mfa_required = false
|
||||||
|
|
||||||
|
[members]
|
||||||
|
founding_master_did = "{{registrant_did}}"
|
||||||
|
initial_roles = ["master"]
|
||||||
|
|
||||||
|
[infrastructure]
|
||||||
|
compute_attestation_tier = 1
|
||||||
|
|
||||||
[ceremonies.code_change]
|
[ceremonies.code_change]
|
||||||
type = "single_approval"
|
type = "single_approval"
|
||||||
eligible_roles = ["master", "journeyman"]
|
eligible_roles = ["master", "journeyman"]
|
||||||
|
|
@ -29,8 +33,11 @@ type = "single_approval"
|
||||||
eligible_roles = ["master"]
|
eligible_roles = ["master"]
|
||||||
quorum = 1
|
quorum = 1
|
||||||
|
|
||||||
[members]
|
[federation_peers]
|
||||||
initial_roles = ["master"]
|
mode = "hub_spoke"
|
||||||
|
hub_trust_domain = "guildhouse.dev"
|
||||||
|
|
||||||
[infrastructure]
|
[attestation]
|
||||||
compute_attestation_tier = 1
|
tier = 1
|
||||||
|
require_tpm = false
|
||||||
|
require_secure_boot = false
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@ description = "Managed Service Provider founding schematic"
|
||||||
source_schematic = "guildhouse-msp-base"
|
source_schematic = "guildhouse-msp-base"
|
||||||
source_version = "1.0.0"
|
source_version = "1.0.0"
|
||||||
|
|
||||||
[consortium]
|
|
||||||
realm = "{{guild_slug}}"
|
|
||||||
description = "{{guild_name}} MSP Consortium"
|
|
||||||
|
|
||||||
[trust_domain]
|
[trust_domain]
|
||||||
spiffe_trust_domain = "{{trust_domain}}"
|
spiffe_trust_domain = "{{trust_domain}}"
|
||||||
attestation_tier = 2
|
attestation_tier = 2
|
||||||
|
|
@ -15,10 +11,18 @@ attestation_tier = 2
|
||||||
[identity_authority]
|
[identity_authority]
|
||||||
provider = "keycloak"
|
provider = "keycloak"
|
||||||
url = "https://auth.guildhouse.dev"
|
url = "https://auth.guildhouse.dev"
|
||||||
realm = "{{guild_slug}}"
|
realm = "guildhouse"
|
||||||
|
client_prefix = "{{guild_slug}}"
|
||||||
trust_level = "federated"
|
trust_level = "federated"
|
||||||
mfa_required = true
|
mfa_required = true
|
||||||
|
|
||||||
|
[members]
|
||||||
|
founding_master_did = "{{registrant_did}}"
|
||||||
|
initial_roles = ["master"]
|
||||||
|
|
||||||
|
[infrastructure]
|
||||||
|
compute_attestation_tier = 2
|
||||||
|
|
||||||
[ceremonies.code_change]
|
[ceremonies.code_change]
|
||||||
type = "single_approval"
|
type = "single_approval"
|
||||||
eligible_roles = ["master", "journeyman"]
|
eligible_roles = ["master", "journeyman"]
|
||||||
|
|
@ -29,8 +33,11 @@ type = "single_approval"
|
||||||
eligible_roles = ["master"]
|
eligible_roles = ["master"]
|
||||||
quorum = 1
|
quorum = 1
|
||||||
|
|
||||||
[members]
|
[federation_peers]
|
||||||
initial_roles = ["master"]
|
mode = "hub_spoke"
|
||||||
|
hub_trust_domain = "guildhouse.dev"
|
||||||
|
|
||||||
[infrastructure]
|
[attestation]
|
||||||
compute_attestation_tier = 2
|
tier = 2
|
||||||
|
require_tpm = false
|
||||||
|
require_secure_boot = false
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@ description = "Network Service Provider founding schematic"
|
||||||
source_schematic = "guildhouse-nsp-base"
|
source_schematic = "guildhouse-nsp-base"
|
||||||
source_version = "1.0.0"
|
source_version = "1.0.0"
|
||||||
|
|
||||||
[consortium]
|
|
||||||
realm = "{{guild_slug}}"
|
|
||||||
description = "{{guild_name}} NSP Consortium"
|
|
||||||
|
|
||||||
[trust_domain]
|
[trust_domain]
|
||||||
spiffe_trust_domain = "{{trust_domain}}"
|
spiffe_trust_domain = "{{trust_domain}}"
|
||||||
attestation_tier = 3
|
attestation_tier = 3
|
||||||
|
|
@ -15,11 +11,21 @@ attestation_tier = 3
|
||||||
[identity_authority]
|
[identity_authority]
|
||||||
provider = "keycloak"
|
provider = "keycloak"
|
||||||
url = "https://auth.guildhouse.dev"
|
url = "https://auth.guildhouse.dev"
|
||||||
realm = "{{guild_slug}}"
|
realm = "guildhouse"
|
||||||
|
client_prefix = "{{guild_slug}}"
|
||||||
trust_level = "federated"
|
trust_level = "federated"
|
||||||
mfa_required = true
|
mfa_required = true
|
||||||
hardware_credential_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]
|
[ceremonies.code_change]
|
||||||
type = "single_approval"
|
type = "single_approval"
|
||||||
eligible_roles = ["master", "journeyman"]
|
eligible_roles = ["master", "journeyman"]
|
||||||
|
|
@ -29,11 +35,13 @@ quorum = 1
|
||||||
type = "multi_party"
|
type = "multi_party"
|
||||||
eligible_roles = ["master"]
|
eligible_roles = ["master"]
|
||||||
quorum = 2
|
quorum = 2
|
||||||
|
founding_override = 1
|
||||||
|
|
||||||
[members]
|
[federation_peers]
|
||||||
initial_roles = ["master"]
|
mode = "mesh"
|
||||||
|
hub_trust_domain = "guildhouse.dev"
|
||||||
|
|
||||||
[infrastructure]
|
[attestation]
|
||||||
compute_attestation_tier = 3
|
tier = 3
|
||||||
wireguard_tunnel = true
|
require_tpm = true
|
||||||
vpp_dataplane = true
|
require_secure_boot = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
defmodule Guildhall.Orchestrator.ApprovalPayloadTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.ApprovalPayload
|
||||||
|
|
||||||
|
defp valid_payload do
|
||||||
|
%ApprovalPayload{
|
||||||
|
schematic_name: "msp-test-guild",
|
||||||
|
schematic_version: "1.0.0",
|
||||||
|
signer_did: "did:web:guildhouse.dev:user:tking",
|
||||||
|
signature: :crypto.strong_rand_bytes(64),
|
||||||
|
accord_hash: :crypto.strong_rand_bytes(32),
|
||||||
|
key_ref: "key-1"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate/1" do
|
||||||
|
test "valid payload passes" do
|
||||||
|
assert :ok = ApprovalPayload.validate(valid_payload())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "signature ≠ 64 bytes rejected" do
|
||||||
|
payload = %{valid_payload() | signature: :crypto.strong_rand_bytes(32)}
|
||||||
|
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
|
||||||
|
assert Enum.any?(errors, fn {field, _} -> field == :signature end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty signer_did rejected" do
|
||||||
|
payload = %{valid_payload() | signer_did: ""}
|
||||||
|
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
|
||||||
|
assert Enum.any?(errors, fn {field, _} -> field == :signer_did end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "signer_did without did: prefix rejected" do
|
||||||
|
payload = %{valid_payload() | signer_did: "web:guildhouse.dev:user:tking"}
|
||||||
|
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
|
||||||
|
assert Enum.any?(errors, fn {field, _} -> field == :signer_did end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty schematic_name rejected" do
|
||||||
|
payload = %{valid_payload() | schematic_name: ""}
|
||||||
|
assert {:error, {:validation_errors, errors}} = ApprovalPayload.validate(payload)
|
||||||
|
assert Enum.any?(errors, fn {field, _} -> field == :schematic_name end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hosting_agreement_id is optional" do
|
||||||
|
payload = %{valid_payload() | hosting_agreement_id: nil}
|
||||||
|
assert :ok = ApprovalPayload.validate(payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "default role is FFC_ROLE_FOUNDING_STAKEHOLDER" do
|
||||||
|
payload = valid_payload()
|
||||||
|
assert payload.role == :FFC_ROLE_FOUNDING_STAKEHOLDER
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule Guildhall.Orchestrator.FoundingOverrideGuardTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.FoundingOverrideGuard
|
||||||
|
|
||||||
|
defp schematic_with_override(opts \\ []) do
|
||||||
|
%{
|
||||||
|
customization_params: %{
|
||||||
|
"ceremonies.governance_change.founding_override" => 1
|
||||||
|
},
|
||||||
|
founding_override_expires_at: Keyword.get(opts, :expires_at, future_time()),
|
||||||
|
founding_override_revoked_at: Keyword.get(opts, :revoked_at, nil),
|
||||||
|
founding_override_revocation_reason: Keyword.get(opts, :revocation_reason, nil)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp future_time, do: DateTime.utc_now() |> DateTime.add(86_400, :second)
|
||||||
|
defp past_time, do: DateTime.utc_now() |> DateTime.add(-86_400, :second)
|
||||||
|
|
||||||
|
describe "check/1" do
|
||||||
|
test "active override (future expiry) → :ok" do
|
||||||
|
assert :ok = FoundingOverrideGuard.check(schematic_with_override())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "expired override → error" do
|
||||||
|
gs = schematic_with_override(expires_at: past_time())
|
||||||
|
assert {:error, :override_ttl_expired} = FoundingOverrideGuard.check(gs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revoked override → error with reason" do
|
||||||
|
gs =
|
||||||
|
schematic_with_override(
|
||||||
|
revoked_at: DateTime.utc_now(),
|
||||||
|
revocation_reason: "second_master_onboarded"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:error, {:override_revoked, "second_master_onboarded"}} =
|
||||||
|
FoundingOverrideGuard.check(gs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil expiry with overrides → fail-closed" do
|
||||||
|
gs = schematic_with_override(expires_at: nil)
|
||||||
|
assert {:error, :override_missing_expiry} = FoundingOverrideGuard.check(gs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no overrides → :ok" do
|
||||||
|
gs = %{customization_params: %{}}
|
||||||
|
assert :ok = FoundingOverrideGuard.check(gs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil expiry + empty overrides → :ok" do
|
||||||
|
gs = %{customization_params: %{}, founding_override_expires_at: nil}
|
||||||
|
assert :ok = FoundingOverrideGuard.check(gs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "missing customization_params → :ok" do
|
||||||
|
assert :ok = FoundingOverrideGuard.check(%{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "effective_quorum/2" do
|
||||||
|
test "returns override value when active" do
|
||||||
|
gs = schematic_with_override()
|
||||||
|
|
||||||
|
assert {:override, 1} =
|
||||||
|
FoundingOverrideGuard.effective_quorum(gs, "governance_change")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :no_override when expired" do
|
||||||
|
gs = schematic_with_override(expires_at: past_time())
|
||||||
|
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "governance_change")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :no_override when no matching ceremony key" do
|
||||||
|
gs = schematic_with_override()
|
||||||
|
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "code_change")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :no_override when no overrides" do
|
||||||
|
gs = %{customization_params: %{}}
|
||||||
|
assert :no_override = FoundingOverrideGuard.effective_quorum(gs, "governance_change")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
defmodule GuildhallWeb.ApprovalController do
|
||||||
|
use GuildhallWeb, :controller
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.{ApprovalCoordinator, ApprovalPayload}
|
||||||
|
|
||||||
|
def create(conn, params) do
|
||||||
|
with :ok <- verify_bearer(conn),
|
||||||
|
{:ok, payload} <- build_payload(params) do
|
||||||
|
case ApprovalCoordinator.receive_approval(payload) do
|
||||||
|
{:ok, _state} ->
|
||||||
|
conn |> put_status(202) |> json(%{status: "accepted"})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn |> put_status(422) |> json(%{error: inspect(reason)})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_bearer(conn) do
|
||||||
|
expected =
|
||||||
|
Application.get_env(:guildhall_orchestrator, :approval_webhook_secret)
|
||||||
|
|
||||||
|
case get_req_header(conn, "authorization") do
|
||||||
|
["Bearer " <> token] when token == expected and expected != nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> json(%{error: "unauthorized"})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_payload(params) do
|
||||||
|
with {:ok, signature} <- decode_base64(params["signature"], :signature),
|
||||||
|
{:ok, accord_hash} <- decode_base64(params["accord_hash"], :accord_hash) do
|
||||||
|
{:ok,
|
||||||
|
%ApprovalPayload{
|
||||||
|
schematic_name: params["schematic_name"] || "",
|
||||||
|
schematic_version: params["schematic_version"] || "",
|
||||||
|
signer_did: params["signer_did"] || "",
|
||||||
|
signature: signature,
|
||||||
|
accord_hash: accord_hash,
|
||||||
|
key_ref: params["key_ref"],
|
||||||
|
hosting_agreement_id: params["hosting_agreement_id"]
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_base64(nil, field), do: {:error, {field, "required"}}
|
||||||
|
|
||||||
|
defp decode_base64(value, _field) when is_binary(value) do
|
||||||
|
case Base.decode64(value) do
|
||||||
|
{:ok, bytes} -> {:ok, bytes}
|
||||||
|
:error -> {:error, {:base64, "invalid base64"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule GuildhallWeb.HostingAgreementController do
|
||||||
|
use GuildhallWeb, :controller
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.HostingAgreements
|
||||||
|
alias Guildhall.Orchestrator.HostingCeremony
|
||||||
|
|
||||||
|
def ratify(conn, %{"id" => id} = params) do
|
||||||
|
with :ok <- verify_bearer(conn) do
|
||||||
|
ha = HostingAgreements.get!(id)
|
||||||
|
|
||||||
|
with {:ok, signature} <- decode_base64(params["signature"]) do
|
||||||
|
case HostingCeremony.ratify(ha, %{
|
||||||
|
signer_did: params["signer_did"] || "",
|
||||||
|
signature: signature
|
||||||
|
}) do
|
||||||
|
{:ok, _ha} ->
|
||||||
|
conn |> put_status(200) |> json(%{status: "ratified"})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn |> put_status(422) |> json(%{error: inspect(reason)})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
conn |> put_status(422) |> json(%{error: inspect(reason)})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_bearer(conn) do
|
||||||
|
expected =
|
||||||
|
Application.get_env(:guildhall_orchestrator, :approval_webhook_secret)
|
||||||
|
|
||||||
|
case get_req_header(conn, "authorization") do
|
||||||
|
["Bearer " <> token] when token == expected and expected != nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
conn
|
||||||
|
|> put_status(401)
|
||||||
|
|> json(%{error: "unauthorized"})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_base64(nil), do: {:error, {:signature, "required"}}
|
||||||
|
|
||||||
|
defp decode_base64(value) when is_binary(value) do
|
||||||
|
case Base.decode64(value) do
|
||||||
|
{:ok, bytes} -> {:ok, bytes}
|
||||||
|
:error -> {:error, {:base64, "invalid base64"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
|
||||||
use GuildhallWeb, :live_view
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
|
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
|
||||||
|
alias Guildhall.Orchestrator.RealizationStatus
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug}, _session, socket) do
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
|
|
@ -41,25 +42,18 @@ defmodule GuildhallWeb.GuildLive.Realization do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp status_icon("succeeded"), do: "text-emerald-600"
|
defp status_icon(status), do: RealizationStatus.section_icon_class(status)
|
||||||
defp status_icon("in_progress"), do: "text-amber-600"
|
defp status_symbol(status), do: RealizationStatus.section_symbol(status)
|
||||||
defp status_icon("failed"), do: "text-red-600"
|
defp display_status(status), do: RealizationStatus.section_display_status(status)
|
||||||
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 overall_class(snapshot) do
|
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
|
defp overall_label(snapshot) do
|
||||||
String.contains?(to_string(overall), "REALIZED") -> "bg-emerald-100 text-emerald-700"
|
overall = RealizationStatus.derive_overall(snapshot)
|
||||||
String.contains?(to_string(overall), "PUBLISHED") -> "bg-amber-100 text-amber-700"
|
RealizationStatus.display_status(overall)
|
||||||
true -> "bg-zinc-100 text-zinc-700"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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}</.link>
|
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">← {@guild.name}</.link>
|
||||||
<h1 class="text-2xl font-semibold mt-2">Realization Dashboard</h1>
|
<h1 class="text-2xl font-semibold mt-2">Realization Dashboard</h1>
|
||||||
<div :if={@snapshot != %{}} class={"inline-block rounded-full px-3 py-1 text-xs font-medium mt-2 #{overall_class(@snapshot)}"}>
|
<div :if={@snapshot != %{}} class={"inline-block rounded-full px-3 py-1 text-xs font-medium mt-2 #{overall_class(@snapshot)}"}>
|
||||||
{Map.get(@snapshot, "overall_status", Map.get(@snapshot, :overall_status, "pending"))}
|
{overall_label(@snapshot)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -93,7 +87,7 @@ defmodule GuildhallWeb.GuildLive.Realization do
|
||||||
<div class="text-sm font-medium">{String.replace(name, "_", " ") |> String.capitalize()}</div>
|
<div class="text-sm font-medium">{String.replace(name, "_", " ") |> String.capitalize()}</div>
|
||||||
<div class="text-xs text-zinc-500">{s.message}</div>
|
<div class="text-xs text-zinc-500">{s.message}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class={"text-xs #{status_icon(s.status)}"}>{s.status}</span>
|
<span class={"text-xs #{status_icon(s.status)}"}>{display_status(s.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
use GuildhallWeb, :live_view
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
|
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
|
||||||
alias Guildhall.Orchestrator.{SchematicClient, SchematicTemplate, RealizationPoller}
|
alias Guildhall.Orchestrator.{FfcPipeline, SchematicTemplate}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug}, _session, socket) do
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
|
|
@ -16,7 +16,7 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
else
|
else
|
||||||
existing = GuildSchematics.get_for_guild(guild.id)
|
existing = GuildSchematics.get_for_guild(guild.id)
|
||||||
|
|
||||||
if existing do
|
if existing && existing.status not in ["draft", "awaiting_approval"] do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, "Schematic already deployed.")
|
|> put_flash(:info, "Schematic already deployed.")
|
||||||
|
|
@ -24,6 +24,12 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
else
|
else
|
||||||
template_result = SchematicTemplate.load_template(guild.guild_type)
|
template_result = SchematicTemplate.load_template(guild.guild_type)
|
||||||
|
|
||||||
|
awaiting = existing && existing.status == "awaiting_approval"
|
||||||
|
|
||||||
|
if awaiting do
|
||||||
|
Phoenix.PubSub.subscribe(Guildhall.PubSub, "approval:#{slug}")
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "#{guild.name} — Deploy Schematic")
|
|> assign(:page_title, "#{guild.name} — Deploy Schematic")
|
||||||
|
|
@ -31,7 +37,11 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
|> assign(:template, elem_ok(template_result))
|
|> assign(:template, elem_ok(template_result))
|
||||||
|> assign(:template_error, elem_error(template_result))
|
|> assign(:template_error, elem_error(template_result))
|
||||||
|> assign(:deploying, false)
|
|> assign(:deploying, false)
|
||||||
|> assign(:deploy_error, nil)}
|
|> assign(:deploy_error, nil)
|
||||||
|
|> assign(:awaiting_approval, awaiting)
|
||||||
|
|> assign(:schematic_hash, nil)
|
||||||
|
|> assign(:guild_schematic, existing)
|
||||||
|
|> assign(:bootstrap_mode, (existing && existing.bootstrap_mode) || "self_sovereign")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -39,59 +49,44 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("deploy_schematic", _params, socket) do
|
def handle_event("deploy_schematic", _params, socket) do
|
||||||
guild = socket.assigns.guild
|
guild = socket.assigns.guild
|
||||||
template = socket.assigns.template
|
|
||||||
socket = assign(socket, :deploying, true)
|
socket = assign(socket, :deploying, true)
|
||||||
|
|
||||||
params = %{
|
case FfcPipeline.deploy(guild) do
|
||||||
"guild_slug" => guild.slug,
|
{:paused, state} ->
|
||||||
"guild_name" => guild.name,
|
hash_hex =
|
||||||
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
|
if state.schematic_hash,
|
||||||
"registrant_did" => guild.registrant_did
|
do: Base.encode16(state.schematic_hash, case: :lower),
|
||||||
}
|
else: "unavailable"
|
||||||
|
|
||||||
rendered = SchematicTemplate.render_template(template, params)
|
Phoenix.PubSub.subscribe(Guildhall.PubSub, "approval:#{guild.slug}")
|
||||||
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"
|
|
||||||
|
|
||||||
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} ->
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:deploying, false)
|
|> assign(:deploying, false)
|
||||||
|> assign(:deploy_error, inspect(reason))}
|
|> assign(:awaiting_approval, true)
|
||||||
|
|> assign(:schematic_hash, hash_hex)
|
||||||
|
|> assign(:guild_schematic, state.guild_schematic)
|
||||||
|
|> assign(:bootstrap_mode, state.bootstrap_mode)
|
||||||
|
|> put_flash(:info, "Schematic created. Awaiting approval signature.")}
|
||||||
|
|
||||||
|
{:error, {step, reason}} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:deploying, false)
|
||||||
|
|> assign(:deploy_error, "Step #{step} failed: #{inspect(reason)}")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:approval_complete, _payload}, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Schematic approved. Realization in progress.")
|
||||||
|
|> push_navigate(to: ~p"/guilds/#{guild.slug}/realization")}
|
||||||
|
end
|
||||||
|
|
||||||
defp elem_ok({:ok, val}), do: val
|
defp elem_ok({:ok, val}), do: val
|
||||||
defp elem_ok(_), do: nil
|
defp elem_ok(_), do: nil
|
||||||
|
|
||||||
|
|
@ -111,7 +106,32 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
Failed to load template: {@template_error}
|
Failed to load template: {@template_error}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section :if={@template} class="space-y-4">
|
<section :if={@awaiting_approval} class="space-y-4">
|
||||||
|
<div class="border border-amber-200 bg-amber-50 rounded p-4 text-sm space-y-3">
|
||||||
|
<p class="font-semibold text-amber-800">Awaiting Approval Signature</p>
|
||||||
|
<p class="text-amber-700">
|
||||||
|
The founding schematic has been created and validated.
|
||||||
|
A founding stakeholder must sign it to proceed.
|
||||||
|
</p>
|
||||||
|
<div :if={@schematic_hash} class="space-y-1">
|
||||||
|
<p class="text-xs text-amber-600 font-semibold">Schematic hash (sign this):</p>
|
||||||
|
<code class="block p-2 bg-white rounded border border-amber-200 text-xs font-mono break-all">
|
||||||
|
{@schematic_hash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-amber-600 mt-2">
|
||||||
|
<p :if={@bootstrap_mode == "self_sovereign"}>
|
||||||
|
Sign from your governed shell:
|
||||||
|
<code class="bg-white px-1 rounded">gsh orgops sign-schematic --hash {@schematic_hash || "..."}</code>
|
||||||
|
</p>
|
||||||
|
<p :if={@bootstrap_mode == "partner_hosted"}>
|
||||||
|
Your hosting partner will provide a signing context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section :if={@template && !@awaiting_approval} class="space-y-4">
|
||||||
<div class="text-sm space-y-2">
|
<div class="text-sm space-y-2">
|
||||||
<p>
|
<p>
|
||||||
Template: <span class="font-mono font-semibold">{@guild.guild_type}-founding</span>
|
Template: <span class="font-mono font-semibold">{@guild.guild_type}-founding</span>
|
||||||
|
|
@ -124,10 +144,10 @@ defmodule GuildhallWeb.GuildLive.Schematic do
|
||||||
<div class="text-sm text-zinc-500 space-y-1">
|
<div class="text-sm text-zinc-500 space-y-1">
|
||||||
<p>This will:</p>
|
<p>This will:</p>
|
||||||
<ol class="list-decimal ml-5 space-y-1">
|
<ol class="list-decimal ml-5 space-y-1">
|
||||||
<li>Fork the founding schematic template for {@guild.name}</li>
|
<li>Validate and resolve the founding schematic template</li>
|
||||||
<li>Create a deployment binding (production)</li>
|
<li>Create and validate the FFC schematic on the server</li>
|
||||||
<li>Trigger realization across all reconciler sections</li>
|
<li>Pause for founding stakeholder approval signature</li>
|
||||||
<li>Transition guild to <span class="font-semibold">active</span> status</li>
|
<li>After approval: publish, realize, and transition guild to <span class="font-semibold">active</span></li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,21 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
schematic = GuildSchematics.get_for_guild(guild.id)
|
||||||
|
is_founding_master = socket.assigns.current_user["did"] == guild.registrant_did
|
||||||
|
|
||||||
|
if connected?(socket) && schematic do
|
||||||
|
Phoenix.PubSub.subscribe(Guildhall.PubSub, "override:#{slug}")
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, guild.name)
|
|> assign(:page_title, guild.name)
|
||||||
|> assign(:guild, guild)
|
|> assign(:guild, guild)
|
||||||
|> assign(:ceremony_status, nil)
|
|> assign(:ceremony_status, nil)
|
||||||
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)
|
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)
|
||||||
|> assign(:schematic, GuildSchematics.get_for_guild(guild.id))
|
|> assign(:is_founding_master, is_founding_master)
|
||||||
|
|> assign(:schematic, schematic)
|
||||||
|> assign(:member_count, length(GuildMemberships.active_members(guild.id)))}
|
|> assign(:member_count, length(GuildMemberships.active_members(guild.id)))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -66,6 +74,17 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
{:noreply, assign(socket, :guild, guild)}
|
{:noreply, assign(socket, :guild, guild)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:founding_override_revoked, _payload}, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
schematic = GuildSchematics.get_for_guild(guild.id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:schematic, schematic)
|
||||||
|
|> put_flash(:info, "Founding override has been revoked.")}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
|
@ -119,6 +138,31 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("revoke_override", _params, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
|
||||||
|
case GuildSchematics.revoke_founding_override(guild.id, "manual_revocation") do
|
||||||
|
{:ok, _} ->
|
||||||
|
schematic = GuildSchematics.get_for_guild(guild.id)
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Guildhall.PubSub,
|
||||||
|
"override:#{guild.slug}",
|
||||||
|
{:founding_override_revoked,
|
||||||
|
%{guild_id: guild.id, reason: "manual_revocation"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:schematic, schematic)
|
||||||
|
|> put_flash(:info, "Founding override revoked.")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Revocation failed: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp status_class("pending_approval"), do: "bg-amber-100 text-amber-700"
|
defp status_class("pending_approval"), do: "bg-amber-100 text-amber-700"
|
||||||
defp status_class("approved"), do: "bg-emerald-100 text-emerald-700"
|
defp status_class("approved"), do: "bg-emerald-100 text-emerald-700"
|
||||||
defp status_class("active"), do: "bg-emerald-100 text-emerald-700"
|
defp status_class("active"), do: "bg-emerald-100 text-emerald-700"
|
||||||
|
|
@ -128,9 +172,31 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
|
|
||||||
defp schematic_status_class("realized"), do: "text-emerald-600"
|
defp schematic_status_class("realized"), do: "text-emerald-600"
|
||||||
defp schematic_status_class("realizing"), do: "text-amber-600"
|
defp schematic_status_class("realizing"), do: "text-amber-600"
|
||||||
|
defp schematic_status_class("awaiting_approval"), do: "text-amber-600"
|
||||||
defp schematic_status_class("failed"), do: "text-red-600"
|
defp schematic_status_class("failed"), do: "text-red-600"
|
||||||
defp schematic_status_class(_), do: "text-zinc-500"
|
defp schematic_status_class(_), do: "text-zinc-500"
|
||||||
|
|
||||||
|
defp override_days_remaining(schematic) do
|
||||||
|
if schematic && schematic.founding_override_expires_at do
|
||||||
|
diff = DateTime.diff(schematic.founding_override_expires_at, DateTime.utc_now(), :second)
|
||||||
|
max(0, div(diff, 86_400))
|
||||||
|
else
|
||||||
|
0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp override_status(schematic) do
|
||||||
|
cond do
|
||||||
|
is_nil(schematic) -> nil
|
||||||
|
!is_map(schematic.customization_params) -> nil
|
||||||
|
map_size(schematic.customization_params) == 0 -> nil
|
||||||
|
schematic.founding_override_revoked_at -> {:revoked, schematic.founding_override_revocation_reason}
|
||||||
|
is_nil(schematic.founding_override_expires_at) -> nil
|
||||||
|
DateTime.compare(DateTime.utc_now(), schematic.founding_override_expires_at) == :gt -> :expired
|
||||||
|
true -> {:active, override_days_remaining(schematic)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp type_label("msp"), do: "Managed Service Provider"
|
defp type_label("msp"), do: "Managed Service Provider"
|
||||||
defp type_label("isv"), do: "Independent Software Vendor"
|
defp type_label("isv"), do: "Independent Software Vendor"
|
||||||
defp type_label("nsp"), do: "Network Service Provider"
|
defp type_label("nsp"), do: "Network Service Provider"
|
||||||
|
|
@ -201,10 +267,10 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 text-sm">
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
<.link :if={@schematic == nil} navigate={~p"/guilds/#{@guild.slug}/schematic"} class="rounded bg-blue-600 px-3 py-1.5 text-white">
|
<.link :if={@schematic == nil || @schematic.status in ["draft", "awaiting_approval"]} navigate={~p"/guilds/#{@guild.slug}/schematic"} class="rounded bg-blue-600 px-3 py-1.5 text-white">
|
||||||
Deploy Schematic
|
{if @schematic && @schematic.status == "awaiting_approval", do: "Approve Schematic", else: "Deploy Schematic"}
|
||||||
</.link>
|
</.link>
|
||||||
<.link :if={@schematic} navigate={~p"/guilds/#{@guild.slug}/realization"} class="rounded bg-zinc-700 px-3 py-1.5 text-white">
|
<.link :if={@schematic && @schematic.status not in ["draft", "awaiting_approval"]} navigate={~p"/guilds/#{@guild.slug}/realization"} class="rounded bg-zinc-700 px-3 py-1.5 text-white">
|
||||||
Realization Dashboard
|
Realization Dashboard
|
||||||
</.link>
|
</.link>
|
||||||
<.link navigate={~p"/guilds/#{@guild.slug}/join"} class="rounded border border-blue-600 px-3 py-1.5 text-blue-600">
|
<.link navigate={~p"/guilds/#{@guild.slug}/join"} class="rounded border border-blue-600 px-3 py-1.5 text-blue-600">
|
||||||
|
|
@ -215,10 +281,53 @@ defmodule GuildhallWeb.GuildLive.Show do
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@schematic} class="rounded border border-zinc-200 p-3 text-sm">
|
<div :if={@schematic} class="rounded border border-zinc-200 p-3 text-sm space-y-2">
|
||||||
<div class="text-zinc-500 text-xs mb-1">Schematic</div>
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono">{@schematic.schematic_name} v{@schematic.schematic_version}</span>
|
<div>
|
||||||
<span class={"ml-2 text-xs #{schematic_status_class(@schematic.status)}"}>{@schematic.status}</span>
|
<div class="text-zinc-500 text-xs mb-1">Schematic</div>
|
||||||
|
<span class="font-mono">{@schematic.schematic_name} v{@schematic.schematic_version}</span>
|
||||||
|
<span class={"ml-2 text-xs #{schematic_status_class(@schematic.status)}"}>{@schematic.status}</span>
|
||||||
|
</div>
|
||||||
|
<span :if={@schematic.bootstrap_mode == "partner_hosted"} class="text-xs bg-sky-100 text-sky-700 rounded-full px-2 py-0.5">
|
||||||
|
partner-hosted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= case override_status(@schematic) do %>
|
||||||
|
<% {:active, days} -> %>
|
||||||
|
<div class="flex items-center justify-between border-t border-zinc-100 pt-2">
|
||||||
|
<span class="text-xs bg-emerald-100 text-emerald-700 rounded-full px-2 py-0.5">
|
||||||
|
Override active ({days} days remaining)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
:if={@is_founding_master}
|
||||||
|
phx-click="revoke_override"
|
||||||
|
data-confirm="Revoke founding override? This cannot be undone."
|
||||||
|
class="text-xs text-red-600 underline"
|
||||||
|
>
|
||||||
|
Revoke Override
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% {:revoked, reason} -> %>
|
||||||
|
<div class="border-t border-zinc-100 pt-2">
|
||||||
|
<span class="text-xs bg-zinc-100 text-zinc-600 rounded-full px-2 py-0.5">
|
||||||
|
Override revoked ({reason || "unknown"})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% :expired -> %>
|
||||||
|
<div class="border-t border-zinc-100 pt-2">
|
||||||
|
<span class="text-xs bg-red-100 text-red-700 rounded-full px-2 py-0.5">
|
||||||
|
Override expired
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% _ -> %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@schematic && @schematic.status == "awaiting_approval"} class="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||||
|
<.link navigate={~p"/guilds/#{@guild.slug}/schematic"} class="underline">
|
||||||
|
Awaiting founding stakeholder signature
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ defmodule GuildhallWeb.Router do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Machine-to-machine API endpoints (bearer token auth)
|
||||||
|
scope "/api", GuildhallWeb do
|
||||||
|
pipe_through :api
|
||||||
|
post "/approvals", ApprovalController, :create
|
||||||
|
post "/hosting-agreements/:id/ratify", HostingAgreementController, :ratify
|
||||||
|
end
|
||||||
|
|
||||||
# Health check endpoint for Kubernetes probes + LB targets.
|
# Health check endpoint for Kubernetes probes + LB targets.
|
||||||
scope "/health", GuildhallWeb do
|
scope "/health", GuildhallWeb do
|
||||||
pipe_through :api
|
pipe_through :api
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,8 @@ config :guildhall_web, :oidc,
|
||||||
config :guildhall_orchestrator,
|
config :guildhall_orchestrator,
|
||||||
ceremony_service_url: "localhost:50053",
|
ceremony_service_url: "localhost:50053",
|
||||||
schematic_service_url: "localhost:9091",
|
schematic_service_url: "localhost:9091",
|
||||||
ffc_schematic_service_url: "localhost:9091"
|
ffc_schematic_service_url: "localhost:9091",
|
||||||
|
founding_override_sweep_interval_ms: 3_600_000,
|
||||||
|
approval_webhook_secret: "dev-secret",
|
||||||
|
guildhouse_partner_slug: "guildhouse-ops",
|
||||||
|
guildhouse_auto_ratify_tiers: ["nsp"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue