Compare commits

..

No commits in common. "50c488b92b2e795bc48c1a5c0d0ad3dea479e149d54296a1c3faf22622a7222f" and "48a7495ef5541981015b8a908dd416fe6e12a9f6f60b0ed006bfa312b3463adf" have entirely different histories.

73 changed files with 27 additions and 7419 deletions

View file

@ -1,53 +0,0 @@
# Build artifacts
_build/
deps/
apps/*/_build/
apps/*/deps/
# Editor / dev state
.elixir_ls/
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# VCS
.git/
.gitignore
.gitattributes
# CI / deploy manifests (not needed in image)
k8s/
.forgejo/
.github/
# Generated web assets — phx.digest regenerates these in the builder
# stage. The source assets in apps/guildhall_web/assets/ are what the
# builder needs.
apps/guildhall_web/priv/static/assets/
apps/guildhall_web/priv/static/cache_manifest.json
# Test + cover
apps/*/test/
cover/
.coverdata
# Docs + tmp + logs
doc/
tmp/
*.log
# Env (never ship into images)
.env
.env.*
!.env.example
# Release artifacts if any were built locally
rel/
releases/
# Docs + design files at repo root
*.md
!README.md
AGENTS.md

View file

@ -1 +0,0 @@
# Placeholder for Forgejo CI workflows (WS2 follow-up)

130
CLAUDE.md
View file

@ -1,130 +0,0 @@
# Guildhall — Claude Context
## What this is
Phoenix umbrella app — ceremony orchestrator + governance UI for the Guildhouse platform. Onboards guilds (MSP/ISV/NSP organizations), deploys founding schematics via gRPC, and manages membership with ceremony-engine approval flows.
## Architecture
```
guildhall/
├── apps/
│ ├── guildhall_ops_db — Ecto schemas + contexts (Postgres)
│ ├── guildhall_orchestrator — gRPC clients, GenServers, template engine
│ ├── guildhall_web — Phoenix LiveView UI + OIDC auth
│ ├── guildhall_chronicle — Chronicle event consumer (stub)
│ └── guildhall_graph_bridge — Microsoft Graph reconciler (stub)
├── config/ — dev.exs, runtime.exs (env-var driven)
└── k8s/ — Kubernetes manifests (00-93, numeric order)
```
## External services (gRPC)
| Service | Port | Proto | Client module |
|---------|------|-------|---------------|
| ceremony-service (Rust) | 50053 | `ceremony.v1.CeremonyService` | `Guildhall.Orchestrator.CeremonyClient` |
| ffc-schematic-server (Rust) | 9091 | `schematic.v1.SchematicsService` + `FfcSchematicService` | `Guildhall.Orchestrator.SchematicClient` |
Env vars: `CEREMONY_SERVICE_URL`, `SCHEMATIC_SERVICE_URL`, `FFC_SCHEMATIC_SERVICE_URL`. In-cluster DNS: `<name>.guildhall.svc.cluster.local:<port>`.
## Auth
Keycloak OIDC at `auth.guildhouse.dev/realms/guildhouse`, client `guildhall-web`. Env vars: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`.
- `GuildhallWeb.Plugs.Auth``fetch_current_user/2` plug (reads session)
- `GuildhallWeb.AuthController` — login/callback/logout controller
- `GuildhallWeb.AuthHooks` — LiveView `on_mount(:require_auth)`
- User stored in session as map: `%{"did" => "did:web:...", "email" => ..., "name" => ..., "sub" => ..., "preferred_username" => ...}`
- DID format: `did:web:guildhouse.dev:user:<preferred_username>`
- Hub operator DID: `did:web:guildhouse.dev:user:tking`
## Database tables (guildhall_ops_db)
Pre-existing: `governed_artifacts`, `deployment_states`, `verification_results`
New guild tables:
- **guilds**`guild_id` (10-bit, 0x0100x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did`
- **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/forked/binding_created/realizing/realized/failed), `realization_snapshot` (map)
- **guild_memberships** — FK to guilds, `user_did`, `role` (apprentice/journeyman/master), `status` (pending/approved/denied/active/suspended/removed), `membership_ceremony_id`, unique on (guild_id, user_did)
## Key flows
### Guild registration
1. User fills form at `/guilds/register` → creates guild row (status: pending_approval) + ceremony via `CeremonyClient.create_guild_registration_ceremony`
2. Hub operator (tking) sees pending guild at `/guilds/:slug`, clicks Approve → `CeremonyClient.approve_ceremony` + `Guilds.approve_guild/1` (Ecto.Multi: updates guild to "approved" + creates registrant as guild master)
### Schematic deployment
1. Guild master visits `/guilds/:slug/schematic` → loads TOML template for guild type from `priv/schematic_templates/`
2. Click Deploy → `SchematicClient.fork_schematic``create_deployment_binding``realize_ffc_schematic`
3. Creates `guild_schematics` row, starts `RealizationPoller.watch/2`
4. `/guilds/:slug/realization` shows 7 reconciler sections via PubSub live updates
### Member onboarding
1. User visits `/guilds/:slug/join` → creates membership (pending) + ceremony for guild master approval
2. Guild master at `/guilds/:slug/members` → approve/deny via ceremony engine, manage roles
## Routes
All guild routes are under authenticated `live_session :authenticated`:
```
/ DashboardLive
/guilds GuildLive.Index
/guilds/register GuildLive.Register
/guilds/:slug GuildLive.Show
/guilds/:slug/schematic GuildLive.Schematic
/guilds/:slug/realization GuildLive.Realization
/guilds/:slug/join GuildLive.Join
/guilds/:slug/members GuildLive.Members
/ceremonies CeremonyLive.Index
/artifacts ArtifactLive.Index
```
Public: `/auth/login`, `/auth/callback`, `/auth/logout`, `/health`
## Orchestrator supervision tree
- `Guildhall.Orchestrator.CeremonyOrchestrator` — existing ceremony workflow coordinator
- `Guildhall.Orchestrator.RealizationPoller` — GenServer, polls realization status every 5s for watched IDs, broadcasts on `"realization:#{guild_slug}"` PubSub topic
## Schematic templates
TOML files in `apps/guildhall_orchestrator/priv/schematic_templates/`:
- `msp-founding.toml` — attestation tier 2, MFA, single_approval
- `isv-founding.toml` — attestation tier 1, no MFA, single_approval
- `nsp-founding.toml` — attestation tier 3, MFA + hardware, multi_party quorum 2
`SchematicTemplate.render_template/2` substitutes `{{guild_slug}}`, `{{guild_name}}`, `{{trust_domain}}`, `{{registrant_did}}`.
## Generated protobuf modules
In `apps/guildhall_orchestrator/lib/guildhall/orchestrator/proto/`:
- `ceremony/v1/ceremony.pb.ex``Ceremony.V1.CeremonyService.Stub`
- `schematic/v1/schematics.pb.ex``Schematic.V1.SchematicsService.Stub`
- `schematic/v1/ffc_schematic.pb.ex``Schematic.V1.FfcSchematicService.Stub`
These were generated with `protoc --elixir_out=plugins=grpc`. Re-generate if protos change.
## K8s manifests
Files in `k8s/` applied in numeric order. Key additions:
- `90-ceremony-service-deployment.yaml` + `91-*-service.yaml` — ClusterIP on 50053
- `92-schematic-server-deployment.yaml` + `93-*-service.yaml` — ClusterIP on 9091
- `70-guildhall-deployment.yaml` — includes OIDC + gRPC env vars
- Secrets created imperatively (see `50-guildhall-secrets-template.yaml`)
## Commands
```bash
mix deps.get # fetch deps
mix ecto.migrate # run migrations
mix compile # verify compilation
mix phx.server # start dev server (localhost:4000)
mix test # run tests
```
## Commit convention
Sign commits as `tking@guildhouse.dev`:
```bash
git -c user.email=tking@guildhouse.dev commit -s -m "message"
```

View file

@ -1,304 +0,0 @@
# Guildhall deploy exploratory — Talos/Hetzner cluster state
**Date:** 2026-04-21
**Scope:** Read-only audit of the Talos/Hetzner Kubernetes cluster to inform Guildhall's initial deployment.
**Method:** `kubectl` against the cluster via `~/projects/substrate-project/guildhouse-talos-bootstrap/kubeconfig`. No mutations.
**Takeaway (synthesis at end):** Guildhall fits cleanly into the existing Keycloak/Forgejo deployment pattern: plain `Deployment` + `Deployment`-backed Postgres + Longhorn PVC + Hetzner LoadBalancer + Cloudflare-terminated TLS. No new infrastructure components required. The v1 substrate foundation (bascule / quartermaster / spire / chronicle / substrate-operator) is Flux-manifested but broken and not running; governance integration is explicitly follow-up work, not blocking.
---
## 1. Cluster basics
| | |
|---|---|
| Control plane endpoint | `https://178.104.100.159:6443` |
| kubectl client | v1.32.2 |
| kubectl server | v1.32.3 |
| Nodes | 5 (3 control-plane + 2 workers), all Ready |
| OS | Talos v1.9.5 |
| Kernel | 6.12.18-talos |
| Container runtime | containerd 2.0.3 |
| Cluster age | 10 days |
```
gsh-cp-01 control-plane 10.0.1.10
gsh-cp-02 control-plane 10.0.1.20
gsh-cp-03 control-plane 10.0.1.21
gsh-worker-01 worker 10.0.1.22
gsh-worker-02 worker 10.0.1.30
```
Matches the memory-carried description (Hetzner Talos cluster 2026-04-11: Talos 1.9.5, 5 nodes, 3 CP + 2 worker). No drift.
## 2. Namespace inventory
| Namespace | Purpose | Workloads |
|---|---|---|
| `cert-manager` | cert-manager 3 controllers (cert-manager, cainjector, webhook) | 3 Deployments |
| `flux-system` | Flux GitOps | 4 Deployments (source / kustomize / helm / notification controllers) |
| `forgejo` | Forgejo git (self-hosted) | Deployment + Postgres Deployment + Runner Deployment (0/1, stuck) |
| `keycloak` | Keycloak OIDC IdP | Deployment + Postgres Deployment |
| `longhorn-system` | Longhorn CSI storage | 5 DaemonSets + 6 Deployments + UI |
| `kube-system`, `kube-public`, `kube-node-lease` | K8s system | — |
| `default` | Empty | — |
**Application workloads:** `forgejo`, `keycloak`. These are the reference patterns for Guildhall.
## 3. Ingress / gateway state
**No traditional ingress controller, no Gateway API:**
- `kubectl get ingressclasses` → No resources found
- `kubectl get gatewayclasses` → server doesn't have the resource type (Gateway API CRDs not installed)
- No HAProxy / nginx / traefik / istio / kong pods anywhere
**Traffic reaches services via `type: LoadBalancer` directly**, backed by the **Hetzner Cloud Controller Manager** (`hcloud-cloud-controller-manager` running in `kube-system`). Each LoadBalancer Service provisions a real Hetzner Cloud Load Balancer via annotations:
```yaml
annotations:
load-balancer.hetzner.cloud/location: nbg1
load-balancer.hetzner.cloud/name: keycloak-lb-v2
load-balancer.hetzner.cloud/type: lb11
load-balancer.hetzner.cloud/use-private-ip: "false"
```
Cilium Envoy DaemonSet pods exist on every node (`cilium-envoy` for L7 proxy features — CiliumNetworkPolicy L7 filtering, not Gateway API). `enable-l7-proxy: true` is set in the Cilium config.
**Existing LoadBalancer services and their public addresses:**
| Service | Namespace | IPv4 | IPv6 | Ports |
|---|---|---|---|---|
| `forgejo-http` | forgejo | `46.225.47.75` | `2a01:4f8:1c1f:65bb::1` | 80 → 30000, 22 → 30022 |
| `keycloak` | keycloak | `162.55.157.168` | `2a01:4f8:1c1d:1109::1` | 80 → 30080 |
Both expose port 80 only. No in-cluster TLS termination. TLS is terminated **upstream at Cloudflare** (`git.guildhouse.dev` and `auth.guildhouse.dev` resolve to Cloudflare IPs; Cloudflare proxies to the Hetzner LB IPs).
## 4. cert-manager / TLS
cert-manager is installed and healthy but **no Certificate resources exist anywhere on the cluster**.
| ClusterIssuer | Status |
|---|---|
| `letsencrypt-prod` | Ready |
| `letsencrypt-staging` | Ready |
Both ClusterIssuers are provisioned and ready to issue. They just aren't being used yet — TLS is currently handled via Cloudflare's Universal SSL / Full mode at the edge, with HTTP between Cloudflare and the Hetzner LBs.
**Implication for Guildhall:** can choose between the existing Cloudflare-termination pattern (simplest, matches forgejo/keycloak) or start using cert-manager (more work, cluster-integrated certs). The former is tonight's path; the latter is a hygiene follow-up.
## 5. Database patterns
**No Postgres operator** installed. CRDs checked (all absent):
- CloudNativePG (`clusters.postgresql.cnpg.io`)
- Zalando postgres-operator (`postgresqls.acid.zalan.do`)
- Crunchy PGO (`postgresclusters.postgres-operator.crunchydata.com`)
**Existing pattern is plain Deployment + PVC:**
```
forgejo-postgres Deployment postgres:16 PVC: forgejo-db 10Gi longhorn
keycloak-postgres Deployment postgres:16 PVC: keycloak-db 5Gi longhorn
```
**Storage:** Longhorn 1.x, single StorageClass `longhorn` (default). All PVCs use it. 5 DaemonSet replicas of longhorn-manager confirm storage is healthy across all nodes.
**Current PVCs:**
| PVC | Namespace | Size | StorageClass |
|---|---|---|---|
| forgejo-data | forgejo | 20Gi | longhorn |
| forgejo-db | forgejo | 10Gi | longhorn |
| runner-cache | forgejo | 5Gi | longhorn |
| keycloak-db | keycloak | 5Gi | longhorn |
## 6. Secrets management
**None of the common secret managers are installed:**
- External Secrets Operator: absent
- Sealed Secrets: absent
- SPIRE/SPIFFE: absent (Flux has a Kustomization for it but the `spire-system` namespace doesn't exist — see §10 Flux state)
- Vault: absent
**Secrets are plain Opaque `Secret` resources.** Examples:
- `forgejo/forgejo-secrets` (3 keys)
- `keycloak/keycloak-secrets` (2 keys)
Managed out-of-band (likely committed to the private Flux source repo or applied via kubectl during bootstrap). No rotation mechanism visible.
## 7. Existing workload patterns
**Reference: `keycloak` Deployment** (cleanest example — the only Flux Kustomization that's `Ready`):
- **Image:** `quay.io/keycloak/keycloak:26.0` (public registry)
- **Env composition:** mix of literal `value:` (DB host, DB port, realm name) and `valueFrom.secretKeyRef` (admin password, DB password)
- **Labels:** `app.kubernetes.io/name=keycloak`, `app.kubernetes.io/part-of=guildhouse`
- **Config files:** ConfigMap-mounted realm import (`keycloak-realm-import`)
- **Resources:** resource requests/limits not aggressively set (defaults mostly)
- **Service:** `type: LoadBalancer` with Hetzner annotations, exposes port 80 only
- **TLS:** none in-cluster; Cloudflare upstream
**Reference: `forgejo-postgres` Deployment:**
- **Image:** `postgres:16` (public Docker Hub)
- **Env:** `POSTGRES_USER`, `POSTGRES_DB` literal; `POSTGRES_PASSWORD` from Secret
- **PGDATA:** `/var/lib/postgresql/data/pgdata` (standard subdirectory to avoid lost+found issues)
- **Volume:** PVC mounted at `/var/lib/postgresql/data`
**No existing Elixir/Phoenix deployment** to reference. Guildhall will be the first. The pattern will follow the Keycloak/Forgejo shape applied to Phoenix's runtime requirements.
## 8. Guildhouse-specific components (v1 foundation)
**Currently running: none.**
- No pod matching `substrate`, `bascule`, `chronicle`, `quartermaster`, or `spire` across all namespaces. The v1 substrate foundation is absent from the cluster's running state.
- Flux has Kustomizations for `bascule`, `quartermaster`, `spire`, `automation`, `governance-talos`, `gitops-controller` — all **failing** on a dependency chain:
```
spire → fails: namespace "spire-system" does not exist
quartermaster → fails: dependency flux-system/spire is not ready
bascule → fails: dependency flux-system/quartermaster is not ready
automation → fails: dependency flux-system/quartermaster is not ready
gitops-controller → fails: dependency flux-system/quartermaster is not ready
governance-talos → fails: dependency flux-system/cluster-infra is not ready
cluster-infra → SUSPENDED + YAML decode error on 10-cilium-values.yaml
```
This chain needs to be unblocked for the v1 substrate foundation to reach the cluster, but **this is explicitly NOT Guildhall's blocker**. Guildhall is the standalone orchestration/presentation layer; it composes with substrate via CRD watches once substrate is running, but doesn't require substrate present to stand up and serve its web UI.
## 9. Networking specifics
**Cilium version:** `v1.16.5` (Cilium 1.16 series, recent but not 1.17-cutting-edge)
**Key Cilium config** (from `kube-system/cilium-config`):
| Flag | Value | Notes |
|---|---|---|
| `kube-proxy-replacement` | `true` | Cilium replaces kube-proxy (full eBPF mode) |
| `enable-ipv4` | `true` | IPv4 on pod network |
| `enable-ipv6` | `false` | IPv6 NOT enabled at pod network (LBs get Hetzner-assigned v6 externally) |
| `enable-l7-proxy` | `true` | Envoy DaemonSet for L7 filtering |
| `enable-hubble` | `true` | Hubble observability |
| `ipam` | `kubernetes` | Host-IPAM, not cluster-pool |
**Not enabled / not present:**
- BGP control plane (`ciliumbgppeeringpolicies` CRD absent)
- L2 announcements (`ciliuml2announcementpolicies` CRD present but zero resources)
- LoadBalancerIPPool (CRD present but zero resources — Hetzner CCM handles LB IPs instead)
- Gateway API (`gatewayclasses` CRD absent)
- ClusterMesh (single-cluster)
**NetworkPolicies in place** (only 3, all in `flux-system`):
- `allow-egress`
- `allow-scraping`
- `allow-webhooks` (scoped to `app=notification-controller`)
**CiliumNetworkPolicies:** none. Workloads rely on default-allow between pods. Guildhall deployment can proceed without adding policies; adding them is hardening follow-up.
## 10. Deployment automation
**GitOps: Flux** is the sole mechanism. Running components:
- `source-controller`, `kustomize-controller`, `helm-controller`, `notification-controller` — all 1/1 Ready
**Sources:** one `GitRepository` registered:
```
flux-system / guildhouse-deploy
URL: https://github.com/gh-tking/guildhouse-deploy-talos-mirror
STATUS: Ready (artifact stored for main@169e077f)
```
**Kustomizations:** 9 total, summary:
| Name | Status |
|---|---|
| `keycloak` | ✅ Ready (applied revision `169e077f`) |
| `forgejo` | ❌ health check failed (forgejo-runner Deployment stuck InProgress) |
| `cluster-infra` | ❌ SUSPENDED + YAML decode error |
| `spire` | ❌ `spire-system` namespace not found |
| `quartermaster` | ❌ depends on spire (not ready) |
| `bascule` | ❌ depends on quartermaster (not ready) |
| `automation` | ❌ depends on quartermaster (not ready) |
| `gitops-controller` | ❌ depends on quartermaster (not ready) |
| `governance-talos` | ❌ depends on cluster-infra (not ready) |
**Key observation:** only `keycloak` flows through Flux successfully. Everything else is either suspended, blocked on missing upstream dependencies, or has a YAML error in the source repo.
**HelmRepositories and HelmReleases:** none.
**Changes land on the cluster:** currently via Flux against the GitHub-hosted source repo for the one working Kustomization (keycloak), otherwise via direct `kubectl apply` (given the broken Flux chain).
---
## Synthesis
### What Guildhall can leverage
- **Longhorn StorageClass** — works out of the box for Postgres PVC. 5Gi is ample for initial Guildhall DB (matches keycloak-db sizing).
- **Hetzner CCM LoadBalancer** — a LoadBalancer Service with `load-balancer.hetzner.cloud/*` annotations provisions a new Hetzner LB automatically. Cost is ~€5/mo for an `lb11` tier. Matches forgejo / keycloak exactly.
- **Cloudflare-at-the-edge TLS** — DNS at `guildhall.guildhouse.dev` points at the Hetzner LB IPv4, Cloudflare terminates TLS, origin is plain HTTP on port 80. Zero cert-manager work required for v1.
- **Keycloak as OIDC IdP** — already running at `auth.guildhouse.dev`. When Guildhall wires its OIDC config (currently commented out in `config/runtime.exs`), the endpoint is ready. Not blocking tonight.
- **cert-manager ClusterIssuers**`letsencrypt-prod` and `letsencrypt-staging` are ready, available as upgrade-path from Cloudflare-edge TLS to cluster-terminated TLS if/when that hygiene pass happens.
- **Reference deployment pattern** — keycloak's Deployment shape (public image, env-from-secret, ConfigMap for data, Service type=LoadBalancer, Postgres sibling Deployment + PVC) maps directly to Guildhall. Apply the same template.
- **Flux GitOps pipeline exists** (if desired) — a new Kustomization in `guildhouse-deploy-talos-mirror` for Guildhall would auto-deploy. BUT the Flux state is currently messy — most Kustomizations are broken — so a direct `kubectl apply` path is cleaner for the v1 Guildhall deploy, with a follow-up Flux migration once the broader chain is healed.
### What Guildhall needs that the cluster doesn't have yet
- **Guildhall container image.** Must be built locally via `mix release` + Dockerfile and pushed to a registry the cluster can pull from. Registry options:
- `ghcr.io/gh-tking/guildhall:<tag>` — public GitHub Container Registry (requires packaging via the GitHub Actions or manual docker push)
- Docker Hub under a personal account
- **Forgejo container registry** at `git.guildhouse.dev/tking/guildhall:<tag>` — Forgejo 1.19+ supports OCI registry; this is the most consistent choice with the rest of the Guildhouse tooling
- A private Hetzner-region ghcr mirror
- **Secrets:** `guildhall-secrets` Opaque Secret with at minimum `SECRET_KEY_BASE` (64-byte Phoenix session key, `mix phx.gen.secret`) and `DATABASE_URL` (or discrete `DB_PASSWORD` + construct URL at runtime).
- **Namespace:** `guildhall` (new).
- **DNS record:** `guildhall.guildhouse.dev` → Hetzner LB IPv4 (via Cloudflare). Can be created after LB is provisioned, once the LB IP is known.
### Likely shape of the deployment
Based on the keycloak/forgejo pattern:
```
Namespace: guildhall
├── Deployment: guildhall-postgres (postgres:16, env POSTGRES_* from guildhall-secrets)
├── PVC: guildhall-db (longhorn, 5-10Gi)
├── Service: guildhall-postgres (ClusterIP, 5432)
├── Secret: guildhall-secrets (SECRET_KEY_BASE, DB_PASSWORD)
├── Deployment: guildhall (image from ghcr / forgejo registry / etc, envs DATABASE_URL + SECRET_KEY_BASE + PHX_HOST=guildhall.guildhouse.dev + PHX_SERVER=true + PORT=4000)
└── Service: guildhall (type=LoadBalancer, Hetzner annotations, port 80 → 4000)
```
Release build discipline:
- `mix release` in Docker multi-stage build (Elixir 1.17.3 / OTP 27 builder stage, debian-slim runtime stage)
- `mix ecto.migrate` on container start (or a Job, or mix release custom step)
- `PHX_SERVER=true` to start the HTTP server (per `config/runtime.exs`)
- Health check endpoint (Phoenix default or custom `/health`)
### Surprises
**What's present that wasn't expected:**
- **Keycloak is already serving at `auth.guildhouse.dev`.** The OIDC substrate Guildhall will eventually integrate with is live. Zero setup needed for that dependency when the time comes.
- **cert-manager is installed but unused.** Suggests a deliberate deferral in favor of Cloudflare-edge TLS; the ClusterIssuers are staged and ready for when in-cluster TLS is adopted.
- **Cilium Envoy DaemonSet is running on every node** but with no Gateway API / CiliumEnvoyConfig / L7 policies currently in play. Present for future L7 use, not actively load-bearing yet.
**What's expected but absent:**
- **No HAProxy.** Previous K3s-era cluster used HAProxy as ingress; this cluster doesn't. Hetzner LBs took its role.
- **v1 substrate foundation is entirely absent from the running cluster.** bascule, substrate-operator, chronicle, quartermaster, SPIRE — none running. Flux manifests exist (in the `guildhouse-deploy-talos-mirror` repo) but are blocked on a dependency chain rooted at missing `spire-system` namespace and a YAML decode error in `cluster-infra/10-cilium-values.yaml`. Unblocking this is real work that is NOT on the Red Hat path — governance integration is follow-up.
- **No existing Elixir/Phoenix deployment** to copy. Guildhall will be the first Phoenix app on this cluster.
- **Flux source is on GitHub (`guildhouse-deploy-talos-mirror`), not Forgejo.** Follows the same pattern as the substrate-project umbrella migration just completed — another GitHub→Forgejo item on the cleanup list, not blocking.
### Minimum path to Guildhall running at `guildhall.guildhouse.dev`
1. Dockerfile in `~/projects/substrate-project/guildhall/` — multi-stage with OTP 27, `mix release`
2. Build and push image to a registry (Forgejo container registry at `git.guildhouse.dev/tking/guildhall:v0.1.0` recommended for consistency)
3. Generate `SECRET_KEY_BASE` via `mix phx.gen.secret`
4. Create `guildhall` namespace; create `guildhall-secrets` Secret
5. Apply Deployment + Service + Postgres + PVC manifest (template from keycloak)
6. Wait for Hetzner LB to provision; note IPv4
7. Create Cloudflare DNS record `guildhall.guildhouse.dev` → LB IPv4 (proxied, so Cloudflare handles TLS)
8. Verify; run any first-time ecto migration
No cluster infrastructure changes. No cert-manager Certificates. No Flux reconfiguration. No governance-stack dependency. Just the same Deployment-shaped pattern that Keycloak and Forgejo already use, applied to Guildhall.
Governance integration (CRD watchers, SPIFFE identity, Chronicle wiring, Accord enforcement) is explicitly follow-up work for after Guildhall is reachable and the Red Hat submission is in.

View file

@ -1,354 +0,0 @@
# Guildhall deploy runbook
**Target:** `guildhall.guildhouse.dev` on the Hetzner Talos cluster, via Forgejo container registry at `git.guildhouse.dev/tking/guildhall`.
**Pattern:** direct `kubectl apply` against the cluster; Flux integration deferred. TLS terminates at Cloudflare (orange cloud); origin is plain HTTP on the Hetzner LB.
**Required reference docs:** `DEPLOY-EXPLORATORY-2026-04-21.md` (cluster state), `FORGEJO-REGISTRY-INVESTIGATION-2026-04-21.md` (registry state).
Tag referenced throughout this runbook: **`v0.1.0`**. When deploying a subsequent tag, substitute throughout OR use the sed helper at the bottom.
---
## Prerequisites
- `kubectl` configured against the Talos cluster (`KUBECONFIG=~/projects/substrate-project/guildhouse-talos-bootstrap/kubeconfig`)
- `docker` available on the build host with enough disk for an Elixir build image (~2 GB)
- Cloudflare account access for `guildhouse.dev` DNS
- Forgejo account `tking` at `git.guildhouse.dev`
---
## Phase 1 — Build and push the image
### 1.1 Create a Forgejo Personal Access Token
Navigate to `https://git.guildhouse.dev/-/user/settings/applications`. Generate a new token:
- **Token name:** `guildhall-registry-push` (or similar)
- **Scopes:** `package:write` (this token will both push and pull; scope down to `package:read` for a separate in-cluster-pull token if splitting)
- **Expiry:** operator's choice; 30-90 days is reasonable for the push token
Copy the token value immediately (Forgejo won't show it again). Save it in your password manager.
### 1.2 Docker login
```bash
docker login git.guildhouse.dev -u tking
# paste PAT when prompted
```
Verify with `cat ~/.docker/config.json | jq '.auths | keys'``git.guildhouse.dev` should appear.
### 1.3 Build the image
```bash
cd /home/tking/projects/substrate-project/guildhall
docker build -t git.guildhouse.dev/tking/guildhall:v0.1.0 .
```
Cold build takes ~5-10 minutes (mix deps + erlang compile + tailwind + esbuild + phx.digest + mix release). Subsequent builds hit Docker layer cache and are much faster.
Verify the image runs before pushing:
```bash
docker run --rm -it --entrypoint /bin/sh \
git.guildhouse.dev/tking/guildhall:v0.1.0 \
-c 'ls -la /app/bin && /app/bin/guildhall version'
```
Expected: the `guildhall` release binary is present and `version` returns the release version without error.
### 1.4 Push to Forgejo registry
```bash
docker push git.guildhouse.dev/tking/guildhall:v0.1.0
```
### 1.5 Verify image is in the registry
Via Forgejo UI: `https://git.guildhouse.dev/tking/-/packages` → should list `guildhall` with a `v0.1.0` tag.
Via registry API (authenticated):
```bash
curl -sS -u tking:<PAT> https://git.guildhouse.dev/v2/tking/guildhall/tags/list
# → {"name":"tking/guildhall","tags":["v0.1.0"]}
```
### 1.6 Decide package visibility
In the Forgejo UI, for the new `guildhall` container package:
- **Private** (default, recommended for tonight): cluster needs `guildhall-registry` pull secret (Phase 2.2 below creates it)
- **Public:** anonymous pulls work; skip Phase 2.2 and remove `imagePullSecrets` from `k8s/60-migration-job.yaml` and `k8s/70-guildhall-deployment.yaml` before applying
---
## Phase 2 — Cluster-side preparation
### 2.1 Create the namespace
```bash
kubectl apply -f k8s/00-namespace.yaml
```
Verify: `kubectl get ns guildhall``Active`.
### 2.2 Create the registry pull secret (if package is private)
```bash
kubectl create secret docker-registry guildhall-registry \
--docker-server=git.guildhouse.dev \
--docker-username=tking \
--docker-password='<PAT-with-package:read>' \
--namespace=guildhall
```
Optionally use a read-only PAT here instead of the push PAT from Phase 1.1. Skip this step entirely if the package is public.
### 2.3 Create the database credentials secret
Generate a strong password and save it to your password manager before running:
```bash
DB_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
echo "Save this: $DB_PASSWORD"
kubectl create secret generic guildhall-db-credentials \
--from-literal=POSTGRES_DB=guildhall \
--from-literal=POSTGRES_USER=guildhall \
--from-literal=POSTGRES_PASSWORD="$DB_PASSWORD" \
--namespace=guildhall
```
### 2.4 Create the application secrets
```bash
SECRET_KEY_BASE="$(cd /home/tking/projects/substrate-project/guildhall && mix phx.gen.secret)"
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" \
--namespace=guildhall
```
Verify secrets exist:
```bash
kubectl get secrets -n guildhall
# expect: guildhall-registry, guildhall-db-credentials, guildhall-app-secrets
```
---
## Phase 3 — Database provisioning
### 3.1 Apply Postgres PVC, Deployment, Service
```bash
kubectl apply -f k8s/20-postgres-pvc.yaml
kubectl apply -f k8s/30-postgres-deployment.yaml
kubectl apply -f k8s/40-postgres-service.yaml
```
### 3.2 Wait for Postgres Ready
```bash
kubectl rollout status deployment/guildhall-postgres -n guildhall --timeout=5m
kubectl wait --for=condition=Ready pod \
-l app=guildhall-postgres -n guildhall --timeout=3m
```
Verify it accepts connections:
```bash
kubectl exec -n guildhall deployment/guildhall-postgres -- \
pg_isready -U guildhall
# → /var/run/postgresql:5432 - accepting connections
```
---
## Phase 4 — Schema migration
### 4.1 Run the migration Job
```bash
kubectl apply -f k8s/60-migration-job.yaml
```
### 4.2 Wait for Job completion
```bash
kubectl wait --for=condition=complete job/guildhall-migrate-v0-1-0 \
-n guildhall --timeout=3m
```
### 4.3 Verify migration output
```bash
kubectl logs job/guildhall-migrate-v0-1-0 -n guildhall
```
Look for `Migrations already up` (no-op if Guildhall has no migrations yet) or a list of `== Running 20xx...` / `== Migrated` entries.
If the Job fails, inspect events + logs:
```bash
kubectl describe job guildhall-migrate-v0-1-0 -n guildhall
kubectl logs job/guildhall-migrate-v0-1-0 -n guildhall
```
Common failures and remediation: DATABASE_URL pointing at a wrong host (check `guildhall-app-secrets`); Postgres not yet accepting auth (wait longer); migration SQL error (fix in source, rebuild image, re-push, re-apply Job).
---
## Phase 5 — Application deployment
### 5.1 Apply Guildhall Deployment + Service
```bash
kubectl apply -f k8s/70-guildhall-deployment.yaml
kubectl apply -f k8s/80-guildhall-service.yaml
```
### 5.2 Wait for Deployment rollout
```bash
kubectl rollout status deployment/guildhall -n guildhall --timeout=5m
```
If this hangs, check pod events + logs:
```bash
kubectl get pods -n guildhall
kubectl describe pod -n guildhall -l app=guildhall
kubectl logs -n guildhall -l app=guildhall --tail=100
```
### 5.3 Obtain the LoadBalancer IP
Hetzner CCM provisions a new LB; allow 30-90 seconds after the Service is applied.
```bash
kubectl get svc guildhall -n guildhall -w
# ^C once EXTERNAL-IP transitions from <pending> to a public address
```
Record the IPv4 in `EXTERNAL-IP`. IPv6 will also be assigned; note both.
---
## Phase 6 — DNS + end-to-end verification
### 6.1 Create Cloudflare DNS records
In the Cloudflare dashboard for `guildhouse.dev` (or via `flarectl` / `terraform` if automated), create:
- **A record:** `guildhall``<Hetzner-LB-IPv4>` — **proxied (orange cloud)**
- **AAAA record** (optional, recommended): `guildhall``<Hetzner-LB-IPv6>` — proxied
Proxied is load-bearing: it's what provides TLS. Do NOT grey-cloud this record.
### 6.2 Smoke test
Allow Cloudflare's edge to pick up the record (1-2 minutes).
```bash
# Health endpoint — unauthenticated, should return 200
curl -sS -w '\n-- HTTP %{http_code} --\n' https://guildhall.guildhouse.dev/health
# Root — should return 200 with LiveView-rendered HTML
curl -sS -w '\n-- HTTP %{http_code} --\n' -I https://guildhall.guildhouse.dev/
```
Expected: `/health` returns `200` with `{"status":"ok","checks":{"db":"ok"}}`; `/` returns `200` with Phoenix's rendered HTML.
### 6.3 Manual walkthrough
In a browser, visit `https://guildhall.guildhouse.dev/`:
- Dashboard LiveView should render
- `/ceremonies` and `/artifacts` should render (will be empty — no data yet)
- No certificate warnings (Cloudflare-terminated TLS)
---
## Iterating on subsequent tags
For v0.1.1, v0.1.2, etc.:
1. Build + push the new image
2. Update the `image:` tag in `k8s/60-migration-job.yaml` and `k8s/70-guildhall-deployment.yaml`
3. Update the Job name in `k8s/60-migration-job.yaml` (e.g. `guildhall-migrate-v0-1-1`)
4. `kubectl apply -f k8s/60-migration-job.yaml` — run the new migration Job
5. `kubectl apply -f k8s/70-guildhall-deployment.yaml` — rolling update of Guildhall
A sed helper to bump everything at once:
```bash
OLD=v0.1.0; NEW=v0.1.1
sed -i "s|guildhall:${OLD}|guildhall:${NEW}|g" \
k8s/60-migration-job.yaml k8s/70-guildhall-deployment.yaml
sed -i "s|guildhall-migrate-${OLD//./-}|guildhall-migrate-${NEW//./-}|g" \
k8s/60-migration-job.yaml
```
---
## Rollback
### Back out the current deployment
Rolling back to a prior image tag (assuming the prior tag is still in the registry):
```bash
kubectl set image -n guildhall deployment/guildhall \
guildhall=git.guildhouse.dev/tking/guildhall:<prior-tag>
kubectl rollout status -n guildhall deployment/guildhall
```
Schema rollback (only if the current deploy introduced migrations that need to be reverted):
```bash
kubectl run guildhall-rollback --rm -it \
--image=git.guildhouse.dev/tking/guildhall:<current-tag> \
--overrides='{"spec":{"imagePullSecrets":[{"name":"guildhall-registry"}]}}' \
-n guildhall -- \
/app/bin/guildhall eval "Guildhall.OpsDb.Release.rollback(Guildhall.OpsDb.Repo, <migration_version>)"
```
### Tear down the whole deployment
```bash
# Delete in reverse order; namespace deletion cascades everything
# attached to it (Deployments, Services, Pods, PVC... note that
# deleting the namespace ALSO deletes the PVC, which destroys the
# database. For non-destructive teardown, preserve the PVC first.)
kubectl delete svc guildhall -n guildhall # triggers Hetzner LB deprovision
kubectl delete deployment guildhall -n guildhall
kubectl delete job -l app.kubernetes.io/name=guildhall,app.kubernetes.io/component=migration -n guildhall
kubectl delete deployment guildhall-postgres -n guildhall
kubectl delete svc guildhall-postgres -n guildhall
# PVC delete is destructive (Longhorn reclaim policy is Delete).
# Uncomment only if the database state should be destroyed:
# kubectl delete pvc guildhall-db -n guildhall
kubectl delete secret guildhall-registry guildhall-db-credentials guildhall-app-secrets -n guildhall
# Finally the namespace itself (retained if you want to keep PVC):
# kubectl delete namespace guildhall
```
Remove the Cloudflare DNS record for `guildhall.guildhouse.dev` if fully tearing down.
---
## Known v0.1 limitations
- **Cloudflare-edge TLS, not cluster-terminated.** Upgrading to cert-manager Certificate + in-cluster TLS is hygiene follow-up once the first deploy stabilizes. The `letsencrypt-prod` ClusterIssuer is already ready.
- **No Flux integration.** Direct `kubectl apply` is the deploy mechanism for v0.1. Flux Kustomization for Guildhall is follow-up — especially once the broader Flux chain (cluster-infra, spire, quartermaster) is healed.
- **No OIDC / Keycloak integration.** Guildhall's `config/runtime.exs` has commented-out OIDC env vars; wiring them to the existing `auth.guildhouse.dev` Keycloak is follow-up.
- **No substrate CRD integration.** The CeremonyOrchestrator and ChronicleConsumer stubs are not yet watching real substrate CRDs — those integrations land after the substrate foundation is reconciling on this cluster.
- **Single replica.** Safe for LiveView (no cluster sticky-session concerns at replicas=1). Scale once DNS cluster / horizontal-pod-autoscaler is configured.

View file

@ -1,57 +0,0 @@
# Guildhall production image — Elixir/Phoenix umbrella release.
# Multi-stage: builder produces a mix release; runtime is a slim debian
# carrying only the OTP release + runtime libs.
#
# Build context: the guildhall umbrella root.
# Target registry: git.guildhouse.dev/tking/guildhall:<tag>
# ---------- Stage 1: builder ---------------------------------------------
FROM git.guildhouse.dev/guildhouse/substrate/elixir-builder:1.17.3 AS builder
ENV MIX_ENV=prod \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
WORKDIR /app
COPY mix.exs mix.lock ./
COPY config/config.exs config/prod.exs config/runtime.exs config/
COPY apps/guildhall_chronicle/mix.exs apps/guildhall_chronicle/
COPY apps/guildhall_graph_bridge/mix.exs apps/guildhall_graph_bridge/
COPY apps/guildhall_ops_db/mix.exs apps/guildhall_ops_db/
COPY apps/guildhall_orchestrator/mix.exs apps/guildhall_orchestrator/
COPY apps/guildhall_web/mix.exs apps/guildhall_web/
RUN mix deps.get --only prod && \
mix deps.compile
COPY apps/ apps/
COPY apps/guildhall_web/assets apps/guildhall_web/assets
RUN cd apps/guildhall_web && \
mix assets.setup && \
mix assets.deploy
RUN mix compile --warnings-as-errors && \
mix release --overwrite
# ---------- Stage 2: runtime (Wolfi — golden elixir-runtime) -------------
FROM git.guildhouse.dev/guildhouse/substrate/elixir-runtime:latest AS runtime
WORKDIR /app
COPY --from=builder --chown=substrate:substrate /app/_build/prod/rel/guildhall /app
USER 1000
ENV HOME=/app \
PHX_SERVER=true \
PORT=4000
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:4000/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/bin/guildhall", "start"]

View file

@ -1,222 +0,0 @@
# Forgejo container registry — pre-enablement investigation
**Date:** 2026-04-21
**Scope:** Read-only audit of Forgejo's running state + registry configuration to determine what enablement work (if any) is needed before Guildhall's image push.
**Method:** `kubectl` + `curl` against `https://git.guildhouse.dev`. No mutations.
**Headline:** **The container registry is already enabled.** `/v2/` returns a standard OCI 401, storage headroom is ample (19.4 GB free on 20 GB PVC), and no Forgejo config change is required. Enablement work collapses to credential setup + `docker push`. Estimated time to operational registry for Guildhall: **~30 minutes.**
---
## 1. Forgejo deployment details
| | |
|---|---|
| Namespace | `forgejo` |
| Workload | `Deployment/forgejo` (1 replica, Running) |
| Image | `codeberg.org/forgejo/forgejo:9` |
| Running version | **9.0.3 (Gitea 1.22.0 base)** — confirmed via `GET /api/v1/version` |
| Scheduled node | `gsh-cp-01` (control-plane node, workloads permitted) |
| Companion | `Deployment/forgejo-postgres` (`postgres:16`, 1/1 Running) |
| Init container | `init-config` (renders `/data/gitea/conf/app.ini` from ConfigMap) |
| Runner | `Deployment/forgejo-runner` (0/1 — scaled to zero, source of the Flux health-check warning) |
**Volume mounts on the forgejo container:** one PVC, `data: /data` (the root Forgejo data path; Forgejo 9.x uses `/data` internally, not `/var/lib/gitea` as older Gitea installs did).
**PVCs in the namespace:**
| PVC | Size | StorageClass | Mount |
|---|---|---|---|
| `forgejo-data` | 20 Gi | longhorn | `/data` on forgejo |
| `forgejo-db` | 10 Gi | longhorn | Postgres data |
| `runner-cache` | 5 Gi | longhorn | forgejo-runner (scaled to zero) |
## 2. Forgejo version and config state
### Version
`GET https://git.guildhouse.dev/api/v1/version``{"version":"9.0.3+gitea-1.22.0"}`
Forgejo 9.0.3 is a recent release. Container registry / OCI Distribution API support has been GA in Forgejo since the project forked from Gitea (Gitea 1.17+); this version fully supports the container package type.
### Configuration
`forgejo-config` ConfigMap contains the full `app.ini` (40 lines, managed by Flux at path `./k8s/forgejo` in the `guildhouse-deploy-talos-mirror` source repo). Notable sections:
- `[server]``DOMAIN=git.guildhouse.dev`, `ROOT_URL=https://git.guildhouse.dev/`, `HTTP_PORT=3000`, `SSH_PORT=22`, `SSH_LISTEN_PORT=2222`, `LFS_START_SERVER=true`
- `[service]``DISABLE_REGISTRATION=true` (invite-only signup)
- `[lfs]``STORAGE_TYPE=local`
- `[repository]`, `[actions]` — with an `ENABLED = true` that belongs to Actions, not Packages
- **No explicit `[packages]` section.** This is normal for Forgejo 9.x because packages (including container registry) are enabled by default without requiring config-level opt-in.
### Verification that container registry is live
The decisive probe is the OCI Distribution API endpoint root:
```
$ curl -sS -w '%{http_code}\n' https://git.guildhouse.dev/v2/
{"errors":[{"code":"UNAUTHORIZED","message":""}]}
401
```
This is **a standards-compliant OCI registry response** to an unauthenticated request. If the registry were disabled, Forgejo would serve 404 (the endpoint would not be registered). The 401 with a well-formed `errors` object means the registry is routing correctly and simply requires authentication — the default and correct behavior.
Equivalent probe against `/v2/_catalog` returns the same 401 shape.
API-layer probe `GET /api/v1/packages/tking` also returns 401 (`token is required`), consistent with packages being enabled but requiring auth.
### Storage backend
No overridden `[packages.storage]` in app.ini, which means packages use the default local filesystem path under the Forgejo data volume: `/data/gitea/packages/` (or similar Forgejo 9.x path). This lives on `forgejo-data` (the Longhorn 20 Gi PVC), same volume as git repositories, LFS objects, and Forgejo's own state.
## 3. How Forgejo is managed
Forgejo is managed by **Flux**. A `Kustomization` `flux-system/forgejo` reconciles the manifests from:
- **Source:** `GitRepository/flux-system/guildhouse-deploy`
- **URL:** `https://github.com/gh-tking/guildhouse-deploy-talos-mirror`
- **Branch:** `main`
- **Path:** `./k8s/forgejo`
- **Current revision:** `main@169e077f`
- **Interval:** 1 minute
**Kustomization inventory** (what Flux claims to own in this path):
```
_forgejo__Namespace
forgejo_forgejo-config__ConfigMap ← this is where app.ini lives
forgejo_runner-config__ConfigMap
forgejo_forgejo-secrets__Secret
forgejo_forgejo-http__Service
forgejo_forgejo-postgres__Service
forgejo_forgejo_apps_Deployment
forgejo_forgejo-postgres_apps_Deployment
forgejo_forgejo-runner_apps_Deployment
forgejo_forgejo-data__PersistentVolumeClaim
forgejo_forgejo-db__PersistentVolumeClaim
```
**Status:** `Ready: False` / `Healthy: False` because of a health-check timeout on `forgejo-runner` — but this is a scaled-to-zero sidecar Deployment, not a problem with core Forgejo. The core Forgejo Deployment is Ready, the registry is live, and the Kustomization IS reconciling successfully against new commits — the health condition is just stuck on the runner.
**Consequence:** if we ever needed to change Forgejo's `app.ini` (we don't, for registry work), the mechanism is to edit `k8s/forgejo/forgejo-config.yaml` in the `gh-tking/guildhouse-deploy-talos-mirror` GitHub repo, push to `main`, and wait for Flux to reconcile (1-minute interval). This path is functional today despite the runner health warning.
## 4. The cluster-infra Flux error
`kubectl describe kustomization cluster-infra -n flux-system`:
- **Suspend: true** (explicitly suspended by an operator earlier)
- **Source:** `guildhouse-deploy` GitRepository, path `./talos/manifests/cluster-infra`
- **Error message:**
```
failed to decode Kubernetes YAML from /tmp/kustomization-.../talos/manifests/cluster-infra/
10-cilium-values.yaml: missing Resource metadata <nil>
```
**Diagnosis:** `10-cilium-values.yaml` is a Helm values file being handed to kustomize-controller as if it were a raw Kubernetes manifest. The file doesn't have a `kind` or `metadata` — it's a values document intended to be consumed by `helm install --values`, not a standalone Kubernetes resource. Kustomize chokes because every file in a Kustomization source path is expected to be Resource-shaped.
**Fix severity:** trivial. One of:
- Move `10-cilium-values.yaml` into a `values/` subdirectory that isn't referenced by `kustomization.yaml`
- Rename the file so it doesn't get picked up (e.g., `10-cilium-values.yaml.hold`)
- Add a `kustomization.yaml` with explicit `resources:` that excludes it
- Replace the file with a proper `HelmRelease` CR that references the values externally
Any of these is a single-file source edit, Flux reconciles on next push.
**Time estimate:** ~3060 minutes including the commit+push+reconcile+verify cycle. The main complication is that `cluster-infra` has `Suspend: true` — whoever suspended it did so deliberately (likely because the error was cascading to blocked downstream Kustomizations). Un-suspending should probably wait until the underlying YAML is fixed, otherwise the same error re-appears.
**Crucially: this error does NOT block Forgejo registry work or Guildhall deployment.** The two Kustomizations are independent. Guildhall deployment can proceed entirely outside the Flux chain (direct `kubectl apply` or a new Guildhall-specific Kustomization once registry+deploy are working). The cluster-infra/spire/quartermaster/bascule chain is substrate-foundation work that's explicitly follow-up.
## 5. Cluster image pull pattern
**No existing pattern for private-registry pulls.** The entire cluster currently pulls only from public registries:
- `quay.io/keycloak/keycloak:26.0`
- `codeberg.org/forgejo/forgejo:9`
- `postgres:16` (Docker Hub)
- `quay.io/cilium/cilium:v1.16.5` and `quay.io/cilium/cilium-envoy`
- Longhorn and Flux images (all public)
Specifically:
```
$ kubectl get secrets -A --field-selector type=kubernetes.io/dockerconfigjson
No resources found
```
Zero `dockerconfigjson` secrets cluster-wide. Zero `imagePullSecrets` referenced on any Deployment.
**Guildhall will be the first workload pulling from a private Forgejo registry.** It introduces the pattern, which then becomes the template for subsequent workloads. Two options:
1. **Make the `tking/guildhall` Forgejo package public.** Forgejo packages can be scoped public or private; a public container package allows anonymous pulls and no pull secret is needed. This matches the rest of the cluster's zero-pull-secret state. Appropriate if there's nothing sensitive in the image itself.
2. **Keep the package private and add a `dockerconfigjson` Secret.** Standard pattern: `kubectl create secret docker-registry guildhall-registry --docker-server=git.guildhouse.dev --docker-username=<user> --docker-password=<token>`, then reference in the Deployment via `imagePullSecrets: [name: guildhall-registry]`.
Option 1 is simplest for v0.1. Option 2 is better hygiene long-term.
## 6. Storage headroom on Forgejo's volume
`kubectl exec -n forgejo deployment/forgejo -- df -h` (inside the forgejo container):
```
/dev/longhorn/pvc-683ec33a-... 19.5G 137.2M 19.4G 1% /data
```
**Headroom is ample.** 19.4 GB free on a 20 GB PVC. Current Forgejo usage after 10 days is 137 MB (git repos + LFS + internal state).
A Guildhall container image — Elixir release on debian-slim, typically 100-300 MB compressed per tag, with OCI layer deduplication across tags — would add maybe 1-3 GB of package storage over dozens of iterations. No pressure on the volume for the foreseeable future.
**No resize required.** If long-term registry growth becomes an issue (multiple applications all pushing many tags, or large binary releases), Longhorn supports online expansion of the PVC — but that's a much-later concern.
---
## Synthesis
### Is the registry already enabled?
**Yes.** The `/v2/` and `/v2/_catalog` endpoints return proper OCI Distribution API responses (401 unauthenticated with well-formed `errors` objects). Forgejo 9.x enables packages by default; no `[packages]` config section is needed, and none is present. The registry is live and waiting for an authenticated client.
### What enablement work is required?
**None at the Forgejo-config layer.** The only work is client-side:
1. **Create a Forgejo Personal Access Token** (scope: `package:write`) via the Forgejo UI at `https://git.guildhouse.dev/-/user/settings/applications`
2. **Docker login from the build machine:** `docker login git.guildhouse.dev -u tking -p <PAT>`
3. **Build + push** the Guildhall image: `docker build -t git.guildhouse.dev/tking/guildhall:v0.1.0 . && docker push …`
4. **Set package visibility** in Forgejo — public (anon-pull, no imagePullSecret needed) or private (create a `dockerconfigjson` Secret in the `guildhall` namespace, reference in Deployment)
No Flux source edits. No Kustomization changes. No ConfigMap changes. No `cluster-infra` unblock required.
### Is the `cluster-infra` Flux error a blocker?
**No.** The Forgejo registry operates entirely outside the cluster-infra / spire / quartermaster / bascule Flux chain. Forgejo is managed by its own independent Kustomization (`flux-system/forgejo`), which is successfully reconciling against source revisions even though its Ready condition is flagged False by the unrelated forgejo-runner health check.
The `cluster-infra` error is real and worth fixing separately (trivial single-file fix in the GitHub source repo) but it has zero coupling to registry enablement or Guildhall deployment. Treat as a cleanup backlog item, not a pre-req.
### Estimated time to registry operational
| Step | Time |
|---|---|
| Create Forgejo PAT (Forgejo UI) | 2 min |
| `docker login git.guildhouse.dev` | <1 min |
| Dockerfile + `mix release` setup in Guildhall repo | 15-20 min (real work) |
| `docker build` (cold build for Elixir + OTP + mix deps + assets) | 5-10 min |
| `docker push` | 1-3 min (single tag, ~200 MB compressed) |
| Set package visibility (public or private + pull secret) | 2-5 min |
| **Total to first successful image in the registry** | **~30-45 min** |
Most of the time is the Dockerfile + release-build setup, not the registry interaction itself.
### Recommended next step
**Build the Guildhall Dockerfile and push a first image.** Sequencing:
1. Author `Dockerfile` in `~/projects/substrate-project/guildhall/` — multi-stage (Elixir 1.17.3/OTP 27 builder → debian-slim runtime, `mix release`, non-root user, expose 4000, healthcheck endpoint)
2. Author `.dockerignore` that excludes `_build/`, `deps/`, `.git/`, `priv/static/` (if built separately) — matches Phoenix release conventions
3. Create Forgejo PAT with `package:write` scope
4. `docker login git.guildhouse.dev` from the desktop
5. `docker build -t git.guildhouse.dev/tking/guildhall:v0.1.0 .`
6. `docker push git.guildhouse.dev/tking/guildhall:v0.1.0`
7. Verify via Forgejo UI at `https://git.guildhouse.dev/tking/-/packages/container/guildhall` and via `curl` to `/v2/tking/guildhall/manifests/v0.1.0` (authenticated)
8. Decide package visibility, and if private, create `guildhall-registry` Secret in the `guildhall` namespace (namespace doesn't exist yet — create at deploy time)
The Kubernetes-side deploy (Deployment + Service + Postgres + PVC + Secret) proceeds in parallel with or immediately after the image build, following the Keycloak pattern captured in the earlier `DEPLOY-EXPLORATORY-2026-04-21.md`.
No pre-work needed on Forgejo itself. The registry is ready.

131
README.md
View file

@ -1,133 +1,4 @@
# Guildhall
**Ceremony orchestrator and governance UI — Elixir/Phoenix umbrella
over substrate CRDs.**
**TODO: Add description**
`guildhall` presents and coordinates; [`substrate`](../substrate) decides
and enforces. The ceremony *engine* is a Rust Kubernetes operator
with CRDs and etcd-backed state. `guildhall` is the *orchestrator*:
it coordinates humans around those CRDs — notifying witnesses,
collecting signatures via LiveView, tracking status, rendering
dashboards.
```
┌────────────────────────────────────────────────────────┐
│ SUBSTRATE (Rust, K8s operators) — decides + enforces │
│ CeremonyEngine (CRD), AccordEvaluator (CRD), │
│ CorpusReconciler (CRD), PostureEvaluator (CRD), │
│ Chronicle collector (agent) │
└────────────────────┬───────────────────────────────────┘
│ watches CRDs + emits Chronicle events
┌────────────────────────────────────────────────────────┐
│ GUILDHALL (Elixir/Phoenix) — orchestrates + presents │
│ CeremonyOrchestrator (workflow coordinator) │
│ AccordComposer (UI + submission) │
│ ArtifactBrowser (UI + lifecycle) │
│ PostureDashboard (visualization) │
│ ChronicleConsumer (projector + UI) │
└────────────────────────────────────────────────────────┘
```
**Naming discipline:** `guildhall` components are *orchestrators*
(workflow, coordination, presentation). The substrate components
are *engines* and *reconcilers* (enforcement, state-machine
advancement). Never call a `guildhall` component by a substrate
name.
---
## Umbrella apps
| App | Role |
|-----|------|
| `guildhall_web` | Phoenix LiveView UI — dashboards, ceremonies, artifacts, posture |
| `guildhall_orchestrator` | Watches substrate CRDs (future), notifies witnesses, broadcasts ceremony status over PubSub |
| `guildhall_ops_db` | Ecto schemas for the five Ops DB tables (per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001) |
| `guildhall_graph_bridge` | Microsoft Graph API reconciler — Intune deployment (stub) |
| `guildhall_chronicle` | Chronicle event consumer + Ops DB projector (stub) |
---
## Local development
### Prerequisites
- Elixir 1.17.x + OTP 27 (via `mise` or `asdf`)
- Postgres 14+ running on `localhost:5432` with a `postgres`
superuser (password `postgres` for dev)
### First-time setup
```bash
mix deps.get
mix ecto.create
mix ecto.migrate
mix run apps/guildhall_ops_db/priv/repo/seeds.exs
```
### Run the server
```bash
mix phx.server
```
Then visit:
- <http://localhost:4000/> — governance dashboard
- <http://localhost:4000/ceremonies> — open ceremonies
- <http://localhost:4000/artifacts> — governed artifacts registry
### Run tests
```bash
mix test
```
---
## Configuration
Development defaults are in `config/dev.exs` (Postgres at
`localhost:5432` as `postgres`/`postgres`, database
`guildhall_dev`). Production runtime configuration reads from
environment variables in `config/runtime.exs`:
| Env var | Purpose |
|---------|---------|
| `DATABASE_URL` | Postgres connection (required in prod) |
| `SECRET_KEY_BASE` | Phoenix cookie/session signing (required in prod) |
| `PHX_HOST` | Public hostname (default `guildhall.guildhouse.dev`) |
| `PHX_SERVER` | Set to `true` to run the HTTP server under `mix release` |
| `POOL_SIZE` | DB pool size (default 10) |
| `ECTO_IPV6` | Set to `true` for IPv6 DB connections |
Commented placeholders exist for future sprints: `KUBECONFIG`
(substrate CRD watcher) and `OIDC_ISSUER` / `OIDC_CLIENT_ID` /
`OIDC_CLIENT_SECRET` (Keycloak auth).
---
## Relationship to the rest of the stack
`guildhall` is one of the PaaS components (ROADMAP WS1). It sits
alongside:
- `substrate` — the governance Rust crates + K8s operators
- `bxnet-ops` — the `org-ops` CLI framework (reference fork: BXNet)
- `guildhouse-mcp` — MCP server for LLM mediator context
- `guildhouse-specs` — the FFC specifications
See the design docs for the full picture:
- [DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001](../guildhouse-specs/design/DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001.md) — Ops DB schema + self-hosted FFC threat model
- [DESIGN-HFL-DB-ENFORCEMENT-0001](../guildhouse-specs/design/DESIGN-HFL-DB-ENFORCEMENT-0001.md) — BPF map ABI for DB governance
- [DESIGN-ORG-OPS-FRAMEWORK-0001](../guildhouse-specs/design/DESIGN-ORG-OPS-FRAMEWORK-0001.md) — governed full-stack framework
- [DESIGN-FORGE-WORKSPACE-0001](../guildhouse-specs/design/DESIGN-FORGE-WORKSPACE-0001.md) — governed workspace staging
- [SPEC-CEREMONY-0001](../guildhouse-specs/family/ffc-app/SPEC-CEREMONY-0001.md) — ceremony protocol
---
## License
Apache 2.0.

View file

@ -1,57 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,44 +0,0 @@
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: %{}
field :founding_override_expires_at, :utc_datetime_usec
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,
:founding_override_expires_at
])
|> validate_required([:guild_id, :template_name, :schematic_name, :schematic_version])
|> validate_inclusion(:status, ~w(pending draft validated approved published realizing realized partially_realized failed))
|> foreign_key_constraint(:guild_id)
|> unique_constraint([:schematic_name, :schematic_version])
end
end

View file

@ -1,30 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,45 +0,0 @@
defmodule Guildhall.OpsDb.Release do
@moduledoc """
Release-time DB tasks for the `guildhall` OTP release.
Mix is not available inside the compiled release, so ecto tasks can't
be invoked via `mix ecto.migrate` in production. This module wraps
the same operations so they can be called via:
bin/guildhall eval 'Guildhall.OpsDb.Release.migrate()'
Intended consumers:
- The `k8s/60-migration-job.yaml` Kubernetes Job, which runs this
module before `guildhall` Deployment rollout so schema changes
land exactly once per release rather than racing across N pods.
- Operators doing targeted rollback: `rollback(Guildhall.OpsDb.Repo, 20240101120000)`.
The @app is the OTP app whose `ecto_repos` key configures which Repos
to migrate. Guildhall's single Repo (`Guildhall.OpsDb.Repo`) is
registered under `:guildhall_ops_db`.
"""
@app :guildhall_ops_db
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View file

@ -1,34 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,9 +0,0 @@
defmodule Guildhall.OpsDb.Repo.Migrations.AlterGuildSchematicsStatus do
use Ecto.Migration
def change do
alter table(:guild_schematics) do
add :founding_override_expires_at, :utc_datetime_usec
end
end
end

View file

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

View file

@ -1,109 +0,0 @@
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

@ -1,182 +0,0 @@
defmodule Guildhall.Orchestrator.FfcPipeline do
@moduledoc false
require Logger
alias Guildhall.Orchestrator.SchematicTemplate
alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder}
alias Guildhall.Orchestrator.RealizationPoller
alias Guildhall.OpsDb.GuildSchematics
defstruct [
:guild,
:guild_type,
:template,
:resolved,
:manifest_yaml,
:files,
:founding_overrides,
:schematic_name,
:schematic_version,
:guild_schematic,
:realization_id
]
def deploy(guild, opts \\ []) do
guild_type = guild.guild_type
version = Keyword.get(opts, :version, "1.0.0")
schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}")
params = %{
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
"guild_slug" => guild.slug,
"guild_name" => guild.slug,
"registrant_did" => guild.registrant_did
}
state = %__MODULE__{
guild: guild,
guild_type: guild_type,
schematic_name: schematic_name,
schematic_version: version
}
with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end),
{:ok, state} <- step(:validate_template, state, fn -> validate_template(state) end),
{:ok, state} <- step(:resolve_variables, state, fn -> resolve_variables(state, params) end),
{:ok, state} <- step(:validate_resolved, state, fn -> validate_resolved(state) end),
{:ok, state} <- step(:encode_wire, state, fn -> encode_wire(state) end),
{:ok, state} <- step(:create_schematic, state, fn -> create_schematic(state) end),
{:ok, state} <- step(:validate_schematic, state, fn -> validate_schematic(state) end),
{:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state) end),
{:ok, state} <- step(:publish_schematic, state, fn -> publish_schematic(state) end),
{:ok, state} <- step(:realize_schematic, state, fn -> realize_schematic(state) end),
{:ok, state} <- step(:create_db_record, state, fn -> create_db_record(state) end),
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do
{:ok, state}
end
end
defp step(name, _state, fun) do
case fun.() do
{:ok, new_state} -> {:ok, new_state}
{:error, reason} -> {:error, {name, reason}}
end
end
defp load_template(%{guild_type: guild_type} = state) do
case SchematicTemplate.load_template(guild_type) do
{:ok, template} -> {:ok, %{state | template: template}}
error -> error
end
end
defp validate_template(%{template: template} = state) do
case Schema.validate(template) do
:ok -> {:ok, state}
error -> error
end
end
defp resolve_variables(%{template: template} = state, params) do
case VariableResolver.resolve(template, params) do
{:ok, resolved} -> {:ok, %{state | resolved: resolved}}
error -> error
end
end
defp validate_resolved(%{resolved: resolved} = state) do
case Schema.validate_resolved(resolved) do
:ok -> {:ok, state}
error -> error
end
end
defp encode_wire(%{resolved: resolved, schematic_name: name, schematic_version: version} = state) do
hub_did = Application.get_env(:guildhall_orchestrator, :hub_did, "did:web:guildhouse.dev")
case WireEncoder.encode(resolved, name, version, hub_did: hub_did) do
{:ok, manifest_yaml, files} ->
overrides = WireEncoder.extract_founding_overrides(resolved)
if map_size(overrides) > 0 do
Logger.warning("Founding overrides active: #{inspect(overrides)}")
end
{:ok, %{state | manifest_yaml: manifest_yaml, files: files, founding_overrides: overrides}}
error ->
error
end
end
defp create_schematic(%{schematic_name: name, schematic_version: version, manifest_yaml: manifest, files: files} = state) do
case client().create_ffc_schematic(name, version, manifest, files) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp validate_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().validate_ffc_schematic(name, version) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp approve_schematic(%{schematic_name: name, schematic_version: version} = state) do
Logger.info("STUB: orgops approve for #{name}@#{version} — signing deferred to governed shell")
{:ok, state}
end
defp publish_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().publish_ffc_schematic(name, version) do
{:ok, _response} -> {:ok, state}
error -> error
end
end
defp realize_schematic(%{schematic_name: name, schematic_version: version} = state) do
case client().realize_ffc_schematic(name, version) do
{:ok, response} -> {:ok, %{state | realization_id: response.realization_id}}
error -> error
end
end
defp create_db_record(%{guild: guild} = state) do
override_ttl_days =
Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90)
overrides = state.founding_overrides || %{}
expires_at =
if map_size(overrides) > 0 do
DateTime.utc_now() |> DateTime.add(override_ttl_days * 86_400, :second) |> DateTime.truncate(:microsecond)
end
attrs = %{
guild_id: guild.id,
template_name: "#{state.guild_type}-founding",
schematic_name: state.schematic_name,
schematic_version: state.schematic_version,
realization_id: state.realization_id,
status: "realizing",
customization_params: overrides,
founding_override_expires_at: expires_at
}
case GuildSchematics.create(attrs) do
{:ok, gs} -> {:ok, %{state | guild_schematic: gs}}
error -> error
end
end
defp start_poller(%{realization_id: realization_id, guild: guild} = state) do
RealizationPoller.watch(realization_id, guild.slug)
{:ok, state}
end
defp client do
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
end
end

View file

@ -1,296 +0,0 @@
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

@ -1,452 +0,0 @@
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

@ -1,90 +0,0 @@
defmodule Guildhall.Orchestrator.RealizationPoller do
use GenServer
require Logger
alias Guildhall.Orchestrator.RealizationStatus
alias Guildhall.OpsDb.GuildSchematics
@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 client().get_realization_status(realization_id) do
{:ok, response} ->
snapshot = RealizationStatus.build_snapshot(response)
persist_snapshot(realization_id, snapshot)
Phoenix.PubSub.broadcast(
Guildhall.PubSub,
"realization:#{guild_slug}",
{:realization_update, snapshot}
)
if RealizationStatus.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 persist_snapshot(realization_id, snapshot) do
case Guildhall.OpsDb.Repo.get_by(Guildhall.OpsDb.GuildSchematic, realization_id: realization_id) do
nil -> :ok
gs -> GuildSchematics.update_realization_snapshot(gs, snapshot)
end
end
defp client do
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
end
end

View file

@ -1,93 +0,0 @@
defmodule Guildhall.Orchestrator.RealizationStatus do
@moduledoc false
require Logger
@terminal_statuses ~w(
FFC_SCHEMATIC_STATUS_REALIZED
FFC_SCHEMATIC_STATUS_ARCHIVED
FFC_SCHEMATIC_STATUS_WITHDRAWN
)
def build_snapshot(response) do
%{
overall_status: to_string(response.overall_status),
sections:
Enum.map(response.per_section, fn s ->
%{name: s.section_name, status: s.status, message: s.message}
end)
}
end
def terminal_status?(status) when is_atom(status), do: to_string(status) in @terminal_statuses
def terminal_status?(status) when is_binary(status), do: status in @terminal_statuses
def terminal_status?(_), do: false
def classify_section("succeeded"), do: {:succeeded, "Succeeded"}
def classify_section("in_progress"), do: {:in_progress, "In progress"}
def classify_section("failed"), do: {:failed, "Failed"}
def classify_section("pending"), do: {:pending, "Pending"}
def classify_section("skipped:no_change"), do: {:skipped, "Skipped (no change)"}
def classify_section("skipped:not_implemented"), do: {:skipped, "Skipped (stub)"}
def classify_section("skipped:upstream_unavailable:" <> svc), do: {:skipped, "Skipped (#{svc} unavailable)"}
def classify_section("skipped:" <> _reason), do: {:skipped, "Skipped"}
def classify_section(unknown) do
Logger.warning("Unknown reconciler section status: #{inspect(unknown)}")
{:pending, to_string(unknown)}
end
def derive_overall(snapshot) do
sections = Map.get(snapshot, :sections, Map.get(snapshot, "sections", []))
statuses =
Enum.map(sections, fn s ->
raw = s[:status] || s["status"] || "pending"
{class, _label} = classify_section(raw)
class
end)
cond do
statuses == [] -> :pending
Enum.all?(statuses, &(&1 in [:succeeded, :skipped])) -> :realized
Enum.any?(statuses, &(&1 == :failed)) and Enum.any?(statuses, &(&1 in [:succeeded, :skipped])) -> :partially_realized
Enum.any?(statuses, &(&1 == :failed)) -> :failed
Enum.any?(statuses, &(&1 == :in_progress)) -> :in_progress
true -> :pending
end
end
def display_status(:realized), do: "Realized"
def display_status(:partially_realized), do: "Partially realized"
def display_status(:failed), do: "Failed"
def display_status(:in_progress), do: "In progress"
def display_status(:pending), do: "Pending"
def overall_css_class(:realized), do: "bg-emerald-100 text-emerald-700"
def overall_css_class(:partially_realized), do: "bg-amber-100 text-amber-700"
def overall_css_class(:failed), do: "bg-red-100 text-red-700"
def overall_css_class(:in_progress), do: "bg-amber-100 text-amber-700"
def overall_css_class(:pending), do: "bg-zinc-100 text-zinc-700"
def section_icon_class("succeeded"), do: "text-emerald-600"
def section_icon_class("in_progress"), do: "text-amber-600"
def section_icon_class("failed"), do: "text-red-600"
def section_icon_class("pending"), do: "text-zinc-400"
def section_icon_class("skipped:" <> _), do: "text-sky-500"
def section_icon_class(_), do: "text-zinc-400"
def section_symbol("succeeded"), do: ""
def section_symbol("in_progress"), do: ""
def section_symbol("failed"), do: ""
def section_symbol("skipped:" <> _), do: ""
def section_symbol(_), do: ""
def section_display_status(status) do
{_class, label} = classify_section(status)
label
end
def all_known_statuses do
~w(succeeded in_progress failed pending skipped:no_change skipped:not_implemented skipped:upstream_unavailable:service)
end
end

View file

@ -1,91 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicClient do
@moduledoc false
@behaviour Guildhall.Orchestrator.SchematicClient.Behaviour
alias Schematic.V1.FfcSchematicService.Stub
alias Schematic.V1.FfcSchematicFile
@impl true
def create_ffc_schematic(name, version, manifest_yaml, files) do
proto_files =
Enum.map(files, fn {path, content} ->
%FfcSchematicFile{path: path, content: content}
end)
request = %Schematic.V1.CreateFfcSchematicRequest{
name: name,
version: version,
manifest_yaml: manifest_yaml,
files: proto_files
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.create_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def validate_ffc_schematic(name, version) do
request = %Schematic.V1.ValidateFfcSchematicRequest{
name: name,
version: version
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.validate_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
if response.valid do
{:ok, response}
else
{:error, {:validation_failed, response.results}}
end
end
end
@impl true
def publish_ffc_schematic(name, version) do
request = %Schematic.V1.PublishFfcSchematicRequest{
name: name,
version: version
}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.publish_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def realize_ffc_schematic(name, version) do
request = %Schematic.V1.RealizeFfcSchematicRequest{name: name, version: version}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.realize_ffc_schematic(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
@impl true
def get_realization_status(realization_id) do
request = %Schematic.V1.GetRealizationStatusRequest{realization_id: realization_id}
with {:ok, channel} <- connect(),
{:ok, response} <- Stub.get_realization_status(channel, request) do
GRPC.Stub.disconnect(channel)
{:ok, response}
end
end
defp connect do
url =
Application.get_env(:guildhall_orchestrator, :ffc_schematic_service_url, "localhost:9091")
GRPC.Stub.connect(url)
end
end

View file

@ -1,18 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do
@moduledoc false
@callback create_ffc_schematic(String.t(), String.t(), binary(), [{String.t(), binary()}]) ::
{:ok, map()} | {:error, term()}
@callback validate_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback publish_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback realize_ffc_schematic(String.t(), String.t()) ::
{:ok, map()} | {:error, term()}
@callback get_realization_status(String.t()) ::
{:ok, map()} | {:error, term()}
end

View file

@ -1,62 +0,0 @@
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} ->
[
%{
op_type: "replace",
path: "#{section}.json",
content: Jason.encode!(content)
}
]
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 template_dir do
Application.app_dir(:guildhall_orchestrator, "priv/schematic_templates")
end
end

View file

@ -1,387 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.Schema do
@moduledoc false
@known_sections ~w(meta trust_domain identity_authority members infrastructure ceremonies federation_peers attestation)
@valid_ceremony_types ~w(single_approval multi_party)
@valid_roles ~w(master journeyman apprentice)
@valid_federation_modes ~w(hub_spoke mesh)
@valid_trust_levels ~w(federated local)
@valid_providers ~w(keycloak)
@tier_requirements %{
1 => %{require_tpm: false, require_secure_boot: false, mfa_required: false},
2 => %{require_tpm: false, require_secure_boot: false, mfa_required: true},
3 => %{require_tpm: true, require_secure_boot: true, mfa_required: true, hardware_credential_required: true}
}
@type_to_federation %{
"msp" => "hub_spoke",
"isv" => "hub_spoke",
"nsp" => "mesh"
}
def validate(template) when is_map(template) do
with :ok <- check_unknown_sections(template),
:ok <- validate_meta(template["meta"]),
:ok <- validate_trust_domain(template["trust_domain"]),
:ok <- validate_identity_authority(template["identity_authority"]),
:ok <- validate_members(template["members"]),
:ok <- validate_infrastructure(template["infrastructure"]),
:ok <- validate_ceremonies(template["ceremonies"]),
:ok <- validate_federation_peers(template["federation_peers"]),
:ok <- validate_attestation(template["attestation"]),
:ok <- validate_cross_section(template) do
:ok
end
end
def validate(nil), do: {:error, {:missing_section, "template is nil"}}
def validate(_), do: {:error, {:invalid_type, "template must be a map"}}
def validate_resolved(template) when is_map(template) do
with :ok <- validate_resolved_values(template) do
:ok
end
end
# --- Section validators ---
defp check_unknown_sections(template) do
unknown = Map.keys(template) -- @known_sections
if unknown == [], do: :ok, else: {:error, {:unknown_sections, unknown}}
end
defp validate_meta(nil), do: {:error, {:missing_section, "meta"}}
defp validate_meta(meta) when is_map(meta) do
with :ok <- require_string(meta, "template_name", "meta"),
:ok <- require_string(meta, "source_schematic", "meta"),
:ok <- require_string(meta, "source_version", "meta") do
:ok
end
end
defp validate_trust_domain(nil), do: {:error, {:missing_section, "trust_domain"}}
defp validate_trust_domain(td) when is_map(td) do
with :ok <- require_string(td, "spiffe_trust_domain", "trust_domain"),
:ok <- require_integer_in(td, "attestation_tier", 1..3, "trust_domain") do
:ok
end
end
defp validate_identity_authority(nil), do: {:error, {:missing_section, "identity_authority"}}
defp validate_identity_authority(ia) when is_map(ia) do
with :ok <- require_string(ia, "provider", "identity_authority"),
:ok <- require_inclusion(ia, "provider", @valid_providers, "identity_authority"),
:ok <- require_string(ia, "url", "identity_authority"),
:ok <- require_string(ia, "realm", "identity_authority"),
:ok <- optional_inclusion(ia, "trust_level", @valid_trust_levels, "identity_authority"),
:ok <- optional_boolean(ia, "mfa_required", "identity_authority"),
:ok <- optional_boolean(ia, "hardware_credential_required", "identity_authority") do
:ok
end
end
defp validate_members(nil), do: {:error, {:missing_section, "members"}}
defp validate_members(m) when is_map(m) do
with :ok <- require_string(m, "founding_master_did", "members"),
:ok <- require_string_list(m, "initial_roles", "members"),
:ok <- validate_roles(m["initial_roles"], "members.initial_roles") do
:ok
end
end
defp validate_infrastructure(nil), do: {:error, {:missing_section, "infrastructure"}}
defp validate_infrastructure(infra) when is_map(infra) do
with :ok <- require_integer_in(infra, "compute_attestation_tier", 1..3, "infrastructure"),
:ok <- optional_boolean(infra, "wireguard_tunnel", "infrastructure"),
:ok <- optional_boolean(infra, "vpp_dataplane", "infrastructure") do
:ok
end
end
defp validate_ceremonies(nil), do: {:error, {:missing_section, "ceremonies"}}
defp validate_ceremonies(c) when is_map(c) do
with :ok <- require_map(c, "code_change", "ceremonies"),
:ok <- validate_ceremony_rule(c["code_change"], "ceremonies.code_change"),
:ok <- require_map(c, "governance_change", "ceremonies"),
:ok <- validate_ceremony_rule(c["governance_change"], "ceremonies.governance_change") do
:ok
end
end
defp validate_ceremony_rule(rule, path) when is_map(rule) do
with :ok <- require_string(rule, "type", path),
:ok <- require_inclusion(rule, "type", @valid_ceremony_types, path),
:ok <- require_string_list(rule, "eligible_roles", path),
:ok <- validate_roles(rule["eligible_roles"], "#{path}.eligible_roles"),
:ok <- require_positive_integer(rule, "quorum", path),
:ok <- validate_founding_override(rule, path) do
:ok
end
end
defp validate_ceremony_rule(_, path), do: {:error, {:invalid_type, "#{path} must be a map"}}
defp validate_founding_override(rule, path) do
case rule["founding_override"] do
nil ->
:ok
override when is_integer(override) ->
cond do
rule["type"] != "multi_party" ->
{:error, {:invalid_value, "#{path}.founding_override is only valid on multi_party ceremonies"}}
override < 1 ->
{:error, {:invalid_value, "#{path}.founding_override must be >= 1"}}
override >= rule["quorum"] ->
{:error, {:invalid_value, "#{path}.founding_override must be less than quorum (#{rule["quorum"]})"}}
true ->
:ok
end
_ ->
{:error, {:invalid_type, "#{path}.founding_override must be an integer"}}
end
end
defp validate_federation_peers(nil), do: {:error, {:missing_section, "federation_peers"}}
defp validate_federation_peers(fp) when is_map(fp) do
with :ok <- require_string(fp, "mode", "federation_peers"),
:ok <- require_inclusion(fp, "mode", @valid_federation_modes, "federation_peers"),
:ok <- require_string(fp, "hub_trust_domain", "federation_peers") do
:ok
end
end
defp validate_attestation(nil), do: {:error, {:missing_section, "attestation"}}
defp validate_attestation(att) when is_map(att) do
with :ok <- require_integer_in(att, "tier", 1..3, "attestation"),
:ok <- optional_boolean(att, "require_tpm", "attestation"),
:ok <- optional_boolean(att, "require_secure_boot", "attestation") do
:ok
end
end
# --- Cross-section validators ---
defp validate_cross_section(template) do
with :ok <- validate_tier_consistency(template),
:ok <- validate_tier_requirements(template),
:ok <- validate_federation_type(template),
:ok <- validate_mesh_wireguard(template) do
:ok
end
end
defp validate_tier_consistency(template) do
td_tier = get_in(template, ["trust_domain", "attestation_tier"])
infra_tier = get_in(template, ["infrastructure", "compute_attestation_tier"])
att_tier = get_in(template, ["attestation", "tier"])
tiers = [td_tier, infra_tier, att_tier] |> Enum.reject(&is_nil/1) |> Enum.uniq()
if length(tiers) <= 1 do
:ok
else
{:error, {:tier_mismatch, "attestation tiers must be consistent: trust_domain=#{td_tier}, infrastructure=#{infra_tier}, attestation=#{att_tier}"}}
end
end
defp validate_tier_requirements(template) do
tier = get_in(template, ["attestation", "tier"])
reqs = Map.get(@tier_requirements, tier)
if is_nil(reqs) do
:ok
else
errors =
[]
|> check_tier_bool(template, ["attestation", "require_tpm"], reqs[:require_tpm], "attestation.require_tpm")
|> check_tier_bool(template, ["attestation", "require_secure_boot"], reqs[:require_secure_boot], "attestation.require_secure_boot")
|> check_tier_bool(template, ["identity_authority", "mfa_required"], reqs[:mfa_required], "identity_authority.mfa_required")
|> maybe_check_tier_bool(template, ["identity_authority", "hardware_credential_required"], reqs[:hardware_credential_required], "identity_authority.hardware_credential_required")
case errors do
[] -> :ok
errs -> {:error, {:tier_requirement_mismatch, Enum.reverse(errs)}}
end
end
end
defp check_tier_bool(errors, template, path, expected, label) do
actual = get_in(template, path)
if not is_nil(actual) and actual != expected do
["#{label}: expected #{expected} for tier #{get_in(template, ["attestation", "tier"])}, got #{actual}" | errors]
else
errors
end
end
defp maybe_check_tier_bool(errors, template, path, nil, _label), do: errors |> check_tier_bool(template, path, nil, "")
defp maybe_check_tier_bool(errors, template, path, expected, label), do: check_tier_bool(errors, template, path, expected, label)
defp validate_federation_type(template) do
template_name = get_in(template, ["meta", "template_name"]) || ""
mode = get_in(template, ["federation_peers", "mode"])
guild_type =
cond do
String.contains?(template_name, "msp") -> "msp"
String.contains?(template_name, "isv") -> "isv"
String.contains?(template_name, "nsp") -> "nsp"
true -> nil
end
expected = Map.get(@type_to_federation, guild_type)
if is_nil(expected) or is_nil(mode) or mode == expected do
:ok
else
{:error, {:federation_mode_mismatch, "#{guild_type} templates must use #{expected} federation, got #{mode}"}}
end
end
defp validate_mesh_wireguard(template) do
mode = get_in(template, ["federation_peers", "mode"])
wg = get_in(template, ["infrastructure", "wireguard_tunnel"])
cond do
mode == "mesh" and wg != true ->
{:error, {:mesh_requires_wireguard, "mesh federation mode requires infrastructure.wireguard_tunnel = true"}}
true ->
:ok
end
end
# --- Resolved value validators (post-substitution) ---
defp validate_resolved_values(template) do
errors =
[]
|> validate_did_format(get_in(template, ["members", "founding_master_did"]), "members.founding_master_did")
|> validate_domain_format(get_in(template, ["trust_domain", "spiffe_trust_domain"]), "trust_domain.spiffe_trust_domain")
|> validate_slug_format(get_in(template, ["identity_authority", "client_prefix"]), "identity_authority.client_prefix")
case errors do
[] -> :ok
errs -> {:error, {:invalid_resolved_values, Enum.reverse(errs)}}
end
end
defp validate_did_format(errors, nil, _path), do: errors
defp validate_did_format(errors, value, path) when is_binary(value) do
if String.starts_with?(value, "did:") do
errors
else
["#{path}: must be a DID (got #{inspect(value)})" | errors]
end
end
defp validate_did_format(errors, _, path), do: ["#{path}: must be a string" | errors]
defp validate_domain_format(errors, nil, _path), do: errors
defp validate_domain_format(errors, value, path) when is_binary(value) do
if Regex.match?(~r/^[a-z0-9][a-z0-9.\-]*[a-z0-9]$/, value) do
errors
else
["#{path}: must be a valid domain (got #{inspect(value)})" | errors]
end
end
defp validate_domain_format(errors, _, path), do: ["#{path}: must be a string" | errors]
defp validate_slug_format(errors, nil, _path), do: errors
defp validate_slug_format(errors, value, path) when is_binary(value) do
if Regex.match?(~r/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/, value) or String.match?(value, ~r/^\{\{.+\}\}$/) do
errors
else
["#{path}: must be a slug (got #{inspect(value)})" | errors]
end
end
defp validate_slug_format(errors, _, path), do: ["#{path}: must be a string" | errors]
# --- Primitive validators ---
defp require_string(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_binary(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a string"}}
end
end
defp require_integer_in(map, key, range, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_integer(v) -> if v in range, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be in #{inspect(range)}"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
end
end
defp require_positive_integer(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_integer(v) and v >= 1 -> :ok
v when is_integer(v) -> {:error, {:invalid_value, "#{section}.#{key} must be >= 1"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
end
end
defp require_string_list(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_list(v) -> if Enum.all?(v, &is_binary/1), do: :ok, else: {:error, {:invalid_type, "#{section}.#{key} must be a list of strings"}}
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a list"}}
end
end
defp require_map(map, key, section) do
case Map.get(map, key) do
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
v when is_map(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a map"}}
end
end
defp require_inclusion(map, key, allowed, section) do
val = Map.get(map, key)
if is_nil(val) or val in allowed, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be one of #{inspect(allowed)}, got #{inspect(val)}"}}
end
defp optional_inclusion(_map, _key, _allowed, _section), do: :ok
defp optional_boolean(map, key, section) do
case Map.get(map, key) do
nil -> :ok
v when is_boolean(v) -> :ok
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a boolean"}}
end
end
defp validate_roles(nil, _path), do: :ok
defp validate_roles(roles, path) when is_list(roles) do
invalid = Enum.reject(roles, &(&1 in @valid_roles))
if invalid == [], do: :ok, else: {:error, {:invalid_value, "#{path} contains invalid roles: #{inspect(invalid)}"}}
end
defp validate_roles(_, path), do: {:error, {:invalid_type, "#{path} must be a list"}}
end

View file

@ -1,100 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolver do
@moduledoc false
@placeholder_regex ~r/\{\{([a-z_]+)\}\}/
@known_variables ~w(trust_domain guild_slug guild_name registrant_did)
def resolve(template, params) when is_map(template) and is_map(params) do
with :ok <- check_params(params),
{:ok, resolved} <- substitute(template, params),
:ok <- check_no_remaining(resolved) do
{:ok, resolved}
end
end
def known_variables, do: @known_variables
defp check_params(params) do
errors =
Enum.reduce(@known_variables, [], fn var, acc ->
case Map.get(params, var) do
nil -> [{:missing_param, var} | acc]
"" -> [{:empty_param, var} | acc]
v when is_binary(v) -> acc
v -> [{:invalid_param, var, "expected string, got #{inspect(v)}"} | acc]
end
end)
placeholders_in_use = collect_placeholders(params |> Map.values() |> Enum.join(" "))
errors =
Enum.reduce(Map.keys(params) -- @known_variables, errors, fn key, acc ->
if key in placeholders_in_use, do: acc, else: acc
end)
case errors do
[] -> :ok
errs -> {:error, {:param_errors, Enum.reverse(errs)}}
end
end
defp substitute(value, params) when is_binary(value) do
result =
Regex.replace(@placeholder_regex, value, fn _full, var_name ->
case Map.get(params, var_name) do
nil -> "{{#{var_name}}}"
v -> to_string(v)
end
end)
{:ok, result}
end
defp substitute(value, params) when is_map(value) do
Enum.reduce_while(value, {:ok, %{}}, fn {k, v}, {:ok, acc} ->
case substitute(v, params) do
{:ok, resolved} -> {:cont, {:ok, Map.put(acc, k, resolved)}}
error -> {:halt, error}
end
end)
end
defp substitute(value, params) when is_list(value) do
Enum.reduce_while(value, {:ok, []}, fn v, {:ok, acc} ->
case substitute(v, params) do
{:ok, resolved} -> {:cont, {:ok, acc ++ [resolved]}}
error -> {:halt, error}
end
end)
end
defp substitute(value, _params), do: {:ok, value}
defp check_no_remaining(template) do
remaining = collect_all_placeholders(template)
case remaining do
[] -> :ok
vars -> {:error, {:unresolved_variables, Enum.uniq(vars)}}
end
end
defp collect_all_placeholders(value) when is_binary(value) do
collect_placeholders(value)
end
defp collect_all_placeholders(value) when is_map(value) do
Enum.flat_map(value, fn {_k, v} -> collect_all_placeholders(v) end)
end
defp collect_all_placeholders(value) when is_list(value) do
Enum.flat_map(value, &collect_all_placeholders/1)
end
defp collect_all_placeholders(_), do: []
defp collect_placeholders(string) when is_binary(string) do
Regex.scan(@placeholder_regex, string) |> Enum.map(fn [_, name] -> name end)
end
end

View file

@ -1,118 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoder do
@moduledoc false
@section_paths %{
"trust_domain" => "trust-domain.yaml",
"identity_authority" => "identity-authority.yaml",
"members" => "members.yaml",
"infrastructure" => "infrastructure.yaml",
"ceremonies" => "ceremonies.yaml",
"federation_peers" => "federation-peers.yaml",
"attestation" => "attestation.yaml"
}
@section_declarations [
%{"name" => "trust-domain", "required" => true},
%{"name" => "identity-authority", "required" => true},
%{"name" => "members", "required" => true},
%{"name" => "infrastructure", "required" => true},
%{"name" => "ceremonies", "required" => true},
%{"name" => "federation-peers", "required" => false},
%{"name" => "attestation", "required" => false}
]
def encode(resolved_template, name, version, opts \\ []) do
hub_did = Keyword.get(opts, :hub_did, "did:web:guildhouse.dev")
registrant_spiffe = Keyword.get(opts, :registrant_spiffe)
trust_domain =
get_in(resolved_template, ["trust_domain", "spiffe_trust_domain"]) || "guildhouse.dev"
registrant_spiffe =
registrant_spiffe ||
"spiffe://#{trust_domain}/founder/#{get_in(resolved_template, ["identity_authority", "client_prefix"]) || "unknown"}"
manifest = build_manifest(name, version, hub_did, registrant_spiffe, resolved_template)
manifest_yaml = Jason.encode!(manifest)
files = build_section_files(resolved_template)
{:ok, manifest_yaml, files}
end
def section_paths, do: @section_paths
defp build_manifest(name, version, hub_did, registrant_spiffe, template) do
realm =
get_in(template, ["identity_authority", "client_prefix"]) ||
get_in(template, ["identity_authority", "realm"]) || name
%{
"apiVersion" => "guildhouse.dev/v1",
"kind" => "FfcSchematic",
"metadata" => %{
"name" => name,
"version" => version,
"description" =>
get_in(template, ["meta", "description"]) || "#{name} founding schematic",
"parentHash" => ""
},
"spec" => %{
"consortium" => %{
"did" => hub_did,
"realm" => realm
},
"stakeholders" => [
%{
"role" => "founding_stakeholder",
"identity" => registrant_spiffe,
"required" => true
}
],
"approval" => %{
"strategy" => "unanimous"
},
"sections" => @section_declarations,
"schematicHash" => "",
"ceremonyId" => "",
"accordHash" => "",
"signatures" => []
}
}
end
defp build_section_files(template) do
@section_paths
|> Enum.map(fn {section_key, file_path} ->
content = Map.get(template, section_key, %{})
content = strip_guildhall_metadata(section_key, content)
{file_path, Jason.encode!(content)}
end)
end
defp strip_guildhall_metadata("ceremonies", ceremonies) when is_map(ceremonies) do
Map.new(ceremonies, fn {key, rule} ->
if is_map(rule) do
{key, Map.delete(rule, "founding_override")}
else
{key, rule}
end
end)
end
defp strip_guildhall_metadata(_section, content), do: content
def extract_founding_overrides(template) do
ceremonies = Map.get(template, "ceremonies", %{})
Enum.reduce(ceremonies, %{}, fn {key, rule}, acc ->
case rule do
%{"founding_override" => override} when is_integer(override) ->
Map.put(acc, "ceremonies.#{key}.founding_override", override)
_ ->
acc
end
end)
end
end

View file

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

View file

@ -1,43 +0,0 @@
[meta]
template_name = "isv-founding"
description = "Independent Software Vendor founding schematic"
source_schematic = "guildhouse-isv-base"
source_version = "1.0.0"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 1
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = false
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 1
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[federation_peers]
mode = "hub_spoke"
hub_trust_domain = "guildhouse.dev"
[attestation]
tier = 1
require_tpm = false
require_secure_boot = false

View file

@ -1,43 +0,0 @@
[meta]
template_name = "msp-founding"
description = "Managed Service Provider founding schematic"
source_schematic = "guildhouse-msp-base"
source_version = "1.0.0"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 2
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 2
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "single_approval"
eligible_roles = ["master"]
quorum = 1
[federation_peers]
mode = "hub_spoke"
hub_trust_domain = "guildhouse.dev"
[attestation]
tier = 2
require_tpm = false
require_secure_boot = false

View file

@ -1,47 +0,0 @@
[meta]
template_name = "nsp-founding"
description = "Network Service Provider founding schematic"
source_schematic = "guildhouse-nsp-base"
source_version = "1.0.0"
[trust_domain]
spiffe_trust_domain = "{{trust_domain}}"
attestation_tier = 3
[identity_authority]
provider = "keycloak"
url = "https://auth.guildhouse.dev"
realm = "guildhouse"
client_prefix = "{{guild_slug}}"
trust_level = "federated"
mfa_required = true
hardware_credential_required = true
[members]
founding_master_did = "{{registrant_did}}"
initial_roles = ["master"]
[infrastructure]
compute_attestation_tier = 3
wireguard_tunnel = true
vpp_dataplane = true
[ceremonies.code_change]
type = "single_approval"
eligible_roles = ["master", "journeyman"]
quorum = 1
[ceremonies.governance_change]
type = "multi_party"
eligible_roles = ["master"]
quorum = 2
founding_override = 1
[federation_peers]
mode = "mesh"
hub_trust_domain = "guildhouse.dev"
[attestation]
tier = 3
require_tpm = true
require_secure_boot = true

View file

@ -1,114 +0,0 @@
defmodule Guildhall.Orchestrator.RealizationStatusTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.RealizationStatus
describe "classify_section/1" do
test "classifies all known statuses" do
assert {:succeeded, _} = RealizationStatus.classify_section("succeeded")
assert {:in_progress, _} = RealizationStatus.classify_section("in_progress")
assert {:failed, _} = RealizationStatus.classify_section("failed")
assert {:pending, _} = RealizationStatus.classify_section("pending")
assert {:skipped, _} = RealizationStatus.classify_section("skipped:no_change")
assert {:skipped, _} = RealizationStatus.classify_section("skipped:not_implemented")
assert {:skipped, label} = RealizationStatus.classify_section("skipped:upstream_unavailable:keycloak")
assert label =~ "keycloak"
end
test "unknown status returns pending with warning" do
assert {:pending, _} = RealizationStatus.classify_section("totally_unknown")
end
end
describe "derive_overall/1" do
test "all succeeded → realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "succeeded"}]}
assert :realized = RealizationStatus.derive_overall(snapshot)
end
test "all succeeded + skipped → realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "skipped:no_change"}]}
assert :realized = RealizationStatus.derive_overall(snapshot)
end
test "mix of failed and succeeded → partially_realized" do
snapshot = %{sections: [%{status: "succeeded"}, %{status: "failed"}]}
assert :partially_realized = RealizationStatus.derive_overall(snapshot)
end
test "all failed → failed" do
snapshot = %{sections: [%{status: "failed"}, %{status: "failed"}]}
assert :failed = RealizationStatus.derive_overall(snapshot)
end
test "in_progress → in_progress" do
snapshot = %{sections: [%{status: "in_progress"}, %{status: "pending"}]}
assert :in_progress = RealizationStatus.derive_overall(snapshot)
end
test "empty sections → pending" do
assert :pending = RealizationStatus.derive_overall(%{sections: []})
end
test "no sections key → pending" do
assert :pending = RealizationStatus.derive_overall(%{})
end
end
describe "terminal_status?/1" do
test "realized is terminal" do
assert RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED)
end
test "string form is terminal" do
assert RealizationStatus.terminal_status?("FFC_SCHEMATIC_STATUS_REALIZED")
end
test "published is not terminal" do
refute RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_PUBLISHED)
end
end
describe "display helpers" do
test "overall_css_class for each status" do
assert RealizationStatus.overall_css_class(:realized) =~ "emerald"
assert RealizationStatus.overall_css_class(:failed) =~ "red"
assert RealizationStatus.overall_css_class(:in_progress) =~ "amber"
assert RealizationStatus.overall_css_class(:pending) =~ "zinc"
end
test "display_status for each overall" do
assert "Realized" = RealizationStatus.display_status(:realized)
assert "Failed" = RealizationStatus.display_status(:failed)
assert "In progress" = RealizationStatus.display_status(:in_progress)
end
test "section_icon_class returns correct CSS" do
assert RealizationStatus.section_icon_class("succeeded") =~ "emerald"
assert RealizationStatus.section_icon_class("failed") =~ "red"
assert RealizationStatus.section_icon_class("skipped:no_change") =~ "sky"
end
test "section_symbol returns correct symbols" do
assert "" = RealizationStatus.section_symbol("succeeded")
assert "" = RealizationStatus.section_symbol("failed")
assert "" = RealizationStatus.section_symbol("skipped:not_implemented")
assert "" = RealizationStatus.section_symbol("pending")
end
test "section_display_status for skipped variants" do
assert RealizationStatus.section_display_status("skipped:no_change") =~ "no change"
assert RealizationStatus.section_display_status("skipped:not_implemented") =~ "stub"
end
end
describe "all_known_statuses/0" do
test "returns comprehensive list" do
statuses = RealizationStatus.all_known_statuses()
assert length(statuses) >= 7
assert "succeeded" in statuses
assert "failed" in statuses
assert "skipped:no_change" in statuses
end
end
end

View file

@ -1,191 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.SchemaTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.Schema
defp valid_msp_template do
%{
"meta" => %{
"template_name" => "msp-founding",
"description" => "MSP founding schematic",
"source_schematic" => "guildhouse-msp-base",
"source_version" => "1.0.0"
},
"trust_domain" => %{
"spiffe_trust_domain" => "{{trust_domain}}",
"attestation_tier" => 2
},
"identity_authority" => %{
"provider" => "keycloak",
"url" => "https://auth.guildhouse.dev",
"realm" => "guildhouse",
"client_prefix" => "{{guild_slug}}",
"trust_level" => "federated",
"mfa_required" => true
},
"members" => %{
"founding_master_did" => "{{registrant_did}}",
"initial_roles" => ["master"]
},
"infrastructure" => %{
"compute_attestation_tier" => 2
},
"ceremonies" => %{
"code_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master", "journeyman"],
"quorum" => 1
},
"governance_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master"],
"quorum" => 1
}
},
"federation_peers" => %{
"mode" => "hub_spoke",
"hub_trust_domain" => "guildhouse.dev"
},
"attestation" => %{
"tier" => 2,
"require_tpm" => false,
"require_secure_boot" => false
}
}
end
describe "validate/1" do
test "valid MSP template passes" do
assert :ok = Schema.validate(valid_msp_template())
end
test "rejects unknown sections" do
template = Map.put(valid_msp_template(), "bogus", %{"foo" => "bar"})
assert {:error, {:unknown_sections, ["bogus"]}} = Schema.validate(template)
end
test "rejects nil template" do
assert {:error, {:missing_section, _}} = Schema.validate(nil)
end
test "rejects missing required section" do
template = Map.delete(valid_msp_template(), "trust_domain")
assert {:error, {:missing_section, "trust_domain"}} = Schema.validate(template)
end
test "rejects missing required field" do
template = put_in(valid_msp_template(), ["trust_domain", "spiffe_trust_domain"], nil)
template = Map.update!(template, "trust_domain", &Map.delete(&1, "spiffe_trust_domain"))
assert {:error, {:missing_field, "trust_domain.spiffe_trust_domain"}} = Schema.validate(template)
end
test "rejects invalid type for attestation_tier" do
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], "two")
assert {:error, {:invalid_type, _}} = Schema.validate(template)
end
test "rejects attestation_tier out of range" do
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], 5)
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
test "rejects invalid ceremony type" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "type"], "whatever")
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
test "rejects invalid role in eligible_roles" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "eligible_roles"], ["admin"])
assert {:error, {:invalid_value, _}} = Schema.validate(template)
end
end
describe "cross-section validation" do
test "detects tier mismatch" do
template = put_in(valid_msp_template(), ["attestation", "tier"], 3)
assert {:error, {:tier_mismatch, _}} = Schema.validate(template)
end
test "detects federation mode mismatch for MSP" do
template = put_in(valid_msp_template(), ["federation_peers", "mode"], "mesh")
# mesh also requires wireguard, but federation mode check runs first
assert {:error, {:federation_mode_mismatch, _}} = Schema.validate(template)
end
test "mesh requires wireguard" do
template =
valid_msp_template()
|> put_in(["meta", "template_name"], "nsp-founding")
|> put_in(["federation_peers", "mode"], "mesh")
assert {:error, {:mesh_requires_wireguard, _}} = Schema.validate(template)
end
end
describe "founding_override validation" do
test "rejects founding_override on single_approval" do
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "founding_override"], 1)
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
assert msg =~ "only valid on multi_party"
end
test "rejects founding_override >= quorum" do
template =
valid_msp_template()
|> put_in(["ceremonies", "governance_change"], %{
"type" => "multi_party",
"eligible_roles" => ["master"],
"quorum" => 2,
"founding_override" => 2
})
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
assert msg =~ "less than quorum"
end
test "accepts valid founding_override" do
template =
valid_msp_template()
|> put_in(["ceremonies", "governance_change"], %{
"type" => "multi_party",
"eligible_roles" => ["master"],
"quorum" => 2,
"founding_override" => 1
})
assert :ok = Schema.validate(template)
end
end
describe "validate_resolved/1" do
test "accepts valid resolved values" do
template =
valid_msp_template()
|> put_in(["trust_domain", "spiffe_trust_domain"], "test-guild.guildhouse.dev")
|> put_in(["members", "founding_master_did"], "did:web:guildhouse.dev:user:tking")
|> put_in(["identity_authority", "client_prefix"], "test-guild")
assert :ok = Schema.validate_resolved(template)
end
test "rejects invalid DID format" do
template =
valid_msp_template()
|> put_in(["members", "founding_master_did"], "not-a-did")
|> put_in(["trust_domain", "spiffe_trust_domain"], "test.guildhouse.dev")
|> put_in(["identity_authority", "client_prefix"], "test")
assert {:error, {:invalid_resolved_values, errors}} = Schema.validate_resolved(template)
assert Enum.any?(errors, &String.contains?(&1, "DID"))
end
end
describe "TOML template loading" do
test "all three templates validate" do
for type <- ["msp", "isv", "nsp"] do
{:ok, template} = Guildhall.Orchestrator.SchematicTemplate.load_template(type)
assert :ok = Schema.validate(template), "#{type} template failed validation"
end
end
end
end

View file

@ -1,88 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolverTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.VariableResolver
@valid_params %{
"trust_domain" => "test-guild.guildhouse.dev",
"guild_slug" => "test-guild",
"guild_name" => "Test Guild",
"registrant_did" => "did:web:guildhouse.dev:user:tking"
}
defp template_with_placeholders do
%{
"trust_domain" => %{
"spiffe_trust_domain" => "{{trust_domain}}"
},
"members" => %{
"founding_master_did" => "{{registrant_did}}"
},
"identity_authority" => %{
"client_prefix" => "{{guild_slug}}"
}
}
end
describe "resolve/2" do
test "resolves all placeholders" do
{:ok, resolved} = VariableResolver.resolve(template_with_placeholders(), @valid_params)
assert resolved["trust_domain"]["spiffe_trust_domain"] == "test-guild.guildhouse.dev"
assert resolved["members"]["founding_master_did"] == "did:web:guildhouse.dev:user:tking"
assert resolved["identity_authority"]["client_prefix"] == "test-guild"
end
test "returns error for missing required param" do
params = Map.delete(@valid_params, "trust_domain")
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
assert Enum.any?(errors, fn {type, key} -> type == :missing_param and key == "trust_domain" end)
end
test "returns error for empty param" do
params = Map.put(@valid_params, "guild_slug", "")
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
assert Enum.any?(errors, fn {type, key} -> type == :empty_param and key == "guild_slug" end)
end
test "returns error for non-string param" do
params = Map.put(@valid_params, "guild_slug", 42)
assert {:error, {:param_errors, _}} = VariableResolver.resolve(template_with_placeholders(), params)
end
test "resolves nested lists" do
template = %{
"roles" => ["{{guild_slug}}-admin", "{{guild_slug}}-user"],
"trust_domain" => %{"spiffe_trust_domain" => "x"},
"members" => %{"founding_master_did" => "x"},
"identity_authority" => %{"client_prefix" => "x"}
}
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
assert resolved["roles"] == ["test-guild-admin", "test-guild-user"]
end
test "leaves non-string values unchanged" do
template = %{
"tier" => 2,
"enabled" => true,
"trust_domain" => %{"spiffe_trust_domain" => "x"},
"members" => %{"founding_master_did" => "x"},
"identity_authority" => %{"client_prefix" => "x"}
}
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
assert resolved["tier"] == 2
assert resolved["enabled"] == true
end
end
describe "known_variables/0" do
test "returns the resolution manifest" do
vars = VariableResolver.known_variables()
assert "trust_domain" in vars
assert "guild_slug" in vars
assert "guild_name" in vars
assert "registrant_did" in vars
end
end
end

View file

@ -1,139 +0,0 @@
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoderTest do
use ExUnit.Case, async: true
alias Guildhall.Orchestrator.SchematicTemplate.WireEncoder
defp resolved_template do
%{
"meta" => %{
"template_name" => "msp-founding",
"description" => "MSP schematic",
"source_schematic" => "guildhouse-msp-base",
"source_version" => "1.0.0"
},
"trust_domain" => %{
"spiffe_trust_domain" => "test.guildhouse.dev",
"attestation_tier" => 2
},
"identity_authority" => %{
"provider" => "keycloak",
"url" => "https://auth.guildhouse.dev",
"realm" => "guildhouse",
"client_prefix" => "test-guild",
"trust_level" => "federated",
"mfa_required" => true
},
"members" => %{
"founding_master_did" => "did:web:guildhouse.dev:user:tking",
"initial_roles" => ["master"]
},
"infrastructure" => %{
"compute_attestation_tier" => 2
},
"ceremonies" => %{
"code_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master", "journeyman"],
"quorum" => 1
},
"governance_change" => %{
"type" => "single_approval",
"eligible_roles" => ["master"],
"quorum" => 1
}
},
"federation_peers" => %{
"mode" => "hub_spoke",
"hub_trust_domain" => "guildhouse.dev"
},
"attestation" => %{
"tier" => 2,
"require_tpm" => false,
"require_secure_boot" => false
}
}
end
describe "encode/4" do
test "produces manifest YAML and 7 section files" do
{:ok, manifest_yaml, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
assert is_binary(manifest_yaml)
assert length(files) == 7
file_paths = Enum.map(files, fn {path, _} -> path end)
assert "trust-domain.yaml" in file_paths
assert "identity-authority.yaml" in file_paths
assert "members.yaml" in file_paths
assert "infrastructure.yaml" in file_paths
assert "ceremonies.yaml" in file_paths
assert "federation-peers.yaml" in file_paths
assert "attestation.yaml" in file_paths
end
test "manifest has correct structure" do
{:ok, manifest_yaml, _files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
manifest = Jason.decode!(manifest_yaml)
assert manifest["apiVersion"] == "guildhouse.dev/v1"
assert manifest["kind"] == "FfcSchematic"
assert manifest["metadata"]["name"] == "test-msp"
assert manifest["metadata"]["version"] == "1.0.0"
assert manifest["spec"]["consortium"]["did"] == "did:web:guildhouse.dev"
assert is_list(manifest["spec"]["stakeholders"])
assert manifest["spec"]["approval"]["strategy"] == "unanimous"
assert is_list(manifest["spec"]["sections"])
end
test "manifest uses custom hub_did" do
{:ok, manifest_yaml, _} =
WireEncoder.encode(resolved_template(), "test", "1.0.0", hub_did: "did:web:custom.dev")
manifest = Jason.decode!(manifest_yaml)
assert manifest["spec"]["consortium"]["did"] == "did:web:custom.dev"
end
test "section file content is valid JSON" do
{:ok, _manifest, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
for {path, content} <- files do
assert {:ok, _} = Jason.decode(content), "#{path} is not valid JSON"
end
end
end
describe "founding_override stripping" do
test "strips founding_override from ceremonies wire output" do
template =
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
{:ok, _manifest, files} = WireEncoder.encode(template, "test", "1.0.0")
{_, ceremonies_json} = Enum.find(files, fn {p, _} -> p == "ceremonies.yaml" end)
ceremonies = Jason.decode!(ceremonies_json)
refute Map.has_key?(ceremonies["governance_change"], "founding_override")
end
end
describe "extract_founding_overrides/1" do
test "extracts override values" do
template =
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
overrides = WireEncoder.extract_founding_overrides(template)
assert overrides["ceremonies.governance_change.founding_override"] == 1
end
test "returns empty map when no overrides" do
assert %{} = WireEncoder.extract_founding_overrides(resolved_template())
end
end
describe "section_paths/0" do
test "returns all 7 section path mappings" do
paths = WireEncoder.section_paths()
assert map_size(paths) == 7
end
end
end

View file

@ -7,31 +7,15 @@ defmodule Guildhall.Application do
@impl true
def start(_type, _args) do
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
]
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
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options

View file

@ -1,96 +0,0 @@
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

@ -1,34 +0,0 @@
defmodule GuildhallWeb.HealthController do
@moduledoc """
Kubernetes-probe and LB-target health endpoint.
The check is deliberately shallow: it confirms Phoenix is serving
requests AND the Ecto pool can execute a trivial query against the
OpsDb Repo. The latter catches the class of failures where the app
is running but has lost its DB connection which a Kubernetes
liveness probe should notice.
Deeper health checks (Chronicle stream liveness, orchestrator state,
downstream substrate CRD reachability) are NOT in scope here
their failure modes warrant different remediation (degraded-mode
operation rather than process restart) and a separate endpoint will
surface them when those integrations land.
"""
use GuildhallWeb, :controller
alias Guildhall.OpsDb.Repo
def check(conn, _params) do
case Ecto.Adapters.SQL.query(Repo, "SELECT 1", []) do
{:ok, _} ->
conn
|> put_status(200)
|> json(%{status: "ok", checks: %{db: "ok"}})
{:error, reason} ->
conn
|> put_status(503)
|> json(%{status: "degraded", checks: %{db: "unreachable"}, reason: inspect(reason)})
end
end
end

View file

@ -1,16 +0,0 @@
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, Guilds}
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult}
import Ecto.Query
@impl true
@ -35,9 +35,6 @@ 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
@ -80,23 +77,7 @@ 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

@ -1,147 +0,0 @@
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

@ -1,100 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,96 +0,0 @@
defmodule GuildhallWeb.GuildLive.Realization do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
alias Guildhall.Orchestrator.RealizationStatus
@impl true
def mount(%{"slug" => slug}, _session, socket) do
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(status), do: RealizationStatus.section_icon_class(status)
defp status_symbol(status), do: RealizationStatus.section_symbol(status)
defp display_status(status), do: RealizationStatus.section_display_status(status)
defp overall_class(snapshot) do
overall = RealizationStatus.derive_overall(snapshot)
RealizationStatus.overall_css_class(overall)
end
defp overall_label(snapshot) do
overall = RealizationStatus.derive_overall(snapshot)
RealizationStatus.display_status(overall)
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)}"}>
{overall_label(@snapshot)}
</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)}"}>{display_status(s.status)}</span>
</div>
</section>
</div>
"""
end
end

View file

@ -1,148 +0,0 @@
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

@ -1,115 +0,0 @@
defmodule GuildhallWeb.GuildLive.Schematic do
use GuildhallWeb, :live_view
alias Guildhall.OpsDb.{Guilds, GuildSchematics}
alias Guildhall.Orchestrator.{FfcPipeline, SchematicTemplate}
@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
socket = assign(socket, :deploying, true)
case FfcPipeline.deploy(guild) do
{:ok, _state} ->
{: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")}
{:error, {step, reason}} ->
{:noreply,
socket
|> assign(:deploying, false)
|> assign(:deploy_error, "Step #{step} failed: #{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>Validate and resolve the founding schematic template</li>
<li>Create, validate, and publish the FFC schematic</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

@ -1,227 +0,0 @@
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

@ -1,24 +0,0 @@
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,8 +1,6 @@
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
@ -10,43 +8,17 @@ 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_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.
scope "/health", GuildhallWeb do
pipe_through :api
get "/", HealthController, :check
live "/", DashboardLive, :index
live "/ceremonies", CeremonyLive.Index, :index
live "/artifacts", ArtifactLive.Index, :index
end
end

View file

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

View file

@ -44,14 +44,3 @@ 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,20 +43,14 @@ if config_env() == :prod do
http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
secret_key_base: secret_key_base
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"
# 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_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"
# 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")
end

View file

@ -1,7 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: guildhall
labels:
app.kubernetes.io/managed-by: manual
app.kubernetes.io/part-of: guildhouse

View file

@ -1,36 +0,0 @@
# Container registry pull secret — TEMPLATE.
#
# Do NOT apply this file directly. The actual secret is created
# imperatively so the Forgejo PAT never lands in git. Create it with:
#
# kubectl create secret docker-registry guildhall-registry \
# --docker-server=git.guildhouse.dev \
# --docker-username=tking \
# --docker-password='<PAT-with-package:read-scope>' \
# --namespace=guildhall
#
# The PAT is generated at:
# https://git.guildhouse.dev/-/user/settings/applications
# Required scope: `package:read` (or `package:write` if the same PAT
# will also be used for `docker push` from the build host — scoping
# read-only is preferable for cluster-resident credentials).
#
# If the `tking/guildhall` Forgejo package is made public, this secret
# is not required and `imagePullSecrets` can be omitted from the
# guildhall Deployment and Job. The Deployment manifests reference
# it unconditionally; switching to public packages means removing
# those references and deleting this secret.
#
# Shape reference (what `kubectl get secret -o yaml` would show):
#
# apiVersion: v1
# kind: Secret
# metadata:
# name: guildhall-registry
# namespace: guildhall
# labels:
# app.kubernetes.io/managed-by: manual
# app.kubernetes.io/part-of: guildhouse
# type: kubernetes.io/dockerconfigjson
# data:
# .dockerconfigjson: <base64-encoded {"auths":{"git.guildhouse.dev":{"auth":"<b64 user:pat>"}}}>

View file

@ -1,16 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: guildhall-db
namespace: guildhall
labels:
app.kubernetes.io/name: guildhall-postgres
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: database
app.kubernetes.io/managed-by: manual
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 5Gi

View file

@ -1,90 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: guildhall-postgres
namespace: guildhall
labels:
app.kubernetes.io/name: guildhall-postgres
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: database
app.kubernetes.io/managed-by: manual
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: guildhall-postgres
template:
metadata:
labels:
app: guildhall-postgres
app.kubernetes.io/name: guildhall-postgres
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: database
spec:
containers:
- name: postgres
image: postgres:16
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: guildhall-db-credentials
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: guildhall-db-credentials
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: guildhall-db-credentials
key: POSTGRES_PASSWORD
# PGDATA subdir under the mount is the standard fix for the
# lost+found that some filesystems create at the mount root,
# which postgres otherwise refuses to initialise into.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
# Matches the Keycloak-postgres resource shape from the
# cluster: memory request 256Mi / limit 512Mi, CPU request
# 100m, no CPU limit. Guildhall's initial DB load is light
# so this is over-provisioned for v0.1; can be trimmed later.
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
readinessProbe:
exec:
command:
- pg_isready
- -U
- guildhall
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
livenessProbe:
exec:
command:
- pg_isready
- -U
- guildhall
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 1
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: guildhall-db

View file

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

View file

@ -1,77 +0,0 @@
# Application + database secrets — TEMPLATES.
#
# Do NOT apply these files directly. Secret values are created
# imperatively so passwords and session keys never land in git.
# Two Secrets are created at deploy time:
#
# ---------- guildhall-db-credentials ----------
# Consumed by the guildhall-postgres Deployment (for its own env) and
# by guildhall-app-secrets (the password is also needed to construct
# DATABASE_URL).
#
# DB_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
#
# kubectl create secret generic guildhall-db-credentials \
# --from-literal=POSTGRES_DB=guildhall \
# --from-literal=POSTGRES_USER=guildhall \
# --from-literal=POSTGRES_PASSWORD="$DB_PASSWORD" \
# --namespace=guildhall
#
# Shape:
#
# apiVersion: v1
# kind: Secret
# metadata:
# name: guildhall-db-credentials
# namespace: guildhall
# type: Opaque
# data:
# POSTGRES_DB: <b64 "guildhall">
# POSTGRES_USER: <b64 "guildhall">
# POSTGRES_PASSWORD: <b64 "<generated-strong-password>">
#
# ---------- guildhall-app-secrets ----------
# Consumed by the guildhall Deployment and migration Job. Contains the
# Phoenix session signing key and the DATABASE_URL used by Ecto at
# runtime.
#
# 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`
# invokes Ecto.Repo's built-in URL parser which accepts either, but
# `ecto://` is the canonical form in Phoenix-generated config.
#
# Shape:
#
# apiVersion: v1
# kind: Secret
# metadata:
# name: guildhall-app-secrets
# namespace: guildhall
# type: Opaque
# data:
# 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

@ -1,66 +0,0 @@
# Guildhall DB migration Job.
#
# Runs `Guildhall.OpsDb.Release.migrate/0` via the compiled release's
# `bin/guildhall eval` entry point. Intended to be applied ONCE per
# image-tag deploy, BEFORE the guildhall Deployment is created or
# rolled. Running migrations from within the app pods on startup
# would race across replicas and is explicitly avoided.
#
# The Job name includes the image tag so multiple deploys across tags
# can coexist in history (Kubernetes Jobs with the same name cannot
# be re-run without deletion). For deploy N+1, either:
# kubectl delete job guildhall-migrate-v0-1-0 -n guildhall
# kubectl apply -f 60-migration-job.yaml # update the name first
# or use `kubectl create job <new-name> --from=...` with a fresh name.
apiVersion: batch/v1
kind: Job
metadata:
name: guildhall-migrate-v0-1-0
namespace: guildhall
labels:
app.kubernetes.io/name: guildhall
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: migration
app.kubernetes.io/managed-by: manual
app.kubernetes.io/version: v0.1.0
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: guildhall-migrate
app.kubernetes.io/name: guildhall
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: migration
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: guildhall-registry
containers:
- name: migrate
image: git.guildhouse.dev/guildhouse/substrate/guildhall:v0.2.0
imagePullPolicy: IfNotPresent
command:
- /app/bin/guildhall
- eval
- "Guildhall.OpsDb.Release.migrate()"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: guildhall-app-secrets
key: DATABASE_URL
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: guildhall-app-secrets
key: SECRET_KEY_BASE
- name: POOL_SIZE
value: "2"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi

View file

@ -1,117 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: guildhall
namespace: guildhall
labels:
app.kubernetes.io/name: guildhall
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: web
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: guildhall
template:
metadata:
labels:
app: guildhall
app.kubernetes.io/name: guildhall
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: web
app.kubernetes.io/version: v0.1.0
spec:
imagePullSecrets:
- name: guildhall-registry
containers:
- name: guildhall
image: git.guildhouse.dev/guildhouse/substrate/guildhall:v0.2.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 4000
name: http
protocol: TCP
env:
# Phoenix / endpoint
- name: PHX_SERVER
value: "true"
- name: PHX_HOST
value: guildhall.guildhouse.dev
- name: PORT
value: "4000"
- name: POOL_SIZE
value: "10"
# Session signing key
- name: SECRET_KEY_BASE
valueFrom:
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:
secretKeyRef:
name: guildhall-app-secrets
key: DATABASE_URL
# Starting envelope. Tune after observing real usage under
# LiveView fan-out; Phoenix's memory footprint grows with
# connected clients.
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
# Probes hit /health, which queries the Ecto pool. See
# apps/guildhall_web/lib/guildhall_web_web/controllers/health_controller.ex
# for semantics.
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
# Graceful shutdown allowance. Phoenix endpoint shuts down
# cleanly inside this window.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30

View file

@ -1,32 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: guildhall
namespace: guildhall
labels:
app.kubernetes.io/name: guildhall
app.kubernetes.io/part-of: guildhouse
app.kubernetes.io/component: web
app.kubernetes.io/managed-by: manual
# Hetzner Cloud Controller Manager annotations. Matches the exact
# annotation set used by keycloak/keycloak (verified from the cluster
# on 2026-04-21): location / name / type / use-private-ip. No
# algorithm-type, no uses-proxyprotocol — the cluster's convention
# is the minimal set.
annotations:
load-balancer.hetzner.cloud/location: nbg1
load-balancer.hetzner.cloud/name: guildhall
load-balancer.hetzner.cloud/type: lb11
load-balancer.hetzner.cloud/use-private-ip: "false"
spec:
type: LoadBalancer
# TLS terminates at Cloudflare (orange cloud); origin is plain HTTP
# on port 80 → app's 4000. This matches forgejo/keycloak. Upgrading
# to in-cluster TLS via cert-manager is hygiene follow-up, not v0.1.
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app: guildhall

View file

@ -1,72 +0,0 @@
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/guildhouse/substrate/ceremony-service:v0.2.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:
tcpSocket:
port: 50053
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 50053
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 15

View file

@ -1,19 +0,0 @@
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

@ -1,72 +0,0 @@
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/guildhouse/substrate/ffc-schematic-server:v0.2.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:
tcpSocket:
port: 9091
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 9091
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
terminationGracePeriodSeconds: 15

View file

@ -1,19 +0,0 @@
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

23
mix.exs
View file

@ -6,28 +6,7 @@ defmodule Guildhall.MixProject do
apps_path: "apps",
version: "0.1.0",
start_permanent: Mix.env() == :prod,
deps: deps(),
releases: releases()
]
end
# Release configuration. Umbrella projects require an explicit
# `releases:` block — mix release cannot infer a default from the
# umbrella root because there is no single "application" to release.
# The `:guildhall` release bundles all five umbrella children in a
# startup order that respects the dep graph: ops_db first (the Repo
# other apps use), then infra apps, then the web endpoint last.
defp releases do
[
guildhall: [
applications: [
guildhall_ops_db: :permanent,
guildhall_chronicle: :permanent,
guildhall_orchestrator: :permanent,
guildhall_graph_bridge: :permanent,
guildhall_web: :permanent
]
]
deps: deps()
]
end

View file

@ -1,8 +1,6 @@
%{
"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"},
@ -12,25 +10,13 @@
"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"},
@ -40,17 +26,12 @@
"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"},
}