feat(guildhall): minimum viable guildhall — OIDC, guilds, schematics, members

Implements the full founding-guild onboarding stack across four phases:

Phase A — Keycloak OIDC auth pipeline (oidcc) + guild registration with
ceremony-engine approval (SingleApproval, hub operator approves via gRPC).
Phase B — Founding schematic templates (MSP/ISV/NSP TOML), gRPC clients
for ceremony-service and ffc-schematic-server, schematic fork/bind/realize
LiveView with DB audit trail in guild_schematics.
Phase C — RealizationPoller GenServer polling realization status every 5s,
PubSub broadcast, live realization dashboard showing 7 reconciler sections.
Phase D — Self-service member onboarding (join request → guild master
approval via ceremony), member management LiveView, auto-create guild
master on guild approval via Ecto.Multi transaction.

Includes K8s manifests for ceremony-service (port 50053) and
ffc-schematic-server (port 9091) as ClusterIP services, plus updated
guildhall deployment with OIDC and gRPC service URL env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-05-15 15:03:50 -04:00
parent 7730bf3818
commit c0959a5376
44 changed files with 4234 additions and 30 deletions

View file

@ -0,0 +1,57 @@
defmodule Guildhall.OpsDb.Guild do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
@min_guild_id 0x010
@max_guild_id 0x3FF
schema "guilds" do
field :guild_id, :integer
field :name, :string
field :slug, :string
field :guild_type, :string
field :description, :string
field :trust_domain, :string
field :contact_did, :string
field :registrant_did, :string
field :registration_ceremony_id, :string
field :status, :string, default: "pending_approval"
field :enrollment_accord_ref, :string
field :metadata, :map, default: %{}
has_many :guild_schematics, Guildhall.OpsDb.GuildSchematic
has_many :guild_memberships, Guildhall.OpsDb.GuildMembership
timestamps(type: :utc_datetime_usec)
end
def changeset(guild, attrs) do
guild
|> cast(attrs, [
:guild_id,
:name,
:slug,
:guild_type,
:description,
:trust_domain,
:contact_did,
:registrant_did,
:registration_ceremony_id,
:status,
:enrollment_accord_ref,
:metadata
])
|> validate_required([:guild_id, :name, :slug, :guild_type, :contact_did, :registrant_did])
|> validate_inclusion(:guild_type, ~w(msp isv nsp))
|> validate_inclusion(:status, ~w(pending_approval approved denied active suspended))
|> validate_number(:guild_id,
greater_than_or_equal_to: @min_guild_id,
less_than_or_equal_to: @max_guild_id
)
|> unique_constraint(:guild_id)
|> unique_constraint(:slug)
|> unique_constraint(:name)
end
end

View file

@ -0,0 +1,45 @@
defmodule Guildhall.OpsDb.GuildMembership do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "guild_memberships" do
belongs_to :guild, Guildhall.OpsDb.Guild
field :user_did, :string
field :user_email, :string
field :display_name, :string
field :keycloak_sub, :string
field :role, :string, default: "apprentice"
field :status, :string, default: "pending"
field :membership_ceremony_id, :string
field :approved_by_did, :string
field :approved_at, :utc_datetime_usec
field :metadata, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
def changeset(membership, attrs) do
membership
|> cast(attrs, [
:guild_id,
:user_did,
:user_email,
:display_name,
:keycloak_sub,
:role,
:status,
:membership_ceremony_id,
:approved_by_did,
:approved_at,
:metadata
])
|> validate_required([:guild_id, :user_did, :user_email, :keycloak_sub])
|> validate_inclusion(:role, ~w(apprentice journeyman master))
|> validate_inclusion(:status, ~w(pending approved denied active suspended removed))
|> foreign_key_constraint(:guild_id)
|> unique_constraint([:guild_id, :user_did])
end
end

View file

@ -0,0 +1,58 @@
defmodule Guildhall.OpsDb.GuildMemberships do
import Ecto.Query, only: [from: 2]
alias Guildhall.OpsDb.{Repo, GuildMembership}
def list_memberships(guild_id) do
Repo.all(from(m in GuildMembership, where: m.guild_id == ^guild_id, order_by: [asc: m.inserted_at]))
end
def get_membership!(id), do: Repo.get!(GuildMembership, id)
def active_members(guild_id) do
Repo.all(from(m in GuildMembership, where: m.guild_id == ^guild_id and m.status == "active"))
end
def masters_for_guild(guild_id) do
Repo.all(
from(m in GuildMembership,
where: m.guild_id == ^guild_id and m.role == "master" and m.status == "active"
)
)
end
def find_membership(guild_id, user_did) do
Repo.one(from(m in GuildMembership, where: m.guild_id == ^guild_id and m.user_did == ^user_did))
end
def request_membership(attrs) do
%GuildMembership{}
|> GuildMembership.changeset(attrs)
|> Repo.insert()
end
def approve_membership(%GuildMembership{} = membership, approver_did) do
membership
|> GuildMembership.changeset(%{
status: "active",
approved_by_did: approver_did,
approved_at: DateTime.utc_now()
})
|> Repo.update()
end
def deny_membership(%GuildMembership{} = membership, approver_did) do
membership
|> GuildMembership.changeset(%{
status: "denied",
approved_by_did: approver_did,
approved_at: DateTime.utc_now()
})
|> Repo.update()
end
def update_role(%GuildMembership{} = membership, new_role) do
membership
|> GuildMembership.changeset(%{role: new_role})
|> Repo.update()
end
end

View file

@ -0,0 +1,42 @@
defmodule Guildhall.OpsDb.GuildSchematic do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "guild_schematics" do
belongs_to :guild, Guildhall.OpsDb.Guild
field :template_name, :string
field :schematic_name, :string
field :schematic_version, :string
field :tree_hash, :string
field :binding_id, :string
field :realization_id, :string
field :status, :string, default: "pending"
field :customization_params, :map, default: %{}
field :realization_snapshot, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
def changeset(schematic, attrs) do
schematic
|> cast(attrs, [
:guild_id,
:template_name,
:schematic_name,
:schematic_version,
:tree_hash,
:binding_id,
:realization_id,
:status,
:customization_params,
:realization_snapshot
])
|> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version])
|> validate_inclusion(:status, ~w(pending forked binding_created realizing realized partially_realized failed))
|> foreign_key_constraint(:guild_id)
|> unique_constraint([:schematic_name, :schematic_version])
end
end

View file

@ -0,0 +1,30 @@
defmodule Guildhall.OpsDb.GuildSchematics do
import Ecto.Query, only: [from: 2]
alias Guildhall.OpsDb.{Repo, GuildSchematic}
def get_for_guild(guild_id) do
Repo.one(
from(gs in GuildSchematic,
where: gs.guild_id == ^guild_id,
order_by: [desc: gs.inserted_at],
limit: 1
)
)
end
def create(attrs) do
%GuildSchematic{}
|> GuildSchematic.changeset(attrs)
|> Repo.insert()
end
def update_schematic(%GuildSchematic{} = gs, attrs) do
gs
|> GuildSchematic.changeset(attrs)
|> Repo.update()
end
def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do
update_schematic(gs, %{realization_snapshot: snapshot})
end
end

View file

@ -0,0 +1,63 @@
defmodule Guildhall.OpsDb.Guilds do
import Ecto.Query
alias Guildhall.OpsDb.{Repo, Guild, GuildMembership}
alias Ecto.Multi
def list_guilds(filters \\ []) do
Guild
|> maybe_filter_status(filters[:status])
|> order_by([g], desc: g.inserted_at)
|> Repo.all()
end
def get_guild!(id), do: Repo.get!(Guild, id)
def get_guild_by_slug(slug), do: Repo.get_by(Guild, slug: slug)
def create_guild(attrs) do
%Guild{}
|> Guild.changeset(attrs)
|> Repo.insert()
end
def update_guild(%Guild{} = guild, attrs) do
guild
|> Guild.changeset(attrs)
|> Repo.update()
end
def next_guild_id do
case Repo.one(from(g in Guild, select: max(g.guild_id))) do
nil -> 0x010
max_id -> max_id + 1
end
end
def approve_guild(%Guild{} = guild) do
now = DateTime.utc_now()
Multi.new()
|> Multi.update(:guild, Guild.changeset(guild, %{status: "approved"}))
|> Multi.insert(:master_membership, GuildMembership.changeset(%GuildMembership{}, %{
guild_id: guild.id,
user_did: guild.registrant_did,
user_email: guild.contact_did,
display_name: "Guild Registrant",
keycloak_sub: "",
role: "master",
status: "active",
approved_by_did: "system",
approved_at: now
}), on_conflict: :nothing, conflict_target: [:guild_id, :user_did])
|> Repo.transaction()
end
def guild_count(status \\ nil) do
Guild
|> maybe_filter_status(status)
|> Repo.aggregate(:count)
end
defp maybe_filter_status(query, nil), do: query
defp maybe_filter_status(query, status), do: where(query, [g], g.status == ^status)
end

View file

@ -0,0 +1,34 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuilds do
use Ecto.Migration
def change do
create table(:guilds) do
add :guild_id, :integer, null: false
add :name, :string, null: false
add :slug, :string, null: false
add :guild_type, :string, null: false
add :description, :text
add :trust_domain, :string
add :contact_did, :string, null: false
add :registrant_did, :string, null: false
add :registration_ceremony_id, :string
add :status, :string, null: false, default: "pending_approval"
add :enrollment_accord_ref, :string
add :metadata, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
create unique_index(:guilds, [:guild_id])
create unique_index(:guilds, [:slug])
create unique_index(:guilds, [:name])
create index(:guilds, [:status])
create index(:guilds, [:guild_type])
create index(:guilds, [:registrant_did])
execute(
"ALTER TABLE guilds ADD CONSTRAINT guilds_guild_id_range CHECK (guild_id >= 16 AND guild_id <= 1023)",
"ALTER TABLE guilds DROP CONSTRAINT guilds_guild_id_range"
)
end
end

View file

@ -0,0 +1,24 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuildSchematics do
use Ecto.Migration
def change do
create table(:guild_schematics) do
add :guild_id, references(:guilds, on_delete: :restrict), null: false
add :template_name, :string, null: false
add :schematic_name, :string, null: false
add :schematic_version, :string, null: false
add :tree_hash, :string
add :binding_id, :string
add :realization_id, :string
add :status, :string, null: false, default: "pending"
add :customization_params, :map, default: %{}
add :realization_snapshot, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
create index(:guild_schematics, [:guild_id])
create index(:guild_schematics, [:status])
create unique_index(:guild_schematics, [:schematic_name, :schematic_version])
end
end

View file

@ -0,0 +1,27 @@
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuildMemberships do
use Ecto.Migration
def change do
create table(:guild_memberships) do
add :guild_id, references(:guilds, on_delete: :restrict), null: false
add :user_did, :string, null: false
add :user_email, :string, null: false
add :display_name, :string
add :keycloak_sub, :string, null: false
add :role, :string, null: false, default: "apprentice"
add :status, :string, null: false, default: "pending"
add :membership_ceremony_id, :string
add :approved_by_did, :string
add :approved_at, :utc_datetime_usec
add :metadata, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
create unique_index(:guild_memberships, [:guild_id, :user_did])
create index(:guild_memberships, [:guild_id])
create index(:guild_memberships, [:user_did])
create index(:guild_memberships, [:status])
create index(:guild_memberships, [:keycloak_sub])
end
end

View file

@ -5,7 +5,8 @@ defmodule Guildhall.Orchestrator.Application do
@impl true
def start(_type, _args) do
children = [
Guildhall.Orchestrator.CeremonyOrchestrator
Guildhall.Orchestrator.CeremonyOrchestrator,
Guildhall.Orchestrator.RealizationPoller
]
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]

View file

@ -0,0 +1,109 @@
defmodule Guildhall.Orchestrator.CeremonyClient do
@moduledoc false
alias Ceremony.V1.{
CeremonyService.Stub,
CreateCeremonyRequest,
ApproveCeremonyRequest,
DenyCeremonyRequest,
GetCeremonyRequest,
ListPendingCeremoniesRequest,
CeremonySubjectMsg
}
def create_guild_registration_ceremony(guild_name, registrant_did, approver_did) do
request = %CreateCeremonyRequest{
ceremony_type: "single_approval",
subject: %CeremonySubjectMsg{
subject_type: "custom",
reference_id: guild_name,
description: "Guild registration: #{guild_name}",
metadata: %{"registrant_did" => registrant_did, "approver_did" => approver_did}
},
required_approvals: 1,
approver_roles: ["hub_operator"],
ttl_hours: 168
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.create_ceremony(channel, request) do
GRPC.Stub.disconnect(channel)
if response.error != "" do
{:error, response.error}
else
{:ok, response}
end
end
end
def approve_ceremony(ceremony_id, approver_did, role \\ "hub_operator") do
request = %ApproveCeremonyRequest{
ceremony_id: ceremony_id,
approver_identity: approver_did,
approver_role: role,
comment: ""
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.approve_ceremony(channel, request) do
GRPC.Stub.disconnect(channel)
if response.error != "" do
{:error, response.error}
else
{:ok, response}
end
end
end
def deny_ceremony(ceremony_id, approver_did, role, comment) do
request = %DenyCeremonyRequest{
ceremony_id: ceremony_id,
approver_identity: approver_did,
approver_role: role,
comment: comment
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.deny_ceremony(channel, request) do
GRPC.Stub.disconnect(channel)
if response.error != "" do
{:error, response.error}
else
{:ok, response}
end
end
end
def get_ceremony(ceremony_id) do
request = %GetCeremonyRequest{ceremony_id: ceremony_id}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.get_ceremony(channel, request) do
GRPC.Stub.disconnect(channel)
if response.error != "" do
{:error, response.error}
else
{:ok, response}
end
end
end
def list_pending_ceremonies(intent_id \\ "") do
request = %ListPendingCeremoniesRequest{intent_id: intent_id}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.list_pending_ceremonies(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response.ceremonies}
end
end
defp connect do
url = Application.get_env(:guildhall_orchestrator, :ceremony_service_url, "localhost:50053")
GRPC.Stub.connect(url)
end
end

View file

@ -0,0 +1,296 @@
defmodule Ceremony.V1.CreateCeremonyRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CreateCeremonyRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_type, 1, type: :string, json_name: "ceremonyType"
field :subject, 2, type: Ceremony.V1.CeremonySubjectMsg
field :required_approvals, 3, type: :uint32, json_name: "requiredApprovals"
field :approver_roles, 4, repeated: true, type: :string, json_name: "approverRoles"
field :ttl_hours, 5, type: :uint32, json_name: "ttlHours"
field :intent_id, 6, type: :string, json_name: "intentId"
field :run_id, 7, type: :string, json_name: "runId"
field :pr_number, 8, type: :uint64, json_name: "prNumber"
field :remote_name, 9, type: :string, json_name: "remoteName"
end
defmodule Ceremony.V1.CreateCeremonyResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CreateCeremonyResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
field :status, 2, type: :string
field :expires_at, 3, type: Google.Protobuf.Timestamp, json_name: "expiresAt"
field :error, 4, type: :string
end
defmodule Ceremony.V1.ApproveCeremonyRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.ApproveCeremonyRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
field :approver_identity, 2, type: :string, json_name: "approverIdentity"
field :approver_role, 3, type: :string, json_name: "approverRole"
field :comment, 4, type: :string
end
defmodule Ceremony.V1.ApproveCeremonyResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.ApproveCeremonyResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :success, 1, type: :bool
field :status, 2, type: :string
field :error, 3, type: :string
end
defmodule Ceremony.V1.DenyCeremonyRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.DenyCeremonyRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
field :approver_identity, 2, type: :string, json_name: "approverIdentity"
field :approver_role, 3, type: :string, json_name: "approverRole"
field :comment, 4, type: :string
end
defmodule Ceremony.V1.DenyCeremonyResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.DenyCeremonyResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :success, 1, type: :bool
field :status, 2, type: :string
field :error, 3, type: :string
end
defmodule Ceremony.V1.CancelCeremonyRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CancelCeremonyRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
end
defmodule Ceremony.V1.CancelCeremonyResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CancelCeremonyResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :success, 1, type: :bool
field :error, 2, type: :string
end
defmodule Ceremony.V1.GetCeremonyRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.GetCeremonyRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
end
defmodule Ceremony.V1.GetCeremonyResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.GetCeremonyResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
field :ceremony_type, 2, type: :string, json_name: "ceremonyType"
field :subject, 3, type: Ceremony.V1.CeremonySubjectMsg
field :status, 4, type: :string
field :required_approvals, 5, type: :uint32, json_name: "requiredApprovals"
field :current_approvals, 6, type: :uint32, json_name: "currentApprovals"
field :approvals, 7, repeated: true, type: Ceremony.V1.CeremonyApprovalMsg
field :created_at, 8, type: Google.Protobuf.Timestamp, json_name: "createdAt"
field :expires_at, 9, type: Google.Protobuf.Timestamp, json_name: "expiresAt"
field :intent_id, 10, type: :string, json_name: "intentId"
field :run_id, 11, type: :string, json_name: "runId"
field :pr_number, 12, type: :uint64, json_name: "prNumber"
field :remote_name, 13, type: :string, json_name: "remoteName"
field :error, 14, type: :string
end
defmodule Ceremony.V1.ListPendingCeremoniesRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.ListPendingCeremoniesRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :intent_id, 1, type: :string, json_name: "intentId"
end
defmodule Ceremony.V1.ListPendingCeremoniesResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.ListPendingCeremoniesResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremonies, 1, repeated: true, type: Ceremony.V1.GetCeremonyResponse
end
defmodule Ceremony.V1.GetCeremonyProofRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.GetCeremonyProofRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
end
defmodule Ceremony.V1.GetCeremonyProofResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.GetCeremonyProofResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
field :status, 2, type: :string
field :proof_hash, 3, type: :string, json_name: "proofHash"
field :approvals, 4, repeated: true, type: Ceremony.V1.CeremonyApprovalMsg
field :resolved_at, 5, type: Google.Protobuf.Timestamp, json_name: "resolvedAt"
field :error, 6, type: :string
end
defmodule Ceremony.V1.CheckCeremonyRequirementRequest do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CheckCeremonyRequirementRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :classification, 1, type: :string
end
defmodule Ceremony.V1.CheckCeremonyRequirementResponse do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CheckCeremonyRequirementResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :outcome, 1, type: :string
field :ceremony_type, 2, type: :string, json_name: "ceremonyType"
field :approver_roles, 3, repeated: true, type: :string, json_name: "approverRoles"
field :required_approvals, 4, type: :uint32, json_name: "requiredApprovals"
end
defmodule Ceremony.V1.CeremonySubjectMsg.MetadataEntry do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CeremonySubjectMsg.MetadataEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Ceremony.V1.CeremonySubjectMsg do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CeremonySubjectMsg",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :subject_type, 1, type: :string, json_name: "subjectType"
field :reference_id, 2, type: :string, json_name: "referenceId"
field :description, 3, type: :string
field :metadata, 4,
repeated: true,
type: Ceremony.V1.CeremonySubjectMsg.MetadataEntry,
map: true
end
defmodule Ceremony.V1.CeremonyApprovalMsg do
@moduledoc false
use Protobuf,
full_name: "ceremony.v1.CeremonyApprovalMsg",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :approver_identity, 1, type: :string, json_name: "approverIdentity"
field :approver_role, 2, type: :string, json_name: "approverRole"
field :decision, 3, type: :string
field :comment, 4, type: :string
field :decided_at, 5, type: Google.Protobuf.Timestamp, json_name: "decidedAt"
end
defmodule Ceremony.V1.CeremonyService.Service do
@moduledoc false
use GRPC.Service, name: "ceremony.v1.CeremonyService", protoc_gen_elixir_version: "0.16.0"
rpc :CreateCeremony, Ceremony.V1.CreateCeremonyRequest, Ceremony.V1.CreateCeremonyResponse
rpc :ApproveCeremony, Ceremony.V1.ApproveCeremonyRequest, Ceremony.V1.ApproveCeremonyResponse
rpc :DenyCeremony, Ceremony.V1.DenyCeremonyRequest, Ceremony.V1.DenyCeremonyResponse
rpc :CancelCeremony, Ceremony.V1.CancelCeremonyRequest, Ceremony.V1.CancelCeremonyResponse
rpc :GetCeremony, Ceremony.V1.GetCeremonyRequest, Ceremony.V1.GetCeremonyResponse
rpc :ListPendingCeremonies,
Ceremony.V1.ListPendingCeremoniesRequest,
Ceremony.V1.ListPendingCeremoniesResponse
rpc :GetCeremonyProof, Ceremony.V1.GetCeremonyProofRequest, Ceremony.V1.GetCeremonyProofResponse
rpc :CheckCeremonyRequirement,
Ceremony.V1.CheckCeremonyRequirementRequest,
Ceremony.V1.CheckCeremonyRequirementResponse
end
defmodule Ceremony.V1.CeremonyService.Stub do
@moduledoc false
use GRPC.Stub, service: Ceremony.V1.CeremonyService.Service
end

View file

@ -0,0 +1,452 @@
defmodule Schematic.V1.CreateSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :manifest_yaml, 3, type: :bytes, json_name: "manifestYaml"
field :files, 4, repeated: true, type: Schematic.V1.SchematicFile
end
defmodule Schematic.V1.SchematicFile do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.SchematicFile",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :path, 1, type: :string
field :content, 2, type: :bytes
end
defmodule Schematic.V1.CreateSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :tree_hash, 3, type: :string, json_name: "treeHash"
field :status, 4, type: :string
end
defmodule Schematic.V1.GetSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
end
defmodule Schematic.V1.GetSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :tree_hash, 3, type: :string, json_name: "treeHash"
field :status, 4, type: :string
field :parent_hash, 5, type: :string, json_name: "parentHash"
field :stakeholders, 6, repeated: true, type: Schematic.V1.StakeholderInfo
field :created_at, 7, type: Google.Protobuf.Timestamp, json_name: "createdAt"
end
defmodule Schematic.V1.StakeholderInfo do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.StakeholderInfo",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :role, 1, type: :string
field :identity, 2, type: :string
field :required, 3, type: :bool
end
defmodule Schematic.V1.ListVersionsRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ListVersionsRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
end
defmodule Schematic.V1.ListVersionsResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ListVersionsResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :versions, 1, repeated: true, type: Schematic.V1.VersionInfo
end
defmodule Schematic.V1.VersionInfo do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.VersionInfo",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :version, 1, type: :string
field :tree_hash, 2, type: :string, json_name: "treeHash"
field :status, 3, type: :string
field :parent_hash, 4, type: :string, json_name: "parentHash"
field :created_at, 5, type: Google.Protobuf.Timestamp, json_name: "createdAt"
end
defmodule Schematic.V1.UpdateSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.UpdateSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :files, 3, repeated: true, type: Schematic.V1.SchematicFile
end
defmodule Schematic.V1.UpdateSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.UpdateSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :tree_hash, 1, type: :string, json_name: "treeHash"
field :status, 2, type: :string
end
defmodule Schematic.V1.ValidateSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ValidateSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
end
defmodule Schematic.V1.ValidateSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ValidateSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :valid, 1, type: :bool
field :error_count, 2, type: :int32, json_name: "errorCount"
field :warning_count, 3, type: :int32, json_name: "warningCount"
field :results, 4, repeated: true, type: Schematic.V1.ValidationResultProto
end
defmodule Schematic.V1.ValidationResultProto do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ValidationResultProto",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :validator, 1, type: :string
field :passed, 2, type: :bool
field :message, 3, type: :string
field :severity, 4, type: :string
end
defmodule Schematic.V1.ApproveSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ApproveSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :role, 3, type: :string
field :identity, 4, type: :string
field :tree_hash, 5, type: :string, json_name: "treeHash"
field :comment, 6, type: :string
end
defmodule Schematic.V1.ApproveSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ApproveSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :accepted, 1, type: :bool
field :approval_status, 2, type: :string, json_name: "approvalStatus"
field :message, 3, type: :string
end
defmodule Schematic.V1.GetApprovalStatusRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetApprovalStatusRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
end
defmodule Schematic.V1.GetApprovalStatusResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetApprovalStatusResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :status, 1, type: :string
field :approved_roles, 2, repeated: true, type: :string, json_name: "approvedRoles"
field :remaining_roles, 3, repeated: true, type: :string, json_name: "remainingRoles"
field :approvals, 4, repeated: true, type: Schematic.V1.ApprovalInfo
end
defmodule Schematic.V1.ApprovalInfo do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ApprovalInfo",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :role, 1, type: :string
field :identity, 2, type: :string
field :approved_at, 3, type: Google.Protobuf.Timestamp, json_name: "approvedAt"
field :comment, 4, type: :string
end
defmodule Schematic.V1.PublishSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.PublishSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
end
defmodule Schematic.V1.PublishSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.PublishSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :tree_hash, 1, type: :string, json_name: "treeHash"
field :status, 2, type: :string
end
defmodule Schematic.V1.CreateNextVersionRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateNextVersionRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :from_version, 2, type: :string, json_name: "fromVersion"
field :bump, 3, type: :string
end
defmodule Schematic.V1.CreateNextVersionResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateNextVersionResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :version, 1, type: :string
field :tree_hash, 2, type: :string, json_name: "treeHash"
field :parent_hash, 3, type: :string, json_name: "parentHash"
field :status, 4, type: :string
end
defmodule Schematic.V1.ForkSchematicRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ForkSchematicRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :source_name, 1, type: :string, json_name: "sourceName"
field :source_version, 2, type: :string, json_name: "sourceVersion"
field :new_name, 3, type: :string, json_name: "newName"
field :new_version, 4, type: :string, json_name: "newVersion"
field :operations, 5, repeated: true, type: Schematic.V1.TemplateOperation
end
defmodule Schematic.V1.TemplateOperation do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.TemplateOperation",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :op_type, 1, type: :string, json_name: "opType"
field :path, 2, type: :string
field :content, 3, type: :bytes
end
defmodule Schematic.V1.ForkSchematicResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.ForkSchematicResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :name, 1, type: :string
field :version, 2, type: :string
field :tree_hash, 3, type: :string, json_name: "treeHash"
field :status, 4, type: :string
end
defmodule Schematic.V1.CreateDeploymentBindingRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateDeploymentBindingRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :schematic_name, 1, type: :string, json_name: "schematicName"
field :schematic_version, 2, type: :string, json_name: "schematicVersion"
field :pipeline_name, 3, type: :string, json_name: "pipelineName"
field :target_env, 4, type: :string, json_name: "targetEnv"
end
defmodule Schematic.V1.CreateDeploymentBindingResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.CreateDeploymentBindingResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :binding_id, 1, type: :string, json_name: "bindingId"
field :status, 2, type: :string
end
defmodule Schematic.V1.GetDeploymentBindingRequest do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetDeploymentBindingRequest",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :binding_id, 1, type: :string, json_name: "bindingId"
end
defmodule Schematic.V1.GetDeploymentBindingResponse do
@moduledoc false
use Protobuf,
full_name: "schematic.v1.GetDeploymentBindingResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :binding_id, 1, type: :string, json_name: "bindingId"
field :schematic_name, 2, type: :string, json_name: "schematicName"
field :schematic_version, 3, type: :string, json_name: "schematicVersion"
field :tree_hash, 4, type: :string, json_name: "treeHash"
field :pipeline_name, 5, type: :string, json_name: "pipelineName"
field :target_env, 6, type: :string, json_name: "targetEnv"
field :status, 7, type: :string
field :run_id, 8, type: :string, json_name: "runId"
field :created_at, 9, type: Google.Protobuf.Timestamp, json_name: "createdAt"
field :deployed_at, 10, type: Google.Protobuf.Timestamp, json_name: "deployedAt"
end
defmodule Schematic.V1.SchematicsService.Service do
@moduledoc false
use GRPC.Service, name: "schematic.v1.SchematicsService", protoc_gen_elixir_version: "0.16.0"
rpc :CreateSchematic, Schematic.V1.CreateSchematicRequest, Schematic.V1.CreateSchematicResponse
rpc :GetSchematic, Schematic.V1.GetSchematicRequest, Schematic.V1.GetSchematicResponse
rpc :ListVersions, Schematic.V1.ListVersionsRequest, Schematic.V1.ListVersionsResponse
rpc :UpdateSchematic, Schematic.V1.UpdateSchematicRequest, Schematic.V1.UpdateSchematicResponse
rpc :ValidateSchematic,
Schematic.V1.ValidateSchematicRequest,
Schematic.V1.ValidateSchematicResponse
rpc :ApproveSchematic,
Schematic.V1.ApproveSchematicRequest,
Schematic.V1.ApproveSchematicResponse
rpc :GetApprovalStatus,
Schematic.V1.GetApprovalStatusRequest,
Schematic.V1.GetApprovalStatusResponse
rpc :PublishSchematic,
Schematic.V1.PublishSchematicRequest,
Schematic.V1.PublishSchematicResponse
rpc :CreateNextVersion,
Schematic.V1.CreateNextVersionRequest,
Schematic.V1.CreateNextVersionResponse
rpc :ForkSchematic, Schematic.V1.ForkSchematicRequest, Schematic.V1.ForkSchematicResponse
rpc :CreateDeploymentBinding,
Schematic.V1.CreateDeploymentBindingRequest,
Schematic.V1.CreateDeploymentBindingResponse
rpc :GetDeploymentBinding,
Schematic.V1.GetDeploymentBindingRequest,
Schematic.V1.GetDeploymentBindingResponse
end
defmodule Schematic.V1.SchematicsService.Stub do
@moduledoc false
use GRPC.Stub, service: Schematic.V1.SchematicsService.Service
end

View file

@ -0,0 +1,91 @@
defmodule Guildhall.Orchestrator.RealizationPoller do
use GenServer
require Logger
alias Guildhall.Orchestrator.SchematicClient
@poll_interval_ms 5_000
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def watch(realization_id, guild_slug) do
GenServer.cast(__MODULE__, {:watch, realization_id, guild_slug})
end
def unwatch(realization_id) do
GenServer.cast(__MODULE__, {:unwatch, realization_id})
end
@impl true
def init(_opts) do
Logger.info("RealizationPoller started")
schedule_poll()
{:ok, %{watches: %{}}}
end
@impl true
def handle_cast({:watch, realization_id, guild_slug}, state) do
{:noreply, put_in(state, [:watches, realization_id], guild_slug)}
end
@impl true
def handle_cast({:unwatch, realization_id}, state) do
{:noreply, %{state | watches: Map.delete(state.watches, realization_id)}}
end
@impl true
def handle_info(:poll, %{watches: watches} = state) when map_size(watches) == 0 do
schedule_poll()
{:noreply, state}
end
@impl true
def handle_info(:poll, state) do
new_watches =
Enum.reduce(state.watches, state.watches, fn {realization_id, guild_slug}, acc ->
case SchematicClient.get_realization_status(realization_id) do
{:ok, response} ->
snapshot = build_snapshot(response)
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"realization:#{guild_slug}",
{:realization_update, snapshot}
)
if terminal_status?(response.overall_status) do
Map.delete(acc, realization_id)
else
acc
end
{:error, reason} ->
Logger.warning("Realization poll failed for #{realization_id}: #{inspect(reason)}")
acc
end
end)
schedule_poll()
{:noreply, %{state | watches: new_watches}}
end
@impl true
def handle_info(_msg, state), do: {:noreply, state}
defp schedule_poll, do: Process.send_after(self(), :poll, @poll_interval_ms)
defp build_snapshot(response) do
%{
overall_status: to_string(response.overall_status),
sections:
Enum.map(response.per_section, fn s ->
%{name: s.section_name, status: s.status, message: s.message}
end)
}
end
defp terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED), do: true
defp terminal_status?(:FFC_SCHEMATIC_STATUS_ARCHIVED), do: true
defp terminal_status?(:FFC_SCHEMATIC_STATUS_WITHDRAWN), do: true
defp terminal_status?(_), do: false
end

View file

@ -0,0 +1,98 @@
defmodule Guildhall.Orchestrator.SchematicClient do
@moduledoc false
alias Schematic.V1.{
SchematicsService.Stub,
ForkSchematicRequest,
TemplateOperation,
CreateDeploymentBindingRequest,
GetDeploymentBindingRequest
}
alias Schematic.V1.RealizeFfcSchematicRequest
alias Schematic.V1.GetRealizationStatusRequest
def fork_schematic(source_name, source_version, new_name, new_version, operations) do
ops =
Enum.map(operations, fn op ->
%TemplateOperation{
op_type: op[:op_type] || "replace",
path: op[:path],
content: op[:content] || ""
}
end)
request = %ForkSchematicRequest{
source_name: source_name,
source_version: source_version,
new_name: new_name,
new_version: new_version,
operations: ops
}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.fork_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def create_deployment_binding(schematic_name, version, pipeline_name, target_env) do
request = %CreateDeploymentBindingRequest{
schematic_name: schematic_name,
schematic_version: version,
pipeline_name: pipeline_name,
target_env: target_env
}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.create_deployment_binding(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def get_deployment_binding(binding_id) do
request = %GetDeploymentBindingRequest{binding_id: binding_id}
with {:ok, channel} <- connect_schematic(),
{:ok, response} <- Stub.get_deployment_binding(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def realize_ffc_schematic(name, version) do
request = %RealizeFfcSchematicRequest{name: name, version: version}
with {:ok, channel} <- connect_ffc(),
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.realize_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
def get_realization_status(realization_id) do
request = %GetRealizationStatusRequest{realization_id: realization_id}
with {:ok, channel} <- connect_ffc(),
{:ok, response} <- Schematic.V1.FfcSchematicService.Stub.get_realization_status(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
defp connect_schematic do
url =
Application.get_env(:guildhall_orchestrator, :schematic_service_url, "localhost:9091")
GRPC.Stub.connect(url)
end
defp connect_ffc do
url =
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")
GRPC.Stub.connect(url)
end
end

View file

@ -0,0 +1,70 @@
defmodule Guildhall.Orchestrator.SchematicTemplate do
@moduledoc false
@type_to_template %{
"msp" => "msp-founding.toml",
"isv" => "isv-founding.toml",
"nsp" => "nsp-founding.toml"
}
def load_template(guild_type) do
filename = Map.fetch!(@type_to_template, guild_type)
path = Path.join(template_dir(), filename)
case Toml.decode_file(path) do
{:ok, data} -> {:ok, data}
{:error, reason} -> {:error, {:template_load_failed, reason}}
end
end
def render_template(template, params) when is_map(template) and is_map(params) do
deep_substitute(template, params)
end
def to_fork_operations(rendered_template) do
rendered_template
|> Map.drop(["meta"])
|> Enum.flat_map(fn {section, content} ->
yaml = to_yaml_string(content)
[
%{
op_type: "replace",
path: "#{section}.yaml",
content: yaml
}
]
end)
end
def source_schematic(template) do
meta = Map.get(template, "meta", %{})
{Map.get(meta, "source_schematic", ""), Map.get(meta, "source_version", "1.0.0")}
end
defp deep_substitute(value, params) when is_binary(value) do
Enum.reduce(params, value, fn {key, val}, acc ->
String.replace(acc, "{{#{key}}}", to_string(val))
end)
end
defp deep_substitute(value, params) when is_map(value) do
Map.new(value, fn {k, v} -> {k, deep_substitute(v, params)} end)
end
defp deep_substitute(value, params) when is_list(value) do
Enum.map(value, &deep_substitute(&1, params))
end
defp deep_substitute(value, _params), do: value
defp to_yaml_string(map) when is_map(map) do
Jason.encode!(map)
end
defp to_yaml_string(other), do: to_string(other)
defp template_dir do
Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates")
end
end

View file

@ -28,7 +28,11 @@ defmodule Guildhall.Orchestrator.MixProject do
[
{:guildhall_ops_db, in_umbrella: true},
{:phoenix_pubsub, "~> 2.1"},
{:jason, "~> 1.4"}
{:jason, "~> 1.4"},
{:grpc, "~> 0.9"},
{:protobuf, "~> 0.13"},
{:google_protos, "~> 0.4"},
{:toml, "~> 0.7"}
]
end
end

View file

@ -0,0 +1,36 @@
[meta]
template_name = "isv-founding"
description = "Independent Software Vendor founding schematic"
source_schematic = "guildhouse-isv-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} ISV Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 1
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
trust_level = "federated"
mfa_required = false
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[members]
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 1

View file

@ -0,0 +1,36 @@
[meta]
template_name = "msp-founding"
description = "Managed Service Provider founding schematic"
source_schematic = "guildhouse-msp-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} MSP Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 2
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[members]
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 2

View file

@ -0,0 +1,39 @@
[meta]
template_name = "nsp-founding"
description = "Network Service Provider founding schematic"
source_schematic = "guildhouse-nsp-base"
source_version = "1.0.0"
[consortium]
realm = "{{guild_slug}}"
description = "{{guild_name}} NSP Consortium"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 3
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
hardware_credential_required = true
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "multi_party"
eligible_roles = ["master"]
quorum = 2
[members]
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 3
wireguard_tunnel = true
vpp_dataplane = true

View file

@ -7,15 +7,31 @@ defmodule Guildhall.Application do
@impl true
def start(_type, _args) do
children = [
GuildhallWeb.Telemetry,
{DNSCluster, query: Application.get_env(:guildhall_web, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Guildhall.PubSub},
# Start a worker by calling: Guildhall.Worker.start_link(arg)
# {Guildhall.Worker, arg},
# Start to serve requests, typically the last entry
GuildhallWeb.Endpoint
]
oidc_children =
case Application.get_env(:guildhall_web, :oidc) do
nil ->
[]
config ->
[
{Oidcc.ProviderConfiguration.Worker,
%{
issuer: config[:issuer],
name: GuildhallWeb.OidcProvider
}}
]
end
children =
[
GuildhallWeb.Telemetry,
{DNSCluster, query: Application.get_env(:guildhall_web, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Guildhall.PubSub}
] ++
oidc_children ++
[
GuildhallWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options

View file

@ -0,0 +1,96 @@
defmodule GuildhallWeb.AuthController do
use GuildhallWeb, :controller
@provider_name GuildhallWeb.OidcProvider
def login(conn, _params) do
oidc_config = Application.fetch_env!(:guildhall_web, :oidc)
client_id = oidc_config[:client_id]
client_secret = oidc_config[:client_secret]
redirect_uri = oidc_config[:redirect_uri]
nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
state = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
case Oidcc.create_redirect_url(@provider_name, client_id, client_secret, %{
redirect_uri: redirect_uri,
scopes: ["openid", "profile", "email"],
state: state,
nonce: nonce
}) do
{:ok, url} ->
conn
|> put_session(:oidc_state, state)
|> put_session(:oidc_nonce, nonce)
|> redirect(external: url)
{:error, reason} ->
conn
|> put_flash(:error, "Failed to initiate login: #{inspect(reason)}")
|> redirect(to: "/")
end
end
def callback(conn, %{"code" => code, "state" => state}) do
saved_state = get_session(conn, :oidc_state)
if state != saved_state do
conn
|> put_flash(:error, "Invalid state parameter.")
|> redirect(to: "/")
else
oidc_config = Application.fetch_env!(:guildhall_web, :oidc)
client_id = oidc_config[:client_id]
client_secret = oidc_config[:client_secret]
redirect_uri = oidc_config[:redirect_uri]
nonce = get_session(conn, :oidc_nonce)
case Oidcc.retrieve_token(code, @provider_name, client_id, client_secret, %{
redirect_uri: redirect_uri,
nonce: nonce
}) do
{:ok, token} ->
claims = extract_claims(token)
preferred_username =
claims["preferred_username"] || claims["email"] || claims["sub"]
current_user = %{
"sub" => claims["sub"],
"email" => claims["email"],
"name" => claims["name"] || preferred_username,
"preferred_username" => preferred_username,
"did" => "did:web:guildhouse.dev:user:#{preferred_username}"
}
conn
|> delete_session(:oidc_state)
|> delete_session(:oidc_nonce)
|> put_session(:current_user, current_user)
|> put_flash(:info, "Welcome, #{current_user["name"]}.")
|> redirect(to: "/")
{:error, reason} ->
conn
|> put_flash(:error, "Authentication failed: #{inspect(reason)}")
|> redirect(to: "/")
end
end
end
def callback(conn, _params) do
conn
|> put_flash(:error, "Missing authorization code.")
|> redirect(to: "/")
end
def logout(conn, _params) do
conn
|> configure_session(drop: true)
|> put_flash(:info, "Signed out.")
|> redirect(to: "/")
end
defp extract_claims(%Oidcc.Token{id: %Oidcc.Token.Id{claims: claims}}), do: claims
defp extract_claims(_), do: %{}
end

View file

@ -0,0 +1,16 @@
defmodule GuildhallWeb.AuthHooks do
@moduledoc false
import Phoenix.LiveView
import Phoenix.Component
def on_mount(:require_auth, _params, session, socket) do
case session["current_user"] do
nil -> {:halt, redirect(socket, to: "/auth/login")}
user -> {:cont, assign(socket, :current_user, user)}
end
end
def on_mount(:fetch_user, _params, session, socket) do
{:cont, assign(socket, :current_user, session["current_user"])}
end
end

View file

@ -5,7 +5,7 @@ defmodule GuildhallWeb.DashboardLive do
"""
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult}
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult, Guilds}
import Ecto.Query
@impl true
@ -35,6 +35,9 @@ defmodule GuildhallWeb.DashboardLive do
|> assign(:healthy_count, count_by_drift("match"))
|> assign(:drifted_count, count_by_drift("drift"))
|> assign(:recent_verifications, recent_verifications(5))
|> assign(:guild_total, Guilds.guild_count())
|> assign(:guild_pending, Guilds.guild_count("pending_approval"))
|> assign(:guild_active, Guilds.guild_count("active"))
end
defp count_by_drift(status) do
@ -77,7 +80,23 @@ defmodule GuildhallWeb.DashboardLive do
</div>
</section>
<section class="grid grid-cols-3 gap-4">
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Guilds</div>
<div class="text-3xl font-semibold">{@guild_total}</div>
</div>
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Pending</div>
<div class="text-3xl font-semibold text-amber-600">{@guild_pending}</div>
</div>
<div class="rounded-lg border border-zinc-200 p-4">
<div class="text-xs uppercase text-zinc-500">Active</div>
<div class="text-3xl font-semibold text-emerald-600">{@guild_active}</div>
</div>
</section>
<nav class="flex gap-3 text-sm">
<.link navigate={~p"/guilds"} class="text-blue-600 underline">Guilds</.link>
<.link navigate={~p"/ceremonies"} class="text-blue-600 underline">Ceremonies</.link>
<.link navigate={~p"/artifacts"} class="text-blue-600 underline">Artifacts</.link>
</nav>

View file

@ -0,0 +1,147 @@
defmodule GuildhallWeb.GuildLive.Index do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.Guilds
alias Guildhall.Orchestrator.CeremonyClient
@hub_operator_did "did:web:guildhouse.dev:user:tking"
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "guild:*")
end
{:ok,
socket
|> assign(:page_title, "Guilds")
|> assign(:guilds, Guilds.list_guilds())
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)}
end
@impl true
def handle_event("approve_guild", %{"id" => id}, socket) do
guild = Guilds.get_guild!(id)
case CeremonyClient.approve_ceremony(
guild.registration_ceremony_id,
socket.assigns.current_user["did"],
"hub_operator"
) do
{:ok, %{status: "approved"}} ->
{:ok, _} = Guilds.update_guild(guild, %{status: "approved"})
{:noreply,
socket
|> put_flash(:info, "#{guild.name} approved.")
|> assign(:guilds, Guilds.list_guilds())}
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Approval recorded for #{guild.name}.")
|> assign(:guilds, Guilds.list_guilds())}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Approval failed: #{inspect(reason)}")}
end
end
@impl true
def handle_event("deny_guild", %{"id" => id}, socket) do
guild = Guilds.get_guild!(id)
case CeremonyClient.deny_ceremony(
guild.registration_ceremony_id,
socket.assigns.current_user["did"],
"hub_operator",
"Denied by hub operator"
) do
{:ok, _} ->
{:ok, _} = Guilds.update_guild(guild, %{status: "denied"})
{:noreply,
socket
|> put_flash(:info, "#{guild.name} denied.")
|> assign(:guilds, Guilds.list_guilds())}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Denial failed: #{inspect(reason)}")}
end
end
@impl true
def handle_info({:guild_updated, _}, socket) do
{:noreply, assign(socket, :guilds, Guilds.list_guilds())}
end
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
defp status_class("pending_approval"), do: "text-amber-600"
defp status_class("approved"), do: "text-emerald-600"
defp status_class("active"), do: "text-emerald-600"
defp status_class("denied"), do: "text-red-600"
defp status_class("suspended"), do: "text-zinc-500"
defp status_class(_), do: "text-zinc-500"
defp type_label("msp"), do: "MSP"
defp type_label("isv"), do: "ISV"
defp type_label("nsp"), do: "NSP"
defp type_label(other), do: String.upcase(other)
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl p-6 space-y-4">
<header class="flex items-center justify-between">
<div>
<.link navigate={~p"/"} class="text-sm text-blue-600 underline">&larr; Dashboard</.link>
<h1 class="text-2xl font-semibold mt-2">Guilds</h1>
<p class="text-sm text-zinc-500">{length(@guilds)} registered.</p>
</div>
<.link navigate={~p"/guilds/register"} class="rounded bg-blue-600 px-4 py-2 text-sm text-white">
Register Guild
</.link>
</header>
<table class="w-full text-sm">
<thead class="text-left text-zinc-500">
<tr>
<th class="py-1">Name</th>
<th class="py-1">Type</th>
<th class="py-1">Guild ID</th>
<th class="py-1">Status</th>
<th class="py-1">Registrant</th>
<th :if={@is_hub_operator} class="py-1">Actions</th>
</tr>
</thead>
<tbody>
<tr :for={g <- @guilds} class="border-t border-zinc-100">
<td class="py-2">
<.link navigate={~p"/guilds/#{g.slug}"} class="text-blue-600 underline">{g.name}</.link>
</td>
<td class="py-2">{type_label(g.guild_type)}</td>
<td class="py-2 font-mono text-xs">0x{Integer.to_string(g.guild_id, 16) |> String.pad_leading(3, "0")}</td>
<td class="py-2"><span class={status_class(g.status)}>{g.status}</span></td>
<td class="py-2 text-xs font-mono">{g.registrant_did}</td>
<td :if={@is_hub_operator} class="py-2">
<div :if={g.status == "pending_approval"} class="flex gap-2">
<button phx-click="approve_guild" phx-value-id={g.id} class="rounded bg-emerald-600 px-2 py-1 text-xs text-white">
Approve
</button>
<button phx-click="deny_guild" phx-value-id={g.id} class="rounded bg-red-600 px-2 py-1 text-xs text-white">
Deny
</button>
</div>
</td>
</tr>
<tr :if={@guilds == []}>
<td colspan="6" class="py-6 text-center text-zinc-400">No guilds registered yet.</td>
</tr>
</tbody>
</table>
</div>
"""
end
end

View file

@ -0,0 +1,100 @@
defmodule GuildhallWeb.GuildLive.Join do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildMemberships}
alias Guildhall.Orchestrator.CeremonyClient
@impl true
def mount(%{"slug" => slug}, _session, socket) do
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
user = socket.assigns.current_user
existing = GuildMemberships.find_membership(guild.id, user["did"])
if existing do
{:ok,
socket
|> put_flash(:info, "You already have a membership in #{guild.name}.")
|> push_navigate(to: ~p"/guilds/#{slug}")}
else
{:ok,
socket
|> assign(:page_title, "Join #{guild.name}")
|> assign(:guild, guild)
|> assign(:submitted, false)}
end
end
@impl true
def handle_event("request_membership", _params, socket) do
guild = socket.assigns.guild
user = socket.assigns.current_user
case GuildMemberships.request_membership(%{
guild_id: guild.id,
user_did: user["did"],
user_email: user["email"],
display_name: user["name"],
keycloak_sub: user["sub"],
role: "apprentice",
status: "pending"
}) do
{:ok, membership} ->
masters = GuildMemberships.masters_for_guild(guild.id)
approver_did = if masters != [], do: hd(masters).user_did, else: guild.registrant_did
case CeremonyClient.create_guild_registration_ceremony(
"membership:#{guild.slug}:#{user["preferred_username"]}",
user["did"],
approver_did
) do
{:ok, response} ->
GuildMemberships.get_membership!(membership.id)
|> Guildhall.OpsDb.GuildMembership.changeset(%{
membership_ceremony_id: response.ceremony_id
})
|> Guildhall.OpsDb.Repo.update()
{:error, _} ->
:ok
end
{:noreply,
socket
|> assign(:submitted, true)
|> put_flash(:info, "Membership request submitted.")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to submit membership request.")}
end
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-lg p-6 space-y-6">
<header>
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">&larr; {@guild.name}</.link>
<h1 class="text-2xl font-semibold mt-2">Join {@guild.name}</h1>
</header>
<section class="text-sm space-y-2">
<p>{@guild.description || "No description."}</p>
<p class="text-zinc-500">Type: {String.upcase(@guild.guild_type)}</p>
</section>
<div :if={!@submitted}>
<button
phx-click="request_membership"
class="rounded bg-blue-600 px-4 py-2 text-sm text-white"
>
Request Membership
</button>
</div>
<div :if={@submitted} class="border border-emerald-200 bg-emerald-50 rounded p-4 text-sm text-emerald-700">
Your membership request has been submitted. A guild master will review it.
</div>
</div>
"""
end
end

View file

@ -0,0 +1,142 @@
defmodule GuildhallWeb.GuildLive.Members do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildMemberships}
alias Guildhall.Orchestrator.CeremonyClient
@impl true
def mount(%{"slug" => slug}, _session, socket) do
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
user = socket.assigns.current_user
user_membership = GuildMemberships.find_membership(guild.id, user["did"])
is_master = user_membership && user_membership.role == "master" && user_membership.status == "active"
unless is_master do
{:ok,
socket
|> put_flash(:error, "Only guild masters can manage members.")
|> push_navigate(to: ~p"/guilds/#{slug}")}
else
{:ok,
socket
|> assign(:page_title, "#{guild.name} — Members")
|> assign(:guild, guild)
|> assign(:memberships, GuildMemberships.list_memberships(guild.id))}
end
end
@impl true
def handle_event("approve_member", %{"id" => id}, socket) do
membership = GuildMemberships.get_membership!(id)
if membership.membership_ceremony_id do
CeremonyClient.approve_ceremony(
membership.membership_ceremony_id,
socket.assigns.current_user["did"],
"guild_master"
)
end
{:ok, _} = GuildMemberships.approve_membership(membership, socket.assigns.current_user["did"])
{:noreply,
socket
|> put_flash(:info, "Member approved.")
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
end
@impl true
def handle_event("deny_member", %{"id" => id}, socket) do
membership = GuildMemberships.get_membership!(id)
if membership.membership_ceremony_id do
CeremonyClient.deny_ceremony(
membership.membership_ceremony_id,
socket.assigns.current_user["did"],
"guild_master",
"Denied by guild master"
)
end
{:ok, _} = GuildMemberships.deny_membership(membership, socket.assigns.current_user["did"])
{:noreply,
socket
|> put_flash(:info, "Member denied.")
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
end
@impl true
def handle_event("update_role", %{"id" => id, "role" => role}, socket) do
membership = GuildMemberships.get_membership!(id)
{:ok, _} = GuildMemberships.update_role(membership, role)
{:noreply,
socket
|> put_flash(:info, "Role updated.")
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
end
defp status_class("pending"), do: "text-amber-600"
defp status_class("active"), do: "text-emerald-600"
defp status_class("denied"), do: "text-red-600"
defp status_class("suspended"), do: "text-zinc-500"
defp status_class(_), do: "text-zinc-500"
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-4xl p-6 space-y-4">
<header>
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">&larr; {@guild.name}</.link>
<h1 class="text-2xl font-semibold mt-2">Members</h1>
<p class="text-sm text-zinc-500">{length(@memberships)} member(s).</p>
</header>
<table class="w-full text-sm">
<thead class="text-left text-zinc-500">
<tr>
<th class="py-1">Name</th>
<th class="py-1">Email</th>
<th class="py-1">Role</th>
<th class="py-1">Status</th>
<th class="py-1">Actions</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @memberships} class="border-t border-zinc-100">
<td class="py-2">{m.display_name || ""}</td>
<td class="py-2 text-xs">{m.user_email}</td>
<td class="py-2">{m.role}</td>
<td class="py-2"><span class={status_class(m.status)}>{m.status}</span></td>
<td class="py-2">
<div :if={m.status == "pending"} class="flex gap-2">
<button phx-click="approve_member" phx-value-id={m.id} class="rounded bg-emerald-600 px-2 py-1 text-xs text-white">
Approve
</button>
<button phx-click="deny_member" phx-value-id={m.id} class="rounded bg-red-600 px-2 py-1 text-xs text-white">
Deny
</button>
</div>
<div :if={m.status == "active"}>
<form phx-change="update_role" phx-value-id={m.id}>
<input type="hidden" name="_member_id" value={m.id} />
<select name="role" class="text-xs border border-zinc-300 rounded px-1 py-0.5">
<option value="apprentice" selected={m.role == "apprentice"}>Apprentice</option>
<option value="journeyman" selected={m.role == "journeyman"}>Journeyman</option>
<option value="master" selected={m.role == "master"}>Master</option>
</select>
</form>
</div>
</td>
</tr>
<tr :if={@memberships == []}>
<td colspan="5" class="py-6 text-center text-zinc-400">No members yet.</td>
</tr>
</tbody>
</table>
</div>
"""
end
end

View file

@ -0,0 +1,102 @@
defmodule GuildhallWeb.GuildLive.Realization do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
@impl true
def mount(%{"slug" => slug}, _session, socket) do
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
schematic = GuildSchematics.get_for_guild(guild.id)
if connected?(socket) do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "realization:#{slug}")
end
snapshot = if schematic, do: schematic.realization_snapshot, else: %{}
{:ok,
socket
|> assign(:page_title, "#{guild.name} — Realization")
|> assign(:guild, guild)
|> assign(:schematic, schematic)
|> assign(:snapshot, snapshot)}
end
@impl true
def handle_info({:realization_update, snapshot}, socket) do
{:noreply, assign(socket, :snapshot, snapshot)}
end
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
@reconciler_sections ~w(trust_domain identity_authority members infrastructure ceremonies federation_peers attestation)
defp section_status(snapshot, name) do
sections = Map.get(snapshot, "sections", Map.get(snapshot, :sections, []))
case Enum.find(sections, fn s -> (s["name"] || s[:name]) == name end) do
nil -> %{status: "pending", message: "Not started"}
s -> %{status: s["status"] || s[:status], message: s["message"] || s[:message] || ""}
end
end
defp status_icon("succeeded"), do: "text-emerald-600"
defp status_icon("in_progress"), do: "text-amber-600"
defp status_icon("failed"), do: "text-red-600"
defp status_icon("pending"), do: "text-zinc-400"
defp status_icon(_), do: "text-zinc-400"
defp status_symbol("succeeded"), do: ""
defp status_symbol("in_progress"), do: ""
defp status_symbol("failed"), do: ""
defp status_symbol(_), do: ""
defp overall_class(snapshot) do
overall = Map.get(snapshot, "overall_status", Map.get(snapshot, :overall_status, "pending"))
cond do
String.contains?(to_string(overall), "REALIZED") -> "bg-emerald-100 text-emerald-700"
String.contains?(to_string(overall), "PUBLISHED") -> "bg-amber-100 text-amber-700"
true -> "bg-zinc-100 text-zinc-700"
end
end
@impl true
def render(assigns) do
assigns = assign(assigns, :sections, @reconciler_sections)
~H"""
<div class="mx-auto max-w-2xl p-6 space-y-6">
<header>
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">&larr; {@guild.name}</.link>
<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)}"}>
{Map.get(@snapshot, "overall_status", Map.get(@snapshot, :overall_status, "pending"))}
</div>
</header>
<section :if={@schematic == nil} class="text-zinc-500 text-sm">
No schematic deployed for this guild yet.
</section>
<section :if={@schematic} class="space-y-2">
<div class="text-sm text-zinc-500 mb-3">
Template: <span class="font-mono">{@schematic.template_name}</span> &middot;
Schematic: <span class="font-mono">{@schematic.schematic_name} v{@schematic.schematic_version}</span>
</div>
<div :for={name <- @sections} class="flex items-center gap-3 rounded border border-zinc-200 p-3">
<% s = section_status(@snapshot, name) %>
<span class={"text-lg font-bold #{status_icon(s.status)}"}>{status_symbol(s.status)}</span>
<div class="flex-1">
<div class="text-sm font-medium">{String.replace(name, "_", " ") |> String.capitalize()}</div>
<div class="text-xs text-zinc-500">{s.message}</div>
</div>
<span class={"text-xs #{status_icon(s.status)}"}>{s.status}</span>
</div>
</section>
</div>
"""
end
end

View file

@ -0,0 +1,148 @@
defmodule GuildhallWeb.GuildLive.Register do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guild, Guilds}
alias Guildhall.Orchestrator.CeremonyClient
@impl true
def mount(_params, _session, socket) do
changeset = Guild.changeset(%Guild{}, %{})
{:ok,
socket
|> assign(:page_title, "Register Guild")
|> assign(:form, to_form(changeset))
|> assign(:submitting, false)}
end
@impl true
def handle_event("validate", %{"guild" => params}, socket) do
changeset =
%Guild{}
|> Guild.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("register", %{"guild" => params}, socket) do
user = socket.assigns.current_user
guild_id = Guilds.next_guild_id()
slug = slugify(params["name"] || "")
attrs =
params
|> Map.put("guild_id", guild_id)
|> Map.put("slug", slug)
|> Map.put("registrant_did", user["did"])
|> Map.put("contact_did", user["did"])
|> Map.put("trust_domain", "#{slug}.guildhouse.dev")
|> Map.put("status", "pending_approval")
case Guilds.create_guild(attrs) do
{:ok, guild} ->
ceremony_result =
CeremonyClient.create_guild_registration_ceremony(
guild.name,
user["did"],
"did:web:guildhouse.dev:user:tking"
)
case ceremony_result do
{:ok, response} ->
Guilds.update_guild(guild, %{registration_ceremony_id: response.ceremony_id})
{:error, _reason} ->
:ok
end
{:noreply,
socket
|> put_flash(:info, "Guild registration submitted for approval.")
|> push_navigate(to: ~p"/guilds/#{guild.slug}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-lg p-6 space-y-4">
<header>
<.link navigate={~p"/guilds"} class="text-sm text-blue-600 underline">&larr; Guilds</.link>
<h1 class="text-2xl font-semibold mt-2">Register Guild</h1>
<p class="text-sm text-zinc-500">Submit a new guild for hub operator approval.</p>
</header>
<.form for={@form} phx-change="validate" phx-submit="register" class="space-y-4">
<div>
<label for="guild_name" class="block text-sm font-medium text-zinc-700">Guild Name</label>
<input
type="text"
id="guild_name"
name="guild[name]"
value={@form[:name].value}
class="mt-1 block w-full rounded border border-zinc-300 px-3 py-2 text-sm"
placeholder="e.g. Gator Guild"
required
/>
<p :if={@form[:name].errors != []} class="mt-1 text-xs text-red-600">
{error_to_string(@form[:name].errors)}
</p>
</div>
<div>
<label for="guild_type" class="block text-sm font-medium text-zinc-700">Guild Type</label>
<select
id="guild_type"
name="guild[guild_type]"
class="mt-1 block w-full rounded border border-zinc-300 px-3 py-2 text-sm"
required
>
<option value="">Select type...</option>
<option value="msp" selected={@form[:guild_type].value == "msp"}>MSP Managed Service Provider</option>
<option value="isv" selected={@form[:guild_type].value == "isv"}>ISV Independent Software Vendor</option>
<option value="nsp" selected={@form[:guild_type].value == "nsp"}>NSP Network Service Provider</option>
</select>
</div>
<div>
<label for="guild_description" class="block text-sm font-medium text-zinc-700">Description</label>
<textarea
id="guild_description"
name="guild[description]"
class="mt-1 block w-full rounded border border-zinc-300 px-3 py-2 text-sm"
rows="3"
placeholder="What does this guild do?"
>{@form[:description].value}</textarea>
</div>
<div class="pt-2">
<button
type="submit"
disabled={@submitting}
class="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
Submit Registration
</button>
</div>
</.form>
</div>
"""
end
defp error_to_string(errors) do
Enum.map_join(errors, ", ", fn {msg, _opts} -> msg end)
end
defp slugify(name) do
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.replace(~r/[\s]+/, "-")
|> String.trim("-")
end
end

View file

@ -0,0 +1,149 @@
defmodule GuildhallWeb.GuildLive.Schematic do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
alias Guildhall.Orchestrator.{SchematicClient, SchematicTemplate, RealizationPoller}
@impl true
def mount(%{"slug" => slug}, _session, socket) do
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
unless guild.status in ["approved", "active"] do
{:ok,
socket
|> put_flash(:error, "Guild must be approved before deploying a schematic.")
|> push_navigate(to: ~p"/guilds/#{slug}")}
else
existing = GuildSchematics.get_for_guild(guild.id)
if existing do
{:ok,
socket
|> put_flash(:info, "Schematic already deployed.")
|> push_navigate(to: ~p"/guilds/#{slug}/realization")}
else
template_result = SchematicTemplate.load_template(guild.guild_type)
{:ok,
socket
|> assign(:page_title, "#{guild.name} — Deploy Schematic")
|> assign(:guild, guild)
|> assign(:template, elem_ok(template_result))
|> assign(:template_error, elem_error(template_result))
|> assign(:deploying, false)
|> assign(:deploy_error, nil)}
end
end
end
@impl true
def handle_event("deploy_schematic", _params, socket) do
guild = socket.assigns.guild
template = socket.assigns.template
socket = assign(socket, :deploying, true)
params = %{
"guild_slug" => guild.slug,
"guild_name" => guild.name,
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
"registrant_did" => guild.registrant_did
}
rendered = SchematicTemplate.render_template(template, params)
operations = SchematicTemplate.to_fork_operations(rendered)
{source_name, source_version} = SchematicTemplate.source_schematic(template)
schematic_name = "#{guild.slug}-#{guild.guild_type}-founding"
schematic_version = "1.0.0"
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,
socket
|> assign(:deploying, false)
|> assign(:deploy_error, inspect(reason))}
end
end
defp elem_ok({:ok, val}), do: val
defp elem_ok(_), do: nil
defp elem_error({:error, reason}), do: inspect(reason)
defp elem_error(_), do: nil
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-2xl p-6 space-y-6">
<header>
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">&larr; {@guild.name}</.link>
<h1 class="text-2xl font-semibold mt-2">Deploy Founding Schematic</h1>
</header>
<div :if={@template_error} class="border border-red-200 bg-red-50 rounded p-4 text-sm text-red-700">
Failed to load template: {@template_error}
</div>
<section :if={@template} class="space-y-4">
<div class="text-sm space-y-2">
<p>
Template: <span class="font-mono font-semibold">{@guild.guild_type}-founding</span>
</p>
<div class="rounded border border-zinc-200 p-3 text-xs font-mono bg-zinc-50 max-h-64 overflow-y-auto">
<pre>{inspect(@template, pretty: true, limit: :infinity)}</pre>
</div>
</div>
<div class="text-sm text-zinc-500 space-y-1">
<p>This will:</p>
<ol class="list-decimal ml-5 space-y-1">
<li>Fork the founding schematic template for {@guild.name}</li>
<li>Create a deployment binding (production)</li>
<li>Trigger realization across all reconciler sections</li>
<li>Transition guild to <span class="font-semibold">active</span> status</li>
</ol>
</div>
<div :if={@deploy_error} class="border border-red-200 bg-red-50 rounded p-4 text-sm text-red-700">
Deploy failed: {@deploy_error}
</div>
<button
phx-click="deploy_schematic"
disabled={@deploying}
class={"rounded px-4 py-2 text-sm text-white #{if @deploying, do: "bg-zinc-400 cursor-not-allowed", else: "bg-blue-600 hover:bg-blue-700"}"}
>
{if @deploying, do: "Deploying…", else: "Deploy Schematic"}
</button>
</section>
</div>
"""
end
end

View file

@ -0,0 +1,227 @@
defmodule GuildhallWeb.GuildLive.Show do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics, GuildMemberships}
alias Guildhall.Orchestrator.CeremonyClient
@hub_operator_did "did:web:guildhouse.dev:user:tking"
@poll_interval_ms 5_000
@impl true
def mount(%{"slug" => slug}, _session, socket) do
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
if connected?(socket) do
Phoenix.PubSub.subscribe(Guildhall.PubSub, "guild:#{slug}")
if guild.status == "pending_approval" && guild.registration_ceremony_id do
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
end
end
{:ok,
socket
|> assign(:page_title, guild.name)
|> assign(:guild, guild)
|> assign(:ceremony_status, nil)
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)
|> assign(:schematic, GuildSchematics.get_for_guild(guild.id))
|> assign(:member_count, length(GuildMemberships.active_members(guild.id)))}
end
@impl true
def handle_info(:poll_ceremony, socket) do
guild = socket.assigns.guild
case CeremonyClient.get_ceremony(guild.registration_ceremony_id) do
{:ok, response} ->
socket = assign(socket, :ceremony_status, response.status)
socket =
case response.status do
"approved" ->
{:ok, %{guild: updated}} = Guilds.approve_guild(guild)
assign(socket, :guild, updated)
"denied" ->
{:ok, updated} = Guilds.update_guild(guild, %{status: "denied"})
assign(socket, :guild, updated)
_ ->
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
socket
end
{:noreply, socket}
{:error, _} ->
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
{:noreply, socket}
end
end
@impl true
def handle_info({:guild_updated, _}, socket) do
guild = Guilds.get_guild_by_slug(socket.assigns.guild.slug)
{:noreply, assign(socket, :guild, guild)}
end
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
@impl true
def handle_event("approve_guild", _params, socket) do
guild = socket.assigns.guild
case CeremonyClient.approve_ceremony(
guild.registration_ceremony_id,
socket.assigns.current_user["did"],
"hub_operator"
) do
{:ok, %{status: "approved"}} ->
{:ok, %{guild: updated}} = Guilds.approve_guild(guild)
{:noreply,
socket
|> assign(:guild, updated)
|> assign(:ceremony_status, "approved")
|> put_flash(:info, "#{guild.name} approved.")}
{:ok, _} ->
{:noreply, put_flash(socket, :info, "Approval recorded.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Approval failed: #{inspect(reason)}")}
end
end
@impl true
def handle_event("deny_guild", _params, socket) do
guild = socket.assigns.guild
case CeremonyClient.deny_ceremony(
guild.registration_ceremony_id,
socket.assigns.current_user["did"],
"hub_operator",
"Denied by hub operator"
) do
{:ok, _} ->
{:ok, updated} = Guilds.update_guild(guild, %{status: "denied"})
{:noreply,
socket
|> assign(:guild, updated)
|> assign(:ceremony_status, "denied")
|> put_flash(:info, "#{guild.name} denied.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Denial failed: #{inspect(reason)}")}
end
end
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("active"), do: "bg-emerald-100 text-emerald-700"
defp status_class("denied"), do: "bg-red-100 text-red-700"
defp status_class("suspended"), do: "bg-zinc-100 text-zinc-700"
defp status_class(_), do: "bg-zinc-100 text-zinc-700"
defp schematic_status_class("realized"), do: "text-emerald-600"
defp schematic_status_class("realizing"), do: "text-amber-600"
defp schematic_status_class("failed"), do: "text-red-600"
defp schematic_status_class(_), do: "text-zinc-500"
defp type_label("msp"), do: "Managed Service Provider"
defp type_label("isv"), do: "Independent Software Vendor"
defp type_label("nsp"), do: "Network Service Provider"
defp type_label(other), do: other
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-2xl p-6 space-y-6">
<header>
<.link navigate={~p"/guilds"} class="text-sm text-blue-600 underline">&larr; Guilds</.link>
<h1 class="text-2xl font-semibold mt-2">{@guild.name}</h1>
<span class={"inline-block rounded-full px-3 py-1 text-xs font-medium #{status_class(@guild.status)}"}>
{@guild.status}
</span>
</header>
<section class="space-y-3 text-sm">
<div class="grid grid-cols-2 gap-2">
<div class="text-zinc-500">Guild Type</div>
<div>{type_label(@guild.guild_type)}</div>
<div class="text-zinc-500">Guild ID</div>
<div class="font-mono">0x{Integer.to_string(@guild.guild_id, 16) |> String.pad_leading(3, "0")}</div>
<div class="text-zinc-500">Trust Domain</div>
<div class="font-mono">{@guild.trust_domain || ""}</div>
<div class="text-zinc-500">Registrant</div>
<div class="font-mono text-xs">{@guild.registrant_did}</div>
<div class="text-zinc-500">Contact</div>
<div class="font-mono text-xs">{@guild.contact_did}</div>
</div>
<div :if={@guild.description} class="border-t border-zinc-100 pt-3">
<div class="text-zinc-500 mb-1">Description</div>
<p>{@guild.description}</p>
</div>
</section>
<section :if={@guild.status == "pending_approval" && @guild.registration_ceremony_id} class="border border-amber-200 bg-amber-50 rounded p-4 text-sm">
<h2 class="font-semibold text-amber-800 mb-2">Awaiting Approval</h2>
<p class="text-amber-700 mb-1">
Ceremony: <span class="font-mono text-xs">{@guild.registration_ceremony_id}</span>
</p>
<p :if={@ceremony_status} class="text-amber-700">
Status: <span class="font-semibold">{@ceremony_status}</span>
</p>
<div :if={@is_hub_operator} class="flex gap-2 mt-3">
<button phx-click="approve_guild" class="rounded bg-emerald-600 px-3 py-1.5 text-xs text-white">
Approve
</button>
<button phx-click="deny_guild" class="rounded bg-red-600 px-3 py-1.5 text-xs text-white">
Deny
</button>
</div>
</section>
<section :if={@guild.status == "denied"} class="border border-red-200 bg-red-50 rounded p-4 text-sm">
<p class="text-red-700">This guild registration was denied.</p>
</section>
<section :if={@guild.status in ["approved", "active"]} class="space-y-3">
<div class="border border-emerald-200 bg-emerald-50 rounded p-4 text-sm text-emerald-700">
Guild is active and operational. {@member_count} active member(s).
</div>
<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">
Deploy Schematic
</.link>
<.link :if={@schematic} navigate={~p"/guilds/#{@guild.slug}/realization"} class="rounded bg-zinc-700 px-3 py-1.5 text-white">
Realization Dashboard
</.link>
<.link navigate={~p"/guilds/#{@guild.slug}/join"} class="rounded border border-blue-600 px-3 py-1.5 text-blue-600">
Join Guild
</.link>
<.link navigate={~p"/guilds/#{@guild.slug}/members"} class="rounded border border-zinc-300 px-3 py-1.5 text-zinc-700">
Manage Members
</.link>
</div>
<div :if={@schematic} class="rounded border border-zinc-200 p-3 text-sm">
<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>
</section>
</div>
"""
end
end

View file

@ -0,0 +1,24 @@
defmodule GuildhallWeb.Plugs.Auth do
@moduledoc false
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :current_user) do
nil ->
conn
|> put_flash(:error, "Please sign in to continue.")
|> redirect(to: "/auth/login")
|> halt()
user ->
assign(conn, :current_user, user)
end
end
def fetch_current_user(conn, _opts) do
assign(conn, :current_user, get_session(conn, :current_user))
end
end

View file

@ -1,6 +1,8 @@
defmodule GuildhallWeb.Router do
use GuildhallWeb, :router
import GuildhallWeb.Plugs.Auth, only: [fetch_current_user: 2]
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@ -8,24 +10,41 @@ defmodule GuildhallWeb.Router do
plug :put_root_layout, html: {GuildhallWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug :accepts, ["json"]
end
# Public auth routes (no auth required)
scope "/auth", GuildhallWeb do
pipe_through :browser
get "/login", AuthController, :login
get "/callback", AuthController, :callback
get "/logout", AuthController, :logout
end
# Authenticated LiveView routes
scope "/", GuildhallWeb do
pipe_through :browser
live "/", DashboardLive, :index
live "/ceremonies", CeremonyLive.Index, :index
live "/artifacts", ArtifactLive.Index, :index
live_session :authenticated, on_mount: {GuildhallWeb.AuthHooks, :require_auth} do
live "/", DashboardLive, :index
live "/ceremonies", CeremonyLive.Index, :index
live "/artifacts", ArtifactLive.Index, :index
live "/guilds", GuildLive.Index, :index
live "/guilds/register", GuildLive.Register, :new
live "/guilds/:slug", GuildLive.Show, :show
live "/guilds/:slug/schematic", GuildLive.Schematic, :schematic
live "/guilds/:slug/realization", GuildLive.Realization, :realization
live "/guilds/:slug/join", GuildLive.Join, :join
live "/guilds/:slug/members", GuildLive.Members, :members
end
end
# Health check endpoint for Kubernetes probes + LB targets.
# GET /health — returns 200 when Phoenix is up AND the Ecto pool
# can query the DB; 503 otherwise. Unauthenticated (the whole point
# is that it's reachable without credentials).
scope "/health", GuildhallWeb do
pipe_through :api
get "/", HealthController, :check

View file

@ -65,7 +65,9 @@ defmodule GuildhallWeb.MixProject do
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
{:bandit, "~> 1.5"},
{:oidcc, "~> 3.2"},
{:req, "~> 0.5"}
]
end

View file

@ -44,3 +44,14 @@ config :phoenix_live_view,
debug_heex_annotations: true,
debug_attributes: true,
enable_expensive_runtime_checks: true
config :guildhall_web, :oidc,
issuer: "https://auth.guildhouse.dev/realms/guildhouse",
client_id: "guildhall-web",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri: "http://localhost:4000/auth/callback"
config :guildhall_orchestrator,
ceremony_service_url: "localhost:50053",
schematic_service_url: "localhost:9091",
ffc_schematic_service_url: "localhost:9091"

View file

@ -43,14 +43,20 @@ if config_env() == :prod do
http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
secret_key_base: secret_key_base
# K8s cluster connection (future — orchestrator will use this)
# config :guildhall_orchestrator,
# kubeconfig: System.get_env("KUBECONFIG") || "~/.kube/config",
# context: System.get_env("K8S_CONTEXT")
config :guildhall_web, :oidc,
issuer:
System.get_env("OIDC_ISSUER") || "https://auth.guildhouse.dev/realms/guildhouse",
client_id: System.get_env("OIDC_CLIENT_ID") || "guildhall-web",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri:
System.get_env("OIDC_REDIRECT_URI") ||
"https://guildhall.guildhouse.dev/auth/callback"
# Keycloak OIDC (future — auth)
# config :guildhall_web, :oidc,
# issuer: System.get_env("OIDC_ISSUER") || "https://auth.guildhouse.dev/realms/guildhouse",
# client_id: System.get_env("OIDC_CLIENT_ID"),
# client_secret: System.get_env("OIDC_CLIENT_SECRET")
config :guildhall_orchestrator,
ceremony_service_url:
System.get_env("CEREMONY_SERVICE_URL") || "localhost:50053",
schematic_service_url:
System.get_env("SCHEMATIC_SERVICE_URL") || "localhost:9091",
ffc_schematic_service_url:
System.get_env("FFC_SCHEMATIC_SERVICE_URL") || "localhost:9091"
end

View file

@ -37,9 +37,12 @@
#
# SECRET_KEY_BASE="$(cd /home/tking/projects/substrate-project/guildhall && mix phx.gen.secret)"
#
# OIDC_CLIENT_SECRET="<from Keycloak guildhall-web client credentials>"
#
# kubectl create secret generic guildhall-app-secrets \
# --from-literal=SECRET_KEY_BASE="$SECRET_KEY_BASE" \
# --from-literal=DATABASE_URL="ecto://guildhall:$DB_PASSWORD@guildhall-postgres:5432/guildhall" \
# --from-literal=OIDC_CLIENT_SECRET="$OIDC_CLIENT_SECRET" \
# --namespace=guildhall
#
# Note: `ecto://` scheme, not `postgres://` — `config/runtime.exs`
@ -55,5 +58,20 @@
# namespace: guildhall
# type: Opaque
# data:
# SECRET_KEY_BASE: <b64 "<64-byte-base64-session-key>">
# DATABASE_URL: <b64 "ecto://guildhall:<pw>@guildhall-postgres:5432/guildhall">
# SECRET_KEY_BASE: <b64 "<64-byte-base64-session-key>">
# DATABASE_URL: <b64 "ecto://guildhall:<pw>@guildhall-postgres:5432/guildhall">
# OIDC_CLIENT_SECRET: <b64 "<keycloak-client-secret>">
#
# ---------- ceremony-service-secrets ----------
# Consumed by the ceremony-service Deployment.
#
# kubectl create secret generic ceremony-service-secrets \
# --from-literal=DATABASE_URL="postgres://ceremony:$CEREMONY_DB_PW@guildhall-postgres:5432/ceremony" \
# --namespace=guildhall
#
# ---------- schematic-server-secrets ----------
# Consumed by the ffc-schematic-server Deployment.
#
# kubectl create secret generic schematic-server-secrets \
# --from-literal=DATABASE_URL="postgres://schematic:$SCHEMATIC_DB_PW@guildhall-postgres:5432/schematic" \
# --namespace=guildhall

View file

@ -54,6 +54,25 @@ spec:
secretKeyRef:
name: guildhall-app-secrets
key: SECRET_KEY_BASE
# OIDC (Keycloak)
- name: OIDC_ISSUER
value: "https://auth.guildhouse.dev/realms/guildhouse"
- name: OIDC_CLIENT_ID
value: guildhall-web
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: guildhall-app-secrets
key: OIDC_CLIENT_SECRET
- name: OIDC_REDIRECT_URI
value: "https://guildhall.guildhouse.dev/auth/callback"
# gRPC service URLs (in-cluster ClusterIP DNS)
- name: CEREMONY_SERVICE_URL
value: "ceremony-service.guildhall.svc.cluster.local:50053"
- name: SCHEMATIC_SERVICE_URL
value: "ffc-schematic-server.guildhall.svc.cluster.local:9091"
- name: FFC_SCHEMATIC_SERVICE_URL
value: "ffc-schematic-server.guildhall.svc.cluster.local:9091"
# Ecto
- name: DATABASE_URL
valueFrom:

View file

@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ceremony-service
namespace: guildhall
labels:
app.kubernetes.io/name: ceremony-service
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: ceremony-engine
app.kubernetes.io/managed-by: manual
app.kubernetes.io/version: v0.1.0
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ceremony-service
template:
metadata:
labels:
app: ceremony-service
app.kubernetes.io/name: ceremony-service
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: ceremony-engine
app.kubernetes.io/version: v0.1.0
spec:
imagePullSecrets:
- name: guildhall-registry
containers:
- name: ceremony-service
image: git.guildhouse.dev/tking/ceremony-service:v0.1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 50053
name: grpc
protocol: TCP
env:
- name: RUST_LOG
value: info
- name: LISTEN_ADDR
value: "0.0.0.0:50053"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ceremony-service-secrets
key: DATABASE_URL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
grpc:
port: 50053
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
grpc:
port: 50053
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 15

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: ceremony-service
namespace: guildhall
labels:
app.kubernetes.io/name: ceremony-service
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: ceremony-engine
app.kubernetes.io/managed-by: manual
spec:
type: ClusterIP
ports:
- port: 50053
targetPort: grpc
protocol: TCP
name: grpc
selector:
app: ceremony-service

View file

@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ffc-schematic-server
namespace: guildhall
labels:
app.kubernetes.io/name: ffc-schematic-server
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: schematic-engine
app.kubernetes.io/managed-by: manual
app.kubernetes.io/version: v0.1.0
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ffc-schematic-server
template:
metadata:
labels:
app: ffc-schematic-server
app.kubernetes.io/name: ffc-schematic-server
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: schematic-engine
app.kubernetes.io/version: v0.1.0
spec:
imagePullSecrets:
- name: guildhall-registry
containers:
- name: ffc-schematic-server
image: git.guildhouse.dev/tking/ffc-schematic-server:v0.1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9091
name: grpc
protocol: TCP
env:
- name: RUST_LOG
value: info
- name: LISTEN_ADDR
value: "0.0.0.0:9091"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: schematic-server-secrets
key: DATABASE_URL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
grpc:
port: 9091
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
grpc:
port: 9091
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 15

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: ffc-schematic-server
namespace: guildhall
labels:
app.kubernetes.io/name: ffc-schematic-server
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: schematic-engine
app.kubernetes.io/managed-by: manual
spec:
type: ClusterIP
ports:
- port: 9091
targetPort: grpc
protocol: TCP
name: grpc
selector:
app: ffc-schematic-server

View file

@ -1,6 +1,8 @@
%{
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
@ -10,13 +12,25 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"},
"gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"google_protos": {:hex, :google_protos, "0.4.0", "93e1be2c1a07517ffed761f69047776caf35e4acd385aac4f5ce4fedd07f3660", [:mix], [{:protobuf, "~> 0.10", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "4c54983d78761a3643e2198adf0f5d40a5a8b08162f3fc91c50faa257f3fa19f"},
"googleapis": {:hex, :googleapis, "0.1.0", "13770f3f75f5b863fb9acf41633c7bc71bad788f3f553b66481a096d083ee20e", [:mix], [{:protobuf, "~> 0.12", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "1989a7244fd17d3eb5f3de311a022b656c3736b39740db46506157c4604bd212"},
"grpc": {:hex, :grpc, "0.11.5", "5dbde9420718b58712779ad98fff1ef50349ca0fa7cc0858ae0f826015068654", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:cowboy, "~> 2.10", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowlib, "~> 2.12", [hex: :cowlib, repo: "hexpm", optional: false]}, {:flow, "~> 1.2", [hex: :flow, repo: "hexpm", optional: false]}, {:googleapis, "~> 0.1.0", [hex: :googleapis, repo: "hexpm", optional: false]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.14", [hex: :protobuf, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a5d8673ef16649bef0903bca01c161acfc148e4d269133b6834b2af1f07f45e"},
"gun": {:hex, :gun, "2.3.0", "c1eb7be3b5178f6e67edd373f954360de7d7933f2d5a57686affd3b279d76cdf", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "c3bfbbb8f146a6c5ffb2c487f06a3ca4a57e90220b07a1f97eb69a4e7b176035"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oidcc": {:hex, :oidcc, "3.7.2", "2047949832ca7984d6d9c218cc5f23e8096bf50ebb809124d3a01673ee2bfe12", [:mix, :rebar3], [{:igniter, "~> 0.6.3 or ~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "e3f1ed91509fdeb31ec8b9de4ecda0e80cb68b463a9f5b7a9ee1ee40e521e445"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
@ -26,12 +40,17 @@
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"protobuf": {:hex, :protobuf, "0.16.0", "d1878725105d49162977cf3408ccc3eac4f3532e26e5a9e250f2c624175d10f6", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f0d0d3edd8768130f24cc2cfc41320637d32c80110e80d13f160fa699102c828"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"},
"tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}