Compare commits
10 commits
48a7495ef5
...
50c488b92b
| Author | SHA256 | Date | |
|---|---|---|---|
| 50c488b92b | |||
| c9800c98e2 | |||
| 524c2f53b8 | |||
| f8788dfba9 | |||
| 03e0567341 | |||
| c0959a5376 | |||
| 7730bf3818 | |||
| 115bd178a2 | |||
| c6f1d07ed9 | |||
| 0a6dd03e91 |
73 changed files with 7419 additions and 27 deletions
53
.dockerignore
Normal file
53
.dockerignore
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# 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
|
||||||
1
.forgejo/workflows/.gitkeep
Normal file
1
.forgejo/workflows/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Placeholder for Forgejo CI workflows (WS2 follow-up)
|
||||||
130
CLAUDE.md
Normal file
130
CLAUDE.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# 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, 0x010–0x3FF), `slug`, `guild_type` (msp/isv/nsp), `status` (pending_approval/approved/denied/active/suspended), `registration_ceremony_id`, `trust_domain`, `registrant_did`, `contact_did`
|
||||||
|
- **guild_schematics** — FK to guilds, `template_name`, `schematic_name`, `schematic_version`, `realization_id`, `status` (pending/forked/binding_created/realizing/realized/failed), `realization_snapshot` (map)
|
||||||
|
- **guild_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"
|
||||||
|
```
|
||||||
304
DEPLOY-EXPLORATORY-2026-04-21.md
Normal file
304
DEPLOY-EXPLORATORY-2026-04-21.md
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
# 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.
|
||||||
354
DEPLOY-RUNBOOK.md
Normal file
354
DEPLOY-RUNBOOK.md
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
# 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.
|
||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# 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"]
|
||||||
222
FORGEJO-REGISTRY-INVESTIGATION-2026-04-21.md
Normal file
222
FORGEJO-REGISTRY-INVESTIGATION-2026-04-21.md
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
# 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:** ~30–60 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
131
README.md
|
|
@ -1,4 +1,133 @@
|
||||||
# Guildhall
|
# Guildhall
|
||||||
|
|
||||||
**TODO: Add description**
|
**Ceremony orchestrator and governance UI — Elixir/Phoenix umbrella
|
||||||
|
over substrate CRDs.**
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
|
||||||
57
apps/guildhall_ops_db/lib/guildhall/ops_db/guild.ex
Normal file
57
apps/guildhall_ops_db/lib/guildhall/ops_db/guild.ex
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
defmodule Guildhall.OpsDb.Guild do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@min_guild_id 0x010
|
||||||
|
@max_guild_id 0x3FF
|
||||||
|
|
||||||
|
schema "guilds" do
|
||||||
|
field :guild_id, :integer
|
||||||
|
field :name, :string
|
||||||
|
field :slug, :string
|
||||||
|
field :guild_type, :string
|
||||||
|
field :description, :string
|
||||||
|
field :trust_domain, :string
|
||||||
|
field :contact_did, :string
|
||||||
|
field :registrant_did, :string
|
||||||
|
field :registration_ceremony_id, :string
|
||||||
|
field :status, :string, default: "pending_approval"
|
||||||
|
field :enrollment_accord_ref, :string
|
||||||
|
field :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
has_many :guild_schematics, Guildhall.OpsDb.GuildSchematic
|
||||||
|
has_many :guild_memberships, Guildhall.OpsDb.GuildMembership
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(guild, attrs) do
|
||||||
|
guild
|
||||||
|
|> cast(attrs, [
|
||||||
|
:guild_id,
|
||||||
|
:name,
|
||||||
|
:slug,
|
||||||
|
:guild_type,
|
||||||
|
:description,
|
||||||
|
:trust_domain,
|
||||||
|
:contact_did,
|
||||||
|
:registrant_did,
|
||||||
|
:registration_ceremony_id,
|
||||||
|
:status,
|
||||||
|
:enrollment_accord_ref,
|
||||||
|
:metadata
|
||||||
|
])
|
||||||
|
|> validate_required([:guild_id, :name, :slug, :guild_type, :contact_did, :registrant_did])
|
||||||
|
|> validate_inclusion(:guild_type, ~w(msp isv nsp))
|
||||||
|
|> validate_inclusion(:status, ~w(pending_approval approved denied active suspended))
|
||||||
|
|> validate_number(:guild_id,
|
||||||
|
greater_than_or_equal_to: @min_guild_id,
|
||||||
|
less_than_or_equal_to: @max_guild_id
|
||||||
|
)
|
||||||
|
|> unique_constraint(:guild_id)
|
||||||
|
|> unique_constraint(:slug)
|
||||||
|
|> unique_constraint(:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
defmodule Guildhall.OpsDb.GuildMembership do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
schema "guild_memberships" do
|
||||||
|
belongs_to :guild, Guildhall.OpsDb.Guild
|
||||||
|
|
||||||
|
field :user_did, :string
|
||||||
|
field :user_email, :string
|
||||||
|
field :display_name, :string
|
||||||
|
field :keycloak_sub, :string
|
||||||
|
field :role, :string, default: "apprentice"
|
||||||
|
field :status, :string, default: "pending"
|
||||||
|
field :membership_ceremony_id, :string
|
||||||
|
field :approved_by_did, :string
|
||||||
|
field :approved_at, :utc_datetime_usec
|
||||||
|
field :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(membership, attrs) do
|
||||||
|
membership
|
||||||
|
|> cast(attrs, [
|
||||||
|
:guild_id,
|
||||||
|
:user_did,
|
||||||
|
:user_email,
|
||||||
|
:display_name,
|
||||||
|
:keycloak_sub,
|
||||||
|
:role,
|
||||||
|
:status,
|
||||||
|
:membership_ceremony_id,
|
||||||
|
:approved_by_did,
|
||||||
|
:approved_at,
|
||||||
|
:metadata
|
||||||
|
])
|
||||||
|
|> validate_required([:guild_id, :user_did, :user_email, :keycloak_sub])
|
||||||
|
|> validate_inclusion(:role, ~w(apprentice journeyman master))
|
||||||
|
|> validate_inclusion(:status, ~w(pending approved denied active suspended removed))
|
||||||
|
|> foreign_key_constraint(:guild_id)
|
||||||
|
|> unique_constraint([:guild_id, :user_did])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule Guildhall.OpsDb.GuildMemberships do
|
||||||
|
import Ecto.Query, only: [from: 2]
|
||||||
|
alias Guildhall.OpsDb.{Repo, GuildMembership}
|
||||||
|
|
||||||
|
def list_memberships(guild_id) do
|
||||||
|
Repo.all(from(m in GuildMembership, where: m.guild_id == ^guild_id, order_by: [asc: m.inserted_at]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_membership!(id), do: Repo.get!(GuildMembership, id)
|
||||||
|
|
||||||
|
def active_members(guild_id) do
|
||||||
|
Repo.all(from(m in GuildMembership, where: m.guild_id == ^guild_id and m.status == "active"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def masters_for_guild(guild_id) do
|
||||||
|
Repo.all(
|
||||||
|
from(m in GuildMembership,
|
||||||
|
where: m.guild_id == ^guild_id and m.role == "master" and m.status == "active"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_membership(guild_id, user_did) do
|
||||||
|
Repo.one(from(m in GuildMembership, where: m.guild_id == ^guild_id and m.user_did == ^user_did))
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_membership(attrs) do
|
||||||
|
%GuildMembership{}
|
||||||
|
|> GuildMembership.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_membership(%GuildMembership{} = membership, approver_did) do
|
||||||
|
membership
|
||||||
|
|> GuildMembership.changeset(%{
|
||||||
|
status: "active",
|
||||||
|
approved_by_did: approver_did,
|
||||||
|
approved_at: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
def deny_membership(%GuildMembership{} = membership, approver_did) do
|
||||||
|
membership
|
||||||
|
|> GuildMembership.changeset(%{
|
||||||
|
status: "denied",
|
||||||
|
approved_by_did: approver_did,
|
||||||
|
approved_at: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_role(%GuildMembership{} = membership, new_role) do
|
||||||
|
membership
|
||||||
|
|> GuildMembership.changeset(%{role: new_role})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
defmodule Guildhall.OpsDb.GuildSchematics do
|
||||||
|
import Ecto.Query, only: [from: 2]
|
||||||
|
alias Guildhall.OpsDb.{Repo, GuildSchematic}
|
||||||
|
|
||||||
|
def get_for_guild(guild_id) do
|
||||||
|
Repo.one(
|
||||||
|
from(gs in GuildSchematic,
|
||||||
|
where: gs.guild_id == ^guild_id,
|
||||||
|
order_by: [desc: gs.inserted_at],
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create(attrs) do
|
||||||
|
%GuildSchematic{}
|
||||||
|
|> GuildSchematic.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_schematic(%GuildSchematic{} = gs, attrs) do
|
||||||
|
gs
|
||||||
|
|> GuildSchematic.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_realization_snapshot(%GuildSchematic{} = gs, snapshot) do
|
||||||
|
update_schematic(gs, %{realization_snapshot: snapshot})
|
||||||
|
end
|
||||||
|
end
|
||||||
63
apps/guildhall_ops_db/lib/guildhall/ops_db/guilds.ex
Normal file
63
apps/guildhall_ops_db/lib/guildhall/ops_db/guilds.ex
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
defmodule Guildhall.OpsDb.Guilds do
|
||||||
|
import Ecto.Query
|
||||||
|
alias Guildhall.OpsDb.{Repo, Guild, GuildMembership}
|
||||||
|
alias Ecto.Multi
|
||||||
|
|
||||||
|
def list_guilds(filters \\ []) do
|
||||||
|
Guild
|
||||||
|
|> maybe_filter_status(filters[:status])
|
||||||
|
|> order_by([g], desc: g.inserted_at)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_guild!(id), do: Repo.get!(Guild, id)
|
||||||
|
|
||||||
|
def get_guild_by_slug(slug), do: Repo.get_by(Guild, slug: slug)
|
||||||
|
|
||||||
|
def create_guild(attrs) do
|
||||||
|
%Guild{}
|
||||||
|
|> Guild.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_guild(%Guild{} = guild, attrs) do
|
||||||
|
guild
|
||||||
|
|> Guild.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_guild_id do
|
||||||
|
case Repo.one(from(g in Guild, select: max(g.guild_id))) do
|
||||||
|
nil -> 0x010
|
||||||
|
max_id -> max_id + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_guild(%Guild{} = guild) do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.update(:guild, Guild.changeset(guild, %{status: "approved"}))
|
||||||
|
|> Multi.insert(:master_membership, GuildMembership.changeset(%GuildMembership{}, %{
|
||||||
|
guild_id: guild.id,
|
||||||
|
user_did: guild.registrant_did,
|
||||||
|
user_email: guild.contact_did,
|
||||||
|
display_name: "Guild Registrant",
|
||||||
|
keycloak_sub: "",
|
||||||
|
role: "master",
|
||||||
|
status: "active",
|
||||||
|
approved_by_did: "system",
|
||||||
|
approved_at: now
|
||||||
|
}), on_conflict: :nothing, conflict_target: [:guild_id, :user_did])
|
||||||
|
|> Repo.transaction()
|
||||||
|
end
|
||||||
|
|
||||||
|
def guild_count(status \\ nil) do
|
||||||
|
Guild
|
||||||
|
|> maybe_filter_status(status)
|
||||||
|
|> Repo.aggregate(:count)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_filter_status(query, nil), do: query
|
||||||
|
defp maybe_filter_status(query, status), do: where(query, [g], g.status == ^status)
|
||||||
|
end
|
||||||
45
apps/guildhall_ops_db/lib/guildhall/ops_db/release.ex
Normal file
45
apps/guildhall_ops_db/lib/guildhall/ops_db/release.ex
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuilds do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:guilds) do
|
||||||
|
add :guild_id, :integer, null: false
|
||||||
|
add :name, :string, null: false
|
||||||
|
add :slug, :string, null: false
|
||||||
|
add :guild_type, :string, null: false
|
||||||
|
add :description, :text
|
||||||
|
add :trust_domain, :string
|
||||||
|
add :contact_did, :string, null: false
|
||||||
|
add :registrant_did, :string, null: false
|
||||||
|
add :registration_ceremony_id, :string
|
||||||
|
add :status, :string, null: false, default: "pending_approval"
|
||||||
|
add :enrollment_accord_ref, :string
|
||||||
|
add :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:guilds, [:guild_id])
|
||||||
|
create unique_index(:guilds, [:slug])
|
||||||
|
create unique_index(:guilds, [:name])
|
||||||
|
create index(:guilds, [:status])
|
||||||
|
create index(:guilds, [:guild_type])
|
||||||
|
create index(:guilds, [:registrant_did])
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"ALTER TABLE guilds ADD CONSTRAINT guilds_guild_id_range CHECK (guild_id >= 16 AND guild_id <= 1023)",
|
||||||
|
"ALTER TABLE guilds DROP CONSTRAINT guilds_guild_id_range"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuildSchematics do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:guild_schematics) do
|
||||||
|
add :guild_id, references(:guilds, on_delete: :restrict), null: false
|
||||||
|
add :template_name, :string, null: false
|
||||||
|
add :schematic_name, :string, null: false
|
||||||
|
add :schematic_version, :string, null: false
|
||||||
|
add :tree_hash, :string
|
||||||
|
add :binding_id, :string
|
||||||
|
add :realization_id, :string
|
||||||
|
add :status, :string, null: false, default: "pending"
|
||||||
|
add :customization_params, :map, default: %{}
|
||||||
|
add :realization_snapshot, :map, default: %{}
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:guild_schematics, [:guild_id])
|
||||||
|
create index(:guild_schematics, [:status])
|
||||||
|
create unique_index(:guild_schematics, [:schematic_name, :schematic_version])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Guildhall.OpsDb.Repo.Migrations.CreateGuildMemberships do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:guild_memberships) do
|
||||||
|
add :guild_id, references(:guilds, on_delete: :restrict), null: false
|
||||||
|
add :user_did, :string, null: false
|
||||||
|
add :user_email, :string, null: false
|
||||||
|
add :display_name, :string
|
||||||
|
add :keycloak_sub, :string, null: false
|
||||||
|
add :role, :string, null: false, default: "apprentice"
|
||||||
|
add :status, :string, null: false, default: "pending"
|
||||||
|
add :membership_ceremony_id, :string
|
||||||
|
add :approved_by_did, :string
|
||||||
|
add :approved_at, :utc_datetime_usec
|
||||||
|
add :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime_usec)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:guild_memberships, [:guild_id, :user_did])
|
||||||
|
create index(:guild_memberships, [:guild_id])
|
||||||
|
create index(:guild_memberships, [:user_did])
|
||||||
|
create index(:guild_memberships, [:status])
|
||||||
|
create index(:guild_memberships, [:keycloak_sub])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule Guildhall.OpsDb.Repo.Migrations.AlterGuildSchematicsStatus do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:guild_schematics) do
|
||||||
|
add :founding_override_expires_at, :utc_datetime_usec
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,7 +5,8 @@ defmodule Guildhall.Orchestrator.Application do
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
Guildhall.Orchestrator.CeremonyOrchestrator
|
Guildhall.Orchestrator.CeremonyOrchestrator,
|
||||||
|
Guildhall.Orchestrator.RealizationPoller
|
||||||
]
|
]
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]
|
opts = [strategy: :one_for_one, name: Guildhall.Orchestrator.Supervisor]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
defmodule Guildhall.Orchestrator.CeremonyClient do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias Ceremony.V1.{
|
||||||
|
CeremonyService.Stub,
|
||||||
|
CreateCeremonyRequest,
|
||||||
|
ApproveCeremonyRequest,
|
||||||
|
DenyCeremonyRequest,
|
||||||
|
GetCeremonyRequest,
|
||||||
|
ListPendingCeremoniesRequest,
|
||||||
|
CeremonySubjectMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_guild_registration_ceremony(guild_name, registrant_did, approver_did) do
|
||||||
|
request = %CreateCeremonyRequest{
|
||||||
|
ceremony_type: "single_approval",
|
||||||
|
subject: %CeremonySubjectMsg{
|
||||||
|
subject_type: "custom",
|
||||||
|
reference_id: guild_name,
|
||||||
|
description: "Guild registration: #{guild_name}",
|
||||||
|
metadata: %{"registrant_did" => registrant_did, "approver_did" => approver_did}
|
||||||
|
},
|
||||||
|
required_approvals: 1,
|
||||||
|
approver_roles: ["hub_operator"],
|
||||||
|
ttl_hours: 168
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.create_ceremony(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
|
||||||
|
if response.error != "" do
|
||||||
|
{:error, response.error}
|
||||||
|
else
|
||||||
|
{:ok, response}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_ceremony(ceremony_id, approver_did, role \\ "hub_operator") do
|
||||||
|
request = %ApproveCeremonyRequest{
|
||||||
|
ceremony_id: ceremony_id,
|
||||||
|
approver_identity: approver_did,
|
||||||
|
approver_role: role,
|
||||||
|
comment: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.approve_ceremony(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
|
||||||
|
if response.error != "" do
|
||||||
|
{:error, response.error}
|
||||||
|
else
|
||||||
|
{:ok, response}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def deny_ceremony(ceremony_id, approver_did, role, comment) do
|
||||||
|
request = %DenyCeremonyRequest{
|
||||||
|
ceremony_id: ceremony_id,
|
||||||
|
approver_identity: approver_did,
|
||||||
|
approver_role: role,
|
||||||
|
comment: comment
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.deny_ceremony(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
|
||||||
|
if response.error != "" do
|
||||||
|
{:error, response.error}
|
||||||
|
else
|
||||||
|
{:ok, response}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_ceremony(ceremony_id) do
|
||||||
|
request = %GetCeremonyRequest{ceremony_id: ceremony_id}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.get_ceremony(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
|
||||||
|
if response.error != "" do
|
||||||
|
{:error, response.error}
|
||||||
|
else
|
||||||
|
{:ok, response}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_pending_ceremonies(intent_id \\ "") do
|
||||||
|
request = %ListPendingCeremoniesRequest{intent_id: intent_id}
|
||||||
|
|
||||||
|
with {:ok, channel} <- connect(),
|
||||||
|
{:ok, response} <- Stub.list_pending_ceremonies(channel, request) do
|
||||||
|
GRPC.Stub.disconnect(channel)
|
||||||
|
{:ok, response.ceremonies}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp connect do
|
||||||
|
url = Application.get_env(:guildhall_orchestrator, :ceremony_service_url, "localhost:50053")
|
||||||
|
GRPC.Stub.connect(url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
defmodule Guildhall.Orchestrator.FfcPipeline do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate.{Schema, VariableResolver, WireEncoder}
|
||||||
|
alias Guildhall.Orchestrator.RealizationPoller
|
||||||
|
alias Guildhall.OpsDb.GuildSchematics
|
||||||
|
|
||||||
|
defstruct [
|
||||||
|
:guild,
|
||||||
|
:guild_type,
|
||||||
|
:template,
|
||||||
|
:resolved,
|
||||||
|
:manifest_yaml,
|
||||||
|
:files,
|
||||||
|
:founding_overrides,
|
||||||
|
:schematic_name,
|
||||||
|
:schematic_version,
|
||||||
|
:guild_schematic,
|
||||||
|
:realization_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def deploy(guild, opts \\ []) do
|
||||||
|
guild_type = guild.guild_type
|
||||||
|
version = Keyword.get(opts, :version, "1.0.0")
|
||||||
|
schematic_name = Keyword.get(opts, :schematic_name, "#{guild_type}-#{guild.slug}")
|
||||||
|
|
||||||
|
params = %{
|
||||||
|
"trust_domain" => guild.trust_domain || "#{guild.slug}.guildhouse.dev",
|
||||||
|
"guild_slug" => guild.slug,
|
||||||
|
"guild_name" => guild.slug,
|
||||||
|
"registrant_did" => guild.registrant_did
|
||||||
|
}
|
||||||
|
|
||||||
|
state = %__MODULE__{
|
||||||
|
guild: guild,
|
||||||
|
guild_type: guild_type,
|
||||||
|
schematic_name: schematic_name,
|
||||||
|
schematic_version: version
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, state} <- step(:load_template, state, fn -> load_template(state) end),
|
||||||
|
{:ok, state} <- step(:validate_template, state, fn -> validate_template(state) end),
|
||||||
|
{:ok, state} <- step(:resolve_variables, state, fn -> resolve_variables(state, params) end),
|
||||||
|
{:ok, state} <- step(:validate_resolved, state, fn -> validate_resolved(state) end),
|
||||||
|
{:ok, state} <- step(:encode_wire, state, fn -> encode_wire(state) end),
|
||||||
|
{:ok, state} <- step(:create_schematic, state, fn -> create_schematic(state) end),
|
||||||
|
{:ok, state} <- step(:validate_schematic, state, fn -> validate_schematic(state) end),
|
||||||
|
{:ok, state} <- step(:approve_schematic, state, fn -> approve_schematic(state) end),
|
||||||
|
{:ok, state} <- step(:publish_schematic, state, fn -> publish_schematic(state) end),
|
||||||
|
{:ok, state} <- step(:realize_schematic, state, fn -> realize_schematic(state) end),
|
||||||
|
{:ok, state} <- step(:create_db_record, state, fn -> create_db_record(state) end),
|
||||||
|
{:ok, state} <- step(:start_poller, state, fn -> start_poller(state) end) do
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp step(name, _state, fun) do
|
||||||
|
case fun.() do
|
||||||
|
{:ok, new_state} -> {:ok, new_state}
|
||||||
|
{:error, reason} -> {:error, {name, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_template(%{guild_type: guild_type} = state) do
|
||||||
|
case SchematicTemplate.load_template(guild_type) do
|
||||||
|
{:ok, template} -> {:ok, %{state | template: template}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_template(%{template: template} = state) do
|
||||||
|
case Schema.validate(template) do
|
||||||
|
:ok -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_variables(%{template: template} = state, params) do
|
||||||
|
case VariableResolver.resolve(template, params) do
|
||||||
|
{:ok, resolved} -> {:ok, %{state | resolved: resolved}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_resolved(%{resolved: resolved} = state) do
|
||||||
|
case Schema.validate_resolved(resolved) do
|
||||||
|
:ok -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp encode_wire(%{resolved: resolved, schematic_name: name, schematic_version: version} = state) do
|
||||||
|
hub_did = Application.get_env(:guildhall_orchestrator, :hub_did, "did:web:guildhouse.dev")
|
||||||
|
|
||||||
|
case WireEncoder.encode(resolved, name, version, hub_did: hub_did) do
|
||||||
|
{:ok, manifest_yaml, files} ->
|
||||||
|
overrides = WireEncoder.extract_founding_overrides(resolved)
|
||||||
|
|
||||||
|
if map_size(overrides) > 0 do
|
||||||
|
Logger.warning("Founding overrides active: #{inspect(overrides)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{state | manifest_yaml: manifest_yaml, files: files, founding_overrides: overrides}}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_schematic(%{schematic_name: name, schematic_version: version, manifest_yaml: manifest, files: files} = state) do
|
||||||
|
case client().create_ffc_schematic(name, version, manifest, files) do
|
||||||
|
{:ok, _response} -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_schematic(%{schematic_name: name, schematic_version: version} = state) do
|
||||||
|
case client().validate_ffc_schematic(name, version) do
|
||||||
|
{:ok, _response} -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp approve_schematic(%{schematic_name: name, schematic_version: version} = state) do
|
||||||
|
Logger.info("STUB: orgops approve for #{name}@#{version} — signing deferred to governed shell")
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp publish_schematic(%{schematic_name: name, schematic_version: version} = state) do
|
||||||
|
case client().publish_ffc_schematic(name, version) do
|
||||||
|
{:ok, _response} -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp realize_schematic(%{schematic_name: name, schematic_version: version} = state) do
|
||||||
|
case client().realize_ffc_schematic(name, version) do
|
||||||
|
{:ok, response} -> {:ok, %{state | realization_id: response.realization_id}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_db_record(%{guild: guild} = state) do
|
||||||
|
override_ttl_days =
|
||||||
|
Application.get_env(:guildhall_orchestrator, :founding_override_ttl_days, 90)
|
||||||
|
|
||||||
|
overrides = state.founding_overrides || %{}
|
||||||
|
|
||||||
|
expires_at =
|
||||||
|
if map_size(overrides) > 0 do
|
||||||
|
DateTime.utc_now() |> DateTime.add(override_ttl_days * 86_400, :second) |> DateTime.truncate(:microsecond)
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
guild_id: guild.id,
|
||||||
|
template_name: "#{state.guild_type}-founding",
|
||||||
|
schematic_name: state.schematic_name,
|
||||||
|
schematic_version: state.schematic_version,
|
||||||
|
realization_id: state.realization_id,
|
||||||
|
status: "realizing",
|
||||||
|
customization_params: overrides,
|
||||||
|
founding_override_expires_at: expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
case GuildSchematics.create(attrs) do
|
||||||
|
{:ok, gs} -> {:ok, %{state | guild_schematic: gs}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_poller(%{realization_id: realization_id, guild: guild} = state) do
|
||||||
|
RealizationPoller.watch(realization_id, guild.slug)
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp client do
|
||||||
|
Application.get_env(:guildhall_orchestrator, :schematic_client, Guildhall.Orchestrator.SchematicClient)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
defmodule Ceremony.V1.CreateCeremonyRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CreateCeremonyRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_type, 1, type: :string, json_name: "ceremonyType"
|
||||||
|
field :subject, 2, type: Ceremony.V1.CeremonySubjectMsg
|
||||||
|
field :required_approvals, 3, type: :uint32, json_name: "requiredApprovals"
|
||||||
|
field :approver_roles, 4, repeated: true, type: :string, json_name: "approverRoles"
|
||||||
|
field :ttl_hours, 5, type: :uint32, json_name: "ttlHours"
|
||||||
|
field :intent_id, 6, type: :string, json_name: "intentId"
|
||||||
|
field :run_id, 7, type: :string, json_name: "runId"
|
||||||
|
field :pr_number, 8, type: :uint64, json_name: "prNumber"
|
||||||
|
field :remote_name, 9, type: :string, json_name: "remoteName"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CreateCeremonyResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CreateCeremonyResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
field :status, 2, type: :string
|
||||||
|
field :expires_at, 3, type: Google.Protobuf.Timestamp, json_name: "expiresAt"
|
||||||
|
field :error, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.ApproveCeremonyRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.ApproveCeremonyRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
field :approver_identity, 2, type: :string, json_name: "approverIdentity"
|
||||||
|
field :approver_role, 3, type: :string, json_name: "approverRole"
|
||||||
|
field :comment, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.ApproveCeremonyResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.ApproveCeremonyResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :success, 1, type: :bool
|
||||||
|
field :status, 2, type: :string
|
||||||
|
field :error, 3, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.DenyCeremonyRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.DenyCeremonyRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
field :approver_identity, 2, type: :string, json_name: "approverIdentity"
|
||||||
|
field :approver_role, 3, type: :string, json_name: "approverRole"
|
||||||
|
field :comment, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.DenyCeremonyResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.DenyCeremonyResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :success, 1, type: :bool
|
||||||
|
field :status, 2, type: :string
|
||||||
|
field :error, 3, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CancelCeremonyRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CancelCeremonyRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CancelCeremonyResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CancelCeremonyResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :success, 1, type: :bool
|
||||||
|
field :error, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.GetCeremonyRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.GetCeremonyRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.GetCeremonyResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.GetCeremonyResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
field :ceremony_type, 2, type: :string, json_name: "ceremonyType"
|
||||||
|
field :subject, 3, type: Ceremony.V1.CeremonySubjectMsg
|
||||||
|
field :status, 4, type: :string
|
||||||
|
field :required_approvals, 5, type: :uint32, json_name: "requiredApprovals"
|
||||||
|
field :current_approvals, 6, type: :uint32, json_name: "currentApprovals"
|
||||||
|
field :approvals, 7, repeated: true, type: Ceremony.V1.CeremonyApprovalMsg
|
||||||
|
field :created_at, 8, type: Google.Protobuf.Timestamp, json_name: "createdAt"
|
||||||
|
field :expires_at, 9, type: Google.Protobuf.Timestamp, json_name: "expiresAt"
|
||||||
|
field :intent_id, 10, type: :string, json_name: "intentId"
|
||||||
|
field :run_id, 11, type: :string, json_name: "runId"
|
||||||
|
field :pr_number, 12, type: :uint64, json_name: "prNumber"
|
||||||
|
field :remote_name, 13, type: :string, json_name: "remoteName"
|
||||||
|
field :error, 14, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.ListPendingCeremoniesRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.ListPendingCeremoniesRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :intent_id, 1, type: :string, json_name: "intentId"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.ListPendingCeremoniesResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.ListPendingCeremoniesResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremonies, 1, repeated: true, type: Ceremony.V1.GetCeremonyResponse
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.GetCeremonyProofRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.GetCeremonyProofRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.GetCeremonyProofResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.GetCeremonyProofResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :ceremony_id, 1, type: :string, json_name: "ceremonyId"
|
||||||
|
field :status, 2, type: :string
|
||||||
|
field :proof_hash, 3, type: :string, json_name: "proofHash"
|
||||||
|
field :approvals, 4, repeated: true, type: Ceremony.V1.CeremonyApprovalMsg
|
||||||
|
field :resolved_at, 5, type: Google.Protobuf.Timestamp, json_name: "resolvedAt"
|
||||||
|
field :error, 6, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CheckCeremonyRequirementRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CheckCeremonyRequirementRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :classification, 1, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CheckCeremonyRequirementResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CheckCeremonyRequirementResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :outcome, 1, type: :string
|
||||||
|
field :ceremony_type, 2, type: :string, json_name: "ceremonyType"
|
||||||
|
field :approver_roles, 3, repeated: true, type: :string, json_name: "approverRoles"
|
||||||
|
field :required_approvals, 4, type: :uint32, json_name: "requiredApprovals"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CeremonySubjectMsg.MetadataEntry do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CeremonySubjectMsg.MetadataEntry",
|
||||||
|
map: true,
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :key, 1, type: :string
|
||||||
|
field :value, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CeremonySubjectMsg do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CeremonySubjectMsg",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :subject_type, 1, type: :string, json_name: "subjectType"
|
||||||
|
field :reference_id, 2, type: :string, json_name: "referenceId"
|
||||||
|
field :description, 3, type: :string
|
||||||
|
|
||||||
|
field :metadata, 4,
|
||||||
|
repeated: true,
|
||||||
|
type: Ceremony.V1.CeremonySubjectMsg.MetadataEntry,
|
||||||
|
map: true
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CeremonyApprovalMsg do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "ceremony.v1.CeremonyApprovalMsg",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :approver_identity, 1, type: :string, json_name: "approverIdentity"
|
||||||
|
field :approver_role, 2, type: :string, json_name: "approverRole"
|
||||||
|
field :decision, 3, type: :string
|
||||||
|
field :comment, 4, type: :string
|
||||||
|
field :decided_at, 5, type: Google.Protobuf.Timestamp, json_name: "decidedAt"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CeremonyService.Service do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GRPC.Service, name: "ceremony.v1.CeremonyService", protoc_gen_elixir_version: "0.16.0"
|
||||||
|
|
||||||
|
rpc :CreateCeremony, Ceremony.V1.CreateCeremonyRequest, Ceremony.V1.CreateCeremonyResponse
|
||||||
|
|
||||||
|
rpc :ApproveCeremony, Ceremony.V1.ApproveCeremonyRequest, Ceremony.V1.ApproveCeremonyResponse
|
||||||
|
|
||||||
|
rpc :DenyCeremony, Ceremony.V1.DenyCeremonyRequest, Ceremony.V1.DenyCeremonyResponse
|
||||||
|
|
||||||
|
rpc :CancelCeremony, Ceremony.V1.CancelCeremonyRequest, Ceremony.V1.CancelCeremonyResponse
|
||||||
|
|
||||||
|
rpc :GetCeremony, Ceremony.V1.GetCeremonyRequest, Ceremony.V1.GetCeremonyResponse
|
||||||
|
|
||||||
|
rpc :ListPendingCeremonies,
|
||||||
|
Ceremony.V1.ListPendingCeremoniesRequest,
|
||||||
|
Ceremony.V1.ListPendingCeremoniesResponse
|
||||||
|
|
||||||
|
rpc :GetCeremonyProof, Ceremony.V1.GetCeremonyProofRequest, Ceremony.V1.GetCeremonyProofResponse
|
||||||
|
|
||||||
|
rpc :CheckCeremonyRequirement,
|
||||||
|
Ceremony.V1.CheckCeremonyRequirementRequest,
|
||||||
|
Ceremony.V1.CheckCeremonyRequirementResponse
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Ceremony.V1.CeremonyService.Stub do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GRPC.Stub, service: Ceremony.V1.CeremonyService.Service
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,452 @@
|
||||||
|
defmodule Schematic.V1.CreateSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :manifest_yaml, 3, type: :bytes, json_name: "manifestYaml"
|
||||||
|
field :files, 4, repeated: true, type: Schematic.V1.SchematicFile
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.SchematicFile do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.SchematicFile",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :path, 1, type: :string
|
||||||
|
field :content, 2, type: :bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.CreateSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :tree_hash, 3, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :tree_hash, 3, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 4, type: :string
|
||||||
|
field :parent_hash, 5, type: :string, json_name: "parentHash"
|
||||||
|
field :stakeholders, 6, repeated: true, type: Schematic.V1.StakeholderInfo
|
||||||
|
field :created_at, 7, type: Google.Protobuf.Timestamp, json_name: "createdAt"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.StakeholderInfo do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.StakeholderInfo",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :role, 1, type: :string
|
||||||
|
field :identity, 2, type: :string
|
||||||
|
field :required, 3, type: :bool
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ListVersionsRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ListVersionsRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ListVersionsResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ListVersionsResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :versions, 1, repeated: true, type: Schematic.V1.VersionInfo
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.VersionInfo do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.VersionInfo",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :version, 1, type: :string
|
||||||
|
field :tree_hash, 2, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 3, type: :string
|
||||||
|
field :parent_hash, 4, type: :string, json_name: "parentHash"
|
||||||
|
field :created_at, 5, type: Google.Protobuf.Timestamp, json_name: "createdAt"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.UpdateSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.UpdateSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :files, 3, repeated: true, type: Schematic.V1.SchematicFile
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.UpdateSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.UpdateSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :tree_hash, 1, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ValidateSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ValidateSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ValidateSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ValidateSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :valid, 1, type: :bool
|
||||||
|
field :error_count, 2, type: :int32, json_name: "errorCount"
|
||||||
|
field :warning_count, 3, type: :int32, json_name: "warningCount"
|
||||||
|
field :results, 4, repeated: true, type: Schematic.V1.ValidationResultProto
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ValidationResultProto do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ValidationResultProto",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :validator, 1, type: :string
|
||||||
|
field :passed, 2, type: :bool
|
||||||
|
field :message, 3, type: :string
|
||||||
|
field :severity, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ApproveSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ApproveSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :role, 3, type: :string
|
||||||
|
field :identity, 4, type: :string
|
||||||
|
field :tree_hash, 5, type: :string, json_name: "treeHash"
|
||||||
|
field :comment, 6, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ApproveSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ApproveSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :accepted, 1, type: :bool
|
||||||
|
field :approval_status, 2, type: :string, json_name: "approvalStatus"
|
||||||
|
field :message, 3, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetApprovalStatusRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetApprovalStatusRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetApprovalStatusResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetApprovalStatusResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :status, 1, type: :string
|
||||||
|
field :approved_roles, 2, repeated: true, type: :string, json_name: "approvedRoles"
|
||||||
|
field :remaining_roles, 3, repeated: true, type: :string, json_name: "remainingRoles"
|
||||||
|
field :approvals, 4, repeated: true, type: Schematic.V1.ApprovalInfo
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ApprovalInfo do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ApprovalInfo",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :role, 1, type: :string
|
||||||
|
field :identity, 2, type: :string
|
||||||
|
field :approved_at, 3, type: Google.Protobuf.Timestamp, json_name: "approvedAt"
|
||||||
|
field :comment, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.PublishSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.PublishSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.PublishSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.PublishSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :tree_hash, 1, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.CreateNextVersionRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateNextVersionRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :from_version, 2, type: :string, json_name: "fromVersion"
|
||||||
|
field :bump, 3, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.CreateNextVersionResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateNextVersionResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :version, 1, type: :string
|
||||||
|
field :tree_hash, 2, type: :string, json_name: "treeHash"
|
||||||
|
field :parent_hash, 3, type: :string, json_name: "parentHash"
|
||||||
|
field :status, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ForkSchematicRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ForkSchematicRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :source_name, 1, type: :string, json_name: "sourceName"
|
||||||
|
field :source_version, 2, type: :string, json_name: "sourceVersion"
|
||||||
|
field :new_name, 3, type: :string, json_name: "newName"
|
||||||
|
field :new_version, 4, type: :string, json_name: "newVersion"
|
||||||
|
field :operations, 5, repeated: true, type: Schematic.V1.TemplateOperation
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.TemplateOperation do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.TemplateOperation",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :op_type, 1, type: :string, json_name: "opType"
|
||||||
|
field :path, 2, type: :string
|
||||||
|
field :content, 3, type: :bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.ForkSchematicResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.ForkSchematicResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :name, 1, type: :string
|
||||||
|
field :version, 2, type: :string
|
||||||
|
field :tree_hash, 3, type: :string, json_name: "treeHash"
|
||||||
|
field :status, 4, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.CreateDeploymentBindingRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateDeploymentBindingRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :schematic_name, 1, type: :string, json_name: "schematicName"
|
||||||
|
field :schematic_version, 2, type: :string, json_name: "schematicVersion"
|
||||||
|
field :pipeline_name, 3, type: :string, json_name: "pipelineName"
|
||||||
|
field :target_env, 4, type: :string, json_name: "targetEnv"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.CreateDeploymentBindingResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.CreateDeploymentBindingResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :binding_id, 1, type: :string, json_name: "bindingId"
|
||||||
|
field :status, 2, type: :string
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetDeploymentBindingRequest do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetDeploymentBindingRequest",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :binding_id, 1, type: :string, json_name: "bindingId"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.GetDeploymentBindingResponse do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Protobuf,
|
||||||
|
full_name: "schematic.v1.GetDeploymentBindingResponse",
|
||||||
|
protoc_gen_elixir_version: "0.16.0",
|
||||||
|
syntax: :proto3
|
||||||
|
|
||||||
|
field :binding_id, 1, type: :string, json_name: "bindingId"
|
||||||
|
field :schematic_name, 2, type: :string, json_name: "schematicName"
|
||||||
|
field :schematic_version, 3, type: :string, json_name: "schematicVersion"
|
||||||
|
field :tree_hash, 4, type: :string, json_name: "treeHash"
|
||||||
|
field :pipeline_name, 5, type: :string, json_name: "pipelineName"
|
||||||
|
field :target_env, 6, type: :string, json_name: "targetEnv"
|
||||||
|
field :status, 7, type: :string
|
||||||
|
field :run_id, 8, type: :string, json_name: "runId"
|
||||||
|
field :created_at, 9, type: Google.Protobuf.Timestamp, json_name: "createdAt"
|
||||||
|
field :deployed_at, 10, type: Google.Protobuf.Timestamp, json_name: "deployedAt"
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.SchematicsService.Service do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GRPC.Service, name: "schematic.v1.SchematicsService", protoc_gen_elixir_version: "0.16.0"
|
||||||
|
|
||||||
|
rpc :CreateSchematic, Schematic.V1.CreateSchematicRequest, Schematic.V1.CreateSchematicResponse
|
||||||
|
|
||||||
|
rpc :GetSchematic, Schematic.V1.GetSchematicRequest, Schematic.V1.GetSchematicResponse
|
||||||
|
|
||||||
|
rpc :ListVersions, Schematic.V1.ListVersionsRequest, Schematic.V1.ListVersionsResponse
|
||||||
|
|
||||||
|
rpc :UpdateSchematic, Schematic.V1.UpdateSchematicRequest, Schematic.V1.UpdateSchematicResponse
|
||||||
|
|
||||||
|
rpc :ValidateSchematic,
|
||||||
|
Schematic.V1.ValidateSchematicRequest,
|
||||||
|
Schematic.V1.ValidateSchematicResponse
|
||||||
|
|
||||||
|
rpc :ApproveSchematic,
|
||||||
|
Schematic.V1.ApproveSchematicRequest,
|
||||||
|
Schematic.V1.ApproveSchematicResponse
|
||||||
|
|
||||||
|
rpc :GetApprovalStatus,
|
||||||
|
Schematic.V1.GetApprovalStatusRequest,
|
||||||
|
Schematic.V1.GetApprovalStatusResponse
|
||||||
|
|
||||||
|
rpc :PublishSchematic,
|
||||||
|
Schematic.V1.PublishSchematicRequest,
|
||||||
|
Schematic.V1.PublishSchematicResponse
|
||||||
|
|
||||||
|
rpc :CreateNextVersion,
|
||||||
|
Schematic.V1.CreateNextVersionRequest,
|
||||||
|
Schematic.V1.CreateNextVersionResponse
|
||||||
|
|
||||||
|
rpc :ForkSchematic, Schematic.V1.ForkSchematicRequest, Schematic.V1.ForkSchematicResponse
|
||||||
|
|
||||||
|
rpc :CreateDeploymentBinding,
|
||||||
|
Schematic.V1.CreateDeploymentBindingRequest,
|
||||||
|
Schematic.V1.CreateDeploymentBindingResponse
|
||||||
|
|
||||||
|
rpc :GetDeploymentBinding,
|
||||||
|
Schematic.V1.GetDeploymentBindingRequest,
|
||||||
|
Schematic.V1.GetDeploymentBindingResponse
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Schematic.V1.SchematicsService.Stub do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GRPC.Stub, service: Schematic.V1.SchematicsService.Service
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
defmodule Guildhall.Orchestrator.RealizationStatus do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@terminal_statuses ~w(
|
||||||
|
FFC_SCHEMATIC_STATUS_REALIZED
|
||||||
|
FFC_SCHEMATIC_STATUS_ARCHIVED
|
||||||
|
FFC_SCHEMATIC_STATUS_WITHDRAWN
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_snapshot(response) do
|
||||||
|
%{
|
||||||
|
overall_status: to_string(response.overall_status),
|
||||||
|
sections:
|
||||||
|
Enum.map(response.per_section, fn s ->
|
||||||
|
%{name: s.section_name, status: s.status, message: s.message}
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminal_status?(status) when is_atom(status), do: to_string(status) in @terminal_statuses
|
||||||
|
def terminal_status?(status) when is_binary(status), do: status in @terminal_statuses
|
||||||
|
def terminal_status?(_), do: false
|
||||||
|
|
||||||
|
def classify_section("succeeded"), do: {:succeeded, "Succeeded"}
|
||||||
|
def classify_section("in_progress"), do: {:in_progress, "In progress"}
|
||||||
|
def classify_section("failed"), do: {:failed, "Failed"}
|
||||||
|
def classify_section("pending"), do: {:pending, "Pending"}
|
||||||
|
def classify_section("skipped:no_change"), do: {:skipped, "Skipped (no change)"}
|
||||||
|
def classify_section("skipped:not_implemented"), do: {:skipped, "Skipped (stub)"}
|
||||||
|
def classify_section("skipped:upstream_unavailable:" <> svc), do: {:skipped, "Skipped (#{svc} unavailable)"}
|
||||||
|
def classify_section("skipped:" <> _reason), do: {:skipped, "Skipped"}
|
||||||
|
|
||||||
|
def classify_section(unknown) do
|
||||||
|
Logger.warning("Unknown reconciler section status: #{inspect(unknown)}")
|
||||||
|
{:pending, to_string(unknown)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def derive_overall(snapshot) do
|
||||||
|
sections = Map.get(snapshot, :sections, Map.get(snapshot, "sections", []))
|
||||||
|
|
||||||
|
statuses =
|
||||||
|
Enum.map(sections, fn s ->
|
||||||
|
raw = s[:status] || s["status"] || "pending"
|
||||||
|
{class, _label} = classify_section(raw)
|
||||||
|
class
|
||||||
|
end)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
statuses == [] -> :pending
|
||||||
|
Enum.all?(statuses, &(&1 in [:succeeded, :skipped])) -> :realized
|
||||||
|
Enum.any?(statuses, &(&1 == :failed)) and Enum.any?(statuses, &(&1 in [:succeeded, :skipped])) -> :partially_realized
|
||||||
|
Enum.any?(statuses, &(&1 == :failed)) -> :failed
|
||||||
|
Enum.any?(statuses, &(&1 == :in_progress)) -> :in_progress
|
||||||
|
true -> :pending
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_status(:realized), do: "Realized"
|
||||||
|
def display_status(:partially_realized), do: "Partially realized"
|
||||||
|
def display_status(:failed), do: "Failed"
|
||||||
|
def display_status(:in_progress), do: "In progress"
|
||||||
|
def display_status(:pending), do: "Pending"
|
||||||
|
|
||||||
|
def overall_css_class(:realized), do: "bg-emerald-100 text-emerald-700"
|
||||||
|
def overall_css_class(:partially_realized), do: "bg-amber-100 text-amber-700"
|
||||||
|
def overall_css_class(:failed), do: "bg-red-100 text-red-700"
|
||||||
|
def overall_css_class(:in_progress), do: "bg-amber-100 text-amber-700"
|
||||||
|
def overall_css_class(:pending), do: "bg-zinc-100 text-zinc-700"
|
||||||
|
|
||||||
|
def section_icon_class("succeeded"), do: "text-emerald-600"
|
||||||
|
def section_icon_class("in_progress"), do: "text-amber-600"
|
||||||
|
def section_icon_class("failed"), do: "text-red-600"
|
||||||
|
def section_icon_class("pending"), do: "text-zinc-400"
|
||||||
|
def section_icon_class("skipped:" <> _), do: "text-sky-500"
|
||||||
|
def section_icon_class(_), do: "text-zinc-400"
|
||||||
|
|
||||||
|
def section_symbol("succeeded"), do: "✓"
|
||||||
|
def section_symbol("in_progress"), do: "⟳"
|
||||||
|
def section_symbol("failed"), do: "✗"
|
||||||
|
def section_symbol("skipped:" <> _), do: "⊘"
|
||||||
|
def section_symbol(_), do: "○"
|
||||||
|
|
||||||
|
def section_display_status(status) do
|
||||||
|
{_class, label} = classify_section(status)
|
||||||
|
label
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_known_statuses do
|
||||||
|
~w(succeeded in_progress failed pending skipped:no_change skipped:not_implemented skipped:upstream_unavailable:service)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicClient.Behaviour do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@callback create_ffc_schematic(String.t(), String.t(), binary(), [{String.t(), binary()}]) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
|
||||||
|
@callback validate_ffc_schematic(String.t(), String.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
|
||||||
|
@callback publish_ffc_schematic(String.t(), String.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
|
||||||
|
@callback realize_ffc_schematic(String.t(), String.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
|
||||||
|
@callback get_realization_status(String.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,387 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.Schema do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@known_sections ~w(meta trust_domain identity_authority members infrastructure ceremonies federation_peers attestation)
|
||||||
|
|
||||||
|
@valid_ceremony_types ~w(single_approval multi_party)
|
||||||
|
@valid_roles ~w(master journeyman apprentice)
|
||||||
|
@valid_federation_modes ~w(hub_spoke mesh)
|
||||||
|
@valid_trust_levels ~w(federated local)
|
||||||
|
@valid_providers ~w(keycloak)
|
||||||
|
|
||||||
|
@tier_requirements %{
|
||||||
|
1 => %{require_tpm: false, require_secure_boot: false, mfa_required: false},
|
||||||
|
2 => %{require_tpm: false, require_secure_boot: false, mfa_required: true},
|
||||||
|
3 => %{require_tpm: true, require_secure_boot: true, mfa_required: true, hardware_credential_required: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
@type_to_federation %{
|
||||||
|
"msp" => "hub_spoke",
|
||||||
|
"isv" => "hub_spoke",
|
||||||
|
"nsp" => "mesh"
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate(template) when is_map(template) do
|
||||||
|
with :ok <- check_unknown_sections(template),
|
||||||
|
:ok <- validate_meta(template["meta"]),
|
||||||
|
:ok <- validate_trust_domain(template["trust_domain"]),
|
||||||
|
:ok <- validate_identity_authority(template["identity_authority"]),
|
||||||
|
:ok <- validate_members(template["members"]),
|
||||||
|
:ok <- validate_infrastructure(template["infrastructure"]),
|
||||||
|
:ok <- validate_ceremonies(template["ceremonies"]),
|
||||||
|
:ok <- validate_federation_peers(template["federation_peers"]),
|
||||||
|
:ok <- validate_attestation(template["attestation"]),
|
||||||
|
:ok <- validate_cross_section(template) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate(nil), do: {:error, {:missing_section, "template is nil"}}
|
||||||
|
def validate(_), do: {:error, {:invalid_type, "template must be a map"}}
|
||||||
|
|
||||||
|
def validate_resolved(template) when is_map(template) do
|
||||||
|
with :ok <- validate_resolved_values(template) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Section validators ---
|
||||||
|
|
||||||
|
defp check_unknown_sections(template) do
|
||||||
|
unknown = Map.keys(template) -- @known_sections
|
||||||
|
if unknown == [], do: :ok, else: {:error, {:unknown_sections, unknown}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_meta(nil), do: {:error, {:missing_section, "meta"}}
|
||||||
|
|
||||||
|
defp validate_meta(meta) when is_map(meta) do
|
||||||
|
with :ok <- require_string(meta, "template_name", "meta"),
|
||||||
|
:ok <- require_string(meta, "source_schematic", "meta"),
|
||||||
|
:ok <- require_string(meta, "source_version", "meta") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_trust_domain(nil), do: {:error, {:missing_section, "trust_domain"}}
|
||||||
|
|
||||||
|
defp validate_trust_domain(td) when is_map(td) do
|
||||||
|
with :ok <- require_string(td, "spiffe_trust_domain", "trust_domain"),
|
||||||
|
:ok <- require_integer_in(td, "attestation_tier", 1..3, "trust_domain") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_identity_authority(nil), do: {:error, {:missing_section, "identity_authority"}}
|
||||||
|
|
||||||
|
defp validate_identity_authority(ia) when is_map(ia) do
|
||||||
|
with :ok <- require_string(ia, "provider", "identity_authority"),
|
||||||
|
:ok <- require_inclusion(ia, "provider", @valid_providers, "identity_authority"),
|
||||||
|
:ok <- require_string(ia, "url", "identity_authority"),
|
||||||
|
:ok <- require_string(ia, "realm", "identity_authority"),
|
||||||
|
:ok <- optional_inclusion(ia, "trust_level", @valid_trust_levels, "identity_authority"),
|
||||||
|
:ok <- optional_boolean(ia, "mfa_required", "identity_authority"),
|
||||||
|
:ok <- optional_boolean(ia, "hardware_credential_required", "identity_authority") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_members(nil), do: {:error, {:missing_section, "members"}}
|
||||||
|
|
||||||
|
defp validate_members(m) when is_map(m) do
|
||||||
|
with :ok <- require_string(m, "founding_master_did", "members"),
|
||||||
|
:ok <- require_string_list(m, "initial_roles", "members"),
|
||||||
|
:ok <- validate_roles(m["initial_roles"], "members.initial_roles") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_infrastructure(nil), do: {:error, {:missing_section, "infrastructure"}}
|
||||||
|
|
||||||
|
defp validate_infrastructure(infra) when is_map(infra) do
|
||||||
|
with :ok <- require_integer_in(infra, "compute_attestation_tier", 1..3, "infrastructure"),
|
||||||
|
:ok <- optional_boolean(infra, "wireguard_tunnel", "infrastructure"),
|
||||||
|
:ok <- optional_boolean(infra, "vpp_dataplane", "infrastructure") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_ceremonies(nil), do: {:error, {:missing_section, "ceremonies"}}
|
||||||
|
|
||||||
|
defp validate_ceremonies(c) when is_map(c) do
|
||||||
|
with :ok <- require_map(c, "code_change", "ceremonies"),
|
||||||
|
:ok <- validate_ceremony_rule(c["code_change"], "ceremonies.code_change"),
|
||||||
|
:ok <- require_map(c, "governance_change", "ceremonies"),
|
||||||
|
:ok <- validate_ceremony_rule(c["governance_change"], "ceremonies.governance_change") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_ceremony_rule(rule, path) when is_map(rule) do
|
||||||
|
with :ok <- require_string(rule, "type", path),
|
||||||
|
:ok <- require_inclusion(rule, "type", @valid_ceremony_types, path),
|
||||||
|
:ok <- require_string_list(rule, "eligible_roles", path),
|
||||||
|
:ok <- validate_roles(rule["eligible_roles"], "#{path}.eligible_roles"),
|
||||||
|
:ok <- require_positive_integer(rule, "quorum", path),
|
||||||
|
:ok <- validate_founding_override(rule, path) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_ceremony_rule(_, path), do: {:error, {:invalid_type, "#{path} must be a map"}}
|
||||||
|
|
||||||
|
defp validate_founding_override(rule, path) do
|
||||||
|
case rule["founding_override"] do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
override when is_integer(override) ->
|
||||||
|
cond do
|
||||||
|
rule["type"] != "multi_party" ->
|
||||||
|
{:error, {:invalid_value, "#{path}.founding_override is only valid on multi_party ceremonies"}}
|
||||||
|
|
||||||
|
override < 1 ->
|
||||||
|
{:error, {:invalid_value, "#{path}.founding_override must be >= 1"}}
|
||||||
|
|
||||||
|
override >= rule["quorum"] ->
|
||||||
|
{:error, {:invalid_value, "#{path}.founding_override must be less than quorum (#{rule["quorum"]})"}}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, {:invalid_type, "#{path}.founding_override must be an integer"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_federation_peers(nil), do: {:error, {:missing_section, "federation_peers"}}
|
||||||
|
|
||||||
|
defp validate_federation_peers(fp) when is_map(fp) do
|
||||||
|
with :ok <- require_string(fp, "mode", "federation_peers"),
|
||||||
|
:ok <- require_inclusion(fp, "mode", @valid_federation_modes, "federation_peers"),
|
||||||
|
:ok <- require_string(fp, "hub_trust_domain", "federation_peers") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_attestation(nil), do: {:error, {:missing_section, "attestation"}}
|
||||||
|
|
||||||
|
defp validate_attestation(att) when is_map(att) do
|
||||||
|
with :ok <- require_integer_in(att, "tier", 1..3, "attestation"),
|
||||||
|
:ok <- optional_boolean(att, "require_tpm", "attestation"),
|
||||||
|
:ok <- optional_boolean(att, "require_secure_boot", "attestation") do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Cross-section validators ---
|
||||||
|
|
||||||
|
defp validate_cross_section(template) do
|
||||||
|
with :ok <- validate_tier_consistency(template),
|
||||||
|
:ok <- validate_tier_requirements(template),
|
||||||
|
:ok <- validate_federation_type(template),
|
||||||
|
:ok <- validate_mesh_wireguard(template) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_tier_consistency(template) do
|
||||||
|
td_tier = get_in(template, ["trust_domain", "attestation_tier"])
|
||||||
|
infra_tier = get_in(template, ["infrastructure", "compute_attestation_tier"])
|
||||||
|
att_tier = get_in(template, ["attestation", "tier"])
|
||||||
|
|
||||||
|
tiers = [td_tier, infra_tier, att_tier] |> Enum.reject(&is_nil/1) |> Enum.uniq()
|
||||||
|
|
||||||
|
if length(tiers) <= 1 do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, {:tier_mismatch, "attestation tiers must be consistent: trust_domain=#{td_tier}, infrastructure=#{infra_tier}, attestation=#{att_tier}"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_tier_requirements(template) do
|
||||||
|
tier = get_in(template, ["attestation", "tier"])
|
||||||
|
reqs = Map.get(@tier_requirements, tier)
|
||||||
|
|
||||||
|
if is_nil(reqs) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
errors =
|
||||||
|
[]
|
||||||
|
|> check_tier_bool(template, ["attestation", "require_tpm"], reqs[:require_tpm], "attestation.require_tpm")
|
||||||
|
|> check_tier_bool(template, ["attestation", "require_secure_boot"], reqs[:require_secure_boot], "attestation.require_secure_boot")
|
||||||
|
|> check_tier_bool(template, ["identity_authority", "mfa_required"], reqs[:mfa_required], "identity_authority.mfa_required")
|
||||||
|
|> maybe_check_tier_bool(template, ["identity_authority", "hardware_credential_required"], reqs[:hardware_credential_required], "identity_authority.hardware_credential_required")
|
||||||
|
|
||||||
|
case errors do
|
||||||
|
[] -> :ok
|
||||||
|
errs -> {:error, {:tier_requirement_mismatch, Enum.reverse(errs)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_tier_bool(errors, template, path, expected, label) do
|
||||||
|
actual = get_in(template, path)
|
||||||
|
|
||||||
|
if not is_nil(actual) and actual != expected do
|
||||||
|
["#{label}: expected #{expected} for tier #{get_in(template, ["attestation", "tier"])}, got #{actual}" | errors]
|
||||||
|
else
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_check_tier_bool(errors, template, path, nil, _label), do: errors |> check_tier_bool(template, path, nil, "")
|
||||||
|
defp maybe_check_tier_bool(errors, template, path, expected, label), do: check_tier_bool(errors, template, path, expected, label)
|
||||||
|
|
||||||
|
defp validate_federation_type(template) do
|
||||||
|
template_name = get_in(template, ["meta", "template_name"]) || ""
|
||||||
|
mode = get_in(template, ["federation_peers", "mode"])
|
||||||
|
|
||||||
|
guild_type =
|
||||||
|
cond do
|
||||||
|
String.contains?(template_name, "msp") -> "msp"
|
||||||
|
String.contains?(template_name, "isv") -> "isv"
|
||||||
|
String.contains?(template_name, "nsp") -> "nsp"
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
expected = Map.get(@type_to_federation, guild_type)
|
||||||
|
|
||||||
|
if is_nil(expected) or is_nil(mode) or mode == expected do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, {:federation_mode_mismatch, "#{guild_type} templates must use #{expected} federation, got #{mode}"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_mesh_wireguard(template) do
|
||||||
|
mode = get_in(template, ["federation_peers", "mode"])
|
||||||
|
wg = get_in(template, ["infrastructure", "wireguard_tunnel"])
|
||||||
|
|
||||||
|
cond do
|
||||||
|
mode == "mesh" and wg != true ->
|
||||||
|
{:error, {:mesh_requires_wireguard, "mesh federation mode requires infrastructure.wireguard_tunnel = true"}}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Resolved value validators (post-substitution) ---
|
||||||
|
|
||||||
|
defp validate_resolved_values(template) do
|
||||||
|
errors =
|
||||||
|
[]
|
||||||
|
|> validate_did_format(get_in(template, ["members", "founding_master_did"]), "members.founding_master_did")
|
||||||
|
|> validate_domain_format(get_in(template, ["trust_domain", "spiffe_trust_domain"]), "trust_domain.spiffe_trust_domain")
|
||||||
|
|> validate_slug_format(get_in(template, ["identity_authority", "client_prefix"]), "identity_authority.client_prefix")
|
||||||
|
|
||||||
|
case errors do
|
||||||
|
[] -> :ok
|
||||||
|
errs -> {:error, {:invalid_resolved_values, Enum.reverse(errs)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_did_format(errors, nil, _path), do: errors
|
||||||
|
|
||||||
|
defp validate_did_format(errors, value, path) when is_binary(value) do
|
||||||
|
if String.starts_with?(value, "did:") do
|
||||||
|
errors
|
||||||
|
else
|
||||||
|
["#{path}: must be a DID (got #{inspect(value)})" | errors]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_did_format(errors, _, path), do: ["#{path}: must be a string" | errors]
|
||||||
|
|
||||||
|
defp validate_domain_format(errors, nil, _path), do: errors
|
||||||
|
|
||||||
|
defp validate_domain_format(errors, value, path) when is_binary(value) do
|
||||||
|
if Regex.match?(~r/^[a-z0-9][a-z0-9.\-]*[a-z0-9]$/, value) do
|
||||||
|
errors
|
||||||
|
else
|
||||||
|
["#{path}: must be a valid domain (got #{inspect(value)})" | errors]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_domain_format(errors, _, path), do: ["#{path}: must be a string" | errors]
|
||||||
|
|
||||||
|
defp validate_slug_format(errors, nil, _path), do: errors
|
||||||
|
|
||||||
|
defp validate_slug_format(errors, value, path) when is_binary(value) do
|
||||||
|
if Regex.match?(~r/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/, value) or String.match?(value, ~r/^\{\{.+\}\}$/) do
|
||||||
|
errors
|
||||||
|
else
|
||||||
|
["#{path}: must be a slug (got #{inspect(value)})" | errors]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_slug_format(errors, _, path), do: ["#{path}: must be a string" | errors]
|
||||||
|
|
||||||
|
# --- Primitive validators ---
|
||||||
|
|
||||||
|
defp require_string(map, key, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
|
||||||
|
v when is_binary(v) -> :ok
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a string"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_integer_in(map, key, range, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
|
||||||
|
v when is_integer(v) -> if v in range, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be in #{inspect(range)}"}}
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_positive_integer(map, key, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
|
||||||
|
v when is_integer(v) and v >= 1 -> :ok
|
||||||
|
v when is_integer(v) -> {:error, {:invalid_value, "#{section}.#{key} must be >= 1"}}
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be an integer"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_string_list(map, key, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
|
||||||
|
v when is_list(v) -> if Enum.all?(v, &is_binary/1), do: :ok, else: {:error, {:invalid_type, "#{section}.#{key} must be a list of strings"}}
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a list"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_map(map, key, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> {:error, {:missing_field, "#{section}.#{key}"}}
|
||||||
|
v when is_map(v) -> :ok
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a map"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_inclusion(map, key, allowed, section) do
|
||||||
|
val = Map.get(map, key)
|
||||||
|
if is_nil(val) or val in allowed, do: :ok, else: {:error, {:invalid_value, "#{section}.#{key} must be one of #{inspect(allowed)}, got #{inspect(val)}"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optional_inclusion(_map, _key, _allowed, _section), do: :ok
|
||||||
|
|
||||||
|
defp optional_boolean(map, key, section) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil -> :ok
|
||||||
|
v when is_boolean(v) -> :ok
|
||||||
|
_ -> {:error, {:invalid_type, "#{section}.#{key} must be a boolean"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_roles(nil, _path), do: :ok
|
||||||
|
|
||||||
|
defp validate_roles(roles, path) when is_list(roles) do
|
||||||
|
invalid = Enum.reject(roles, &(&1 in @valid_roles))
|
||||||
|
if invalid == [], do: :ok, else: {:error, {:invalid_value, "#{path} contains invalid roles: #{inspect(invalid)}"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_roles(_, path), do: {:error, {:invalid_type, "#{path} must be a list"}}
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolver do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@placeholder_regex ~r/\{\{([a-z_]+)\}\}/
|
||||||
|
|
||||||
|
@known_variables ~w(trust_domain guild_slug guild_name registrant_did)
|
||||||
|
|
||||||
|
def resolve(template, params) when is_map(template) and is_map(params) do
|
||||||
|
with :ok <- check_params(params),
|
||||||
|
{:ok, resolved} <- substitute(template, params),
|
||||||
|
:ok <- check_no_remaining(resolved) do
|
||||||
|
{:ok, resolved}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def known_variables, do: @known_variables
|
||||||
|
|
||||||
|
defp check_params(params) do
|
||||||
|
errors =
|
||||||
|
Enum.reduce(@known_variables, [], fn var, acc ->
|
||||||
|
case Map.get(params, var) do
|
||||||
|
nil -> [{:missing_param, var} | acc]
|
||||||
|
"" -> [{:empty_param, var} | acc]
|
||||||
|
v when is_binary(v) -> acc
|
||||||
|
v -> [{:invalid_param, var, "expected string, got #{inspect(v)}"} | acc]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
placeholders_in_use = collect_placeholders(params |> Map.values() |> Enum.join(" "))
|
||||||
|
|
||||||
|
errors =
|
||||||
|
Enum.reduce(Map.keys(params) -- @known_variables, errors, fn key, acc ->
|
||||||
|
if key in placeholders_in_use, do: acc, else: acc
|
||||||
|
end)
|
||||||
|
|
||||||
|
case errors do
|
||||||
|
[] -> :ok
|
||||||
|
errs -> {:error, {:param_errors, Enum.reverse(errs)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp substitute(value, params) when is_binary(value) do
|
||||||
|
result =
|
||||||
|
Regex.replace(@placeholder_regex, value, fn _full, var_name ->
|
||||||
|
case Map.get(params, var_name) do
|
||||||
|
nil -> "{{#{var_name}}}"
|
||||||
|
v -> to_string(v)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, result}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp substitute(value, params) when is_map(value) do
|
||||||
|
Enum.reduce_while(value, {:ok, %{}}, fn {k, v}, {:ok, acc} ->
|
||||||
|
case substitute(v, params) do
|
||||||
|
{:ok, resolved} -> {:cont, {:ok, Map.put(acc, k, resolved)}}
|
||||||
|
error -> {:halt, error}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp substitute(value, params) when is_list(value) do
|
||||||
|
Enum.reduce_while(value, {:ok, []}, fn v, {:ok, acc} ->
|
||||||
|
case substitute(v, params) do
|
||||||
|
{:ok, resolved} -> {:cont, {:ok, acc ++ [resolved]}}
|
||||||
|
error -> {:halt, error}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp substitute(value, _params), do: {:ok, value}
|
||||||
|
|
||||||
|
defp check_no_remaining(template) do
|
||||||
|
remaining = collect_all_placeholders(template)
|
||||||
|
|
||||||
|
case remaining do
|
||||||
|
[] -> :ok
|
||||||
|
vars -> {:error, {:unresolved_variables, Enum.uniq(vars)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_all_placeholders(value) when is_binary(value) do
|
||||||
|
collect_placeholders(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_all_placeholders(value) when is_map(value) do
|
||||||
|
Enum.flat_map(value, fn {_k, v} -> collect_all_placeholders(v) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_all_placeholders(value) when is_list(value) do
|
||||||
|
Enum.flat_map(value, &collect_all_placeholders/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_all_placeholders(_), do: []
|
||||||
|
|
||||||
|
defp collect_placeholders(string) when is_binary(string) do
|
||||||
|
Regex.scan(@placeholder_regex, string) |> Enum.map(fn [_, name] -> name end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoder do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@section_paths %{
|
||||||
|
"trust_domain" => "trust-domain.yaml",
|
||||||
|
"identity_authority" => "identity-authority.yaml",
|
||||||
|
"members" => "members.yaml",
|
||||||
|
"infrastructure" => "infrastructure.yaml",
|
||||||
|
"ceremonies" => "ceremonies.yaml",
|
||||||
|
"federation_peers" => "federation-peers.yaml",
|
||||||
|
"attestation" => "attestation.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
@section_declarations [
|
||||||
|
%{"name" => "trust-domain", "required" => true},
|
||||||
|
%{"name" => "identity-authority", "required" => true},
|
||||||
|
%{"name" => "members", "required" => true},
|
||||||
|
%{"name" => "infrastructure", "required" => true},
|
||||||
|
%{"name" => "ceremonies", "required" => true},
|
||||||
|
%{"name" => "federation-peers", "required" => false},
|
||||||
|
%{"name" => "attestation", "required" => false}
|
||||||
|
]
|
||||||
|
|
||||||
|
def encode(resolved_template, name, version, opts \\ []) do
|
||||||
|
hub_did = Keyword.get(opts, :hub_did, "did:web:guildhouse.dev")
|
||||||
|
registrant_spiffe = Keyword.get(opts, :registrant_spiffe)
|
||||||
|
|
||||||
|
trust_domain =
|
||||||
|
get_in(resolved_template, ["trust_domain", "spiffe_trust_domain"]) || "guildhouse.dev"
|
||||||
|
|
||||||
|
registrant_spiffe =
|
||||||
|
registrant_spiffe ||
|
||||||
|
"spiffe://#{trust_domain}/founder/#{get_in(resolved_template, ["identity_authority", "client_prefix"]) || "unknown"}"
|
||||||
|
|
||||||
|
manifest = build_manifest(name, version, hub_did, registrant_spiffe, resolved_template)
|
||||||
|
manifest_yaml = Jason.encode!(manifest)
|
||||||
|
|
||||||
|
files = build_section_files(resolved_template)
|
||||||
|
|
||||||
|
{:ok, manifest_yaml, files}
|
||||||
|
end
|
||||||
|
|
||||||
|
def section_paths, do: @section_paths
|
||||||
|
|
||||||
|
defp build_manifest(name, version, hub_did, registrant_spiffe, template) do
|
||||||
|
realm =
|
||||||
|
get_in(template, ["identity_authority", "client_prefix"]) ||
|
||||||
|
get_in(template, ["identity_authority", "realm"]) || name
|
||||||
|
|
||||||
|
%{
|
||||||
|
"apiVersion" => "guildhouse.dev/v1",
|
||||||
|
"kind" => "FfcSchematic",
|
||||||
|
"metadata" => %{
|
||||||
|
"name" => name,
|
||||||
|
"version" => version,
|
||||||
|
"description" =>
|
||||||
|
get_in(template, ["meta", "description"]) || "#{name} founding schematic",
|
||||||
|
"parentHash" => ""
|
||||||
|
},
|
||||||
|
"spec" => %{
|
||||||
|
"consortium" => %{
|
||||||
|
"did" => hub_did,
|
||||||
|
"realm" => realm
|
||||||
|
},
|
||||||
|
"stakeholders" => [
|
||||||
|
%{
|
||||||
|
"role" => "founding_stakeholder",
|
||||||
|
"identity" => registrant_spiffe,
|
||||||
|
"required" => true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approval" => %{
|
||||||
|
"strategy" => "unanimous"
|
||||||
|
},
|
||||||
|
"sections" => @section_declarations,
|
||||||
|
"schematicHash" => "",
|
||||||
|
"ceremonyId" => "",
|
||||||
|
"accordHash" => "",
|
||||||
|
"signatures" => []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_section_files(template) do
|
||||||
|
@section_paths
|
||||||
|
|> Enum.map(fn {section_key, file_path} ->
|
||||||
|
content = Map.get(template, section_key, %{})
|
||||||
|
content = strip_guildhall_metadata(section_key, content)
|
||||||
|
{file_path, Jason.encode!(content)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp strip_guildhall_metadata("ceremonies", ceremonies) when is_map(ceremonies) do
|
||||||
|
Map.new(ceremonies, fn {key, rule} ->
|
||||||
|
if is_map(rule) do
|
||||||
|
{key, Map.delete(rule, "founding_override")}
|
||||||
|
else
|
||||||
|
{key, rule}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp strip_guildhall_metadata(_section, content), do: content
|
||||||
|
|
||||||
|
def extract_founding_overrides(template) do
|
||||||
|
ceremonies = Map.get(template, "ceremonies", %{})
|
||||||
|
|
||||||
|
Enum.reduce(ceremonies, %{}, fn {key, rule}, acc ->
|
||||||
|
case rule do
|
||||||
|
%{"founding_override" => override} when is_integer(override) ->
|
||||||
|
Map.put(acc, "ceremonies.#{key}.founding_override", override)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -28,7 +28,10 @@ defmodule Guildhall.Orchestrator.MixProject do
|
||||||
[
|
[
|
||||||
{:guildhall_ops_db, in_umbrella: true},
|
{:guildhall_ops_db, in_umbrella: true},
|
||||||
{:phoenix_pubsub, "~> 2.1"},
|
{:phoenix_pubsub, "~> 2.1"},
|
||||||
{:jason, "~> 1.4"}
|
{:jason, "~> 1.4"},
|
||||||
|
{:grpc, "~> 0.9"},
|
||||||
|
{:protobuf, "~> 0.13"},
|
||||||
|
{:toml, "~> 0.7"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
defmodule Guildhall.Orchestrator.RealizationStatusTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.RealizationStatus
|
||||||
|
|
||||||
|
describe "classify_section/1" do
|
||||||
|
test "classifies all known statuses" do
|
||||||
|
assert {:succeeded, _} = RealizationStatus.classify_section("succeeded")
|
||||||
|
assert {:in_progress, _} = RealizationStatus.classify_section("in_progress")
|
||||||
|
assert {:failed, _} = RealizationStatus.classify_section("failed")
|
||||||
|
assert {:pending, _} = RealizationStatus.classify_section("pending")
|
||||||
|
assert {:skipped, _} = RealizationStatus.classify_section("skipped:no_change")
|
||||||
|
assert {:skipped, _} = RealizationStatus.classify_section("skipped:not_implemented")
|
||||||
|
assert {:skipped, label} = RealizationStatus.classify_section("skipped:upstream_unavailable:keycloak")
|
||||||
|
assert label =~ "keycloak"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown status returns pending with warning" do
|
||||||
|
assert {:pending, _} = RealizationStatus.classify_section("totally_unknown")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "derive_overall/1" do
|
||||||
|
test "all succeeded → realized" do
|
||||||
|
snapshot = %{sections: [%{status: "succeeded"}, %{status: "succeeded"}]}
|
||||||
|
assert :realized = RealizationStatus.derive_overall(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all succeeded + skipped → realized" do
|
||||||
|
snapshot = %{sections: [%{status: "succeeded"}, %{status: "skipped:no_change"}]}
|
||||||
|
assert :realized = RealizationStatus.derive_overall(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mix of failed and succeeded → partially_realized" do
|
||||||
|
snapshot = %{sections: [%{status: "succeeded"}, %{status: "failed"}]}
|
||||||
|
assert :partially_realized = RealizationStatus.derive_overall(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all failed → failed" do
|
||||||
|
snapshot = %{sections: [%{status: "failed"}, %{status: "failed"}]}
|
||||||
|
assert :failed = RealizationStatus.derive_overall(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "in_progress → in_progress" do
|
||||||
|
snapshot = %{sections: [%{status: "in_progress"}, %{status: "pending"}]}
|
||||||
|
assert :in_progress = RealizationStatus.derive_overall(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty sections → pending" do
|
||||||
|
assert :pending = RealizationStatus.derive_overall(%{sections: []})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no sections key → pending" do
|
||||||
|
assert :pending = RealizationStatus.derive_overall(%{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "terminal_status?/1" do
|
||||||
|
test "realized is terminal" do
|
||||||
|
assert RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_REALIZED)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "string form is terminal" do
|
||||||
|
assert RealizationStatus.terminal_status?("FFC_SCHEMATIC_STATUS_REALIZED")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "published is not terminal" do
|
||||||
|
refute RealizationStatus.terminal_status?(:FFC_SCHEMATIC_STATUS_PUBLISHED)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "display helpers" do
|
||||||
|
test "overall_css_class for each status" do
|
||||||
|
assert RealizationStatus.overall_css_class(:realized) =~ "emerald"
|
||||||
|
assert RealizationStatus.overall_css_class(:failed) =~ "red"
|
||||||
|
assert RealizationStatus.overall_css_class(:in_progress) =~ "amber"
|
||||||
|
assert RealizationStatus.overall_css_class(:pending) =~ "zinc"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "display_status for each overall" do
|
||||||
|
assert "Realized" = RealizationStatus.display_status(:realized)
|
||||||
|
assert "Failed" = RealizationStatus.display_status(:failed)
|
||||||
|
assert "In progress" = RealizationStatus.display_status(:in_progress)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "section_icon_class returns correct CSS" do
|
||||||
|
assert RealizationStatus.section_icon_class("succeeded") =~ "emerald"
|
||||||
|
assert RealizationStatus.section_icon_class("failed") =~ "red"
|
||||||
|
assert RealizationStatus.section_icon_class("skipped:no_change") =~ "sky"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "section_symbol returns correct symbols" do
|
||||||
|
assert "✓" = RealizationStatus.section_symbol("succeeded")
|
||||||
|
assert "✗" = RealizationStatus.section_symbol("failed")
|
||||||
|
assert "⊘" = RealizationStatus.section_symbol("skipped:not_implemented")
|
||||||
|
assert "○" = RealizationStatus.section_symbol("pending")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "section_display_status for skipped variants" do
|
||||||
|
assert RealizationStatus.section_display_status("skipped:no_change") =~ "no change"
|
||||||
|
assert RealizationStatus.section_display_status("skipped:not_implemented") =~ "stub"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "all_known_statuses/0" do
|
||||||
|
test "returns comprehensive list" do
|
||||||
|
statuses = RealizationStatus.all_known_statuses()
|
||||||
|
assert length(statuses) >= 7
|
||||||
|
assert "succeeded" in statuses
|
||||||
|
assert "failed" in statuses
|
||||||
|
assert "skipped:no_change" in statuses
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.SchemaTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate.Schema
|
||||||
|
|
||||||
|
defp valid_msp_template do
|
||||||
|
%{
|
||||||
|
"meta" => %{
|
||||||
|
"template_name" => "msp-founding",
|
||||||
|
"description" => "MSP founding schematic",
|
||||||
|
"source_schematic" => "guildhouse-msp-base",
|
||||||
|
"source_version" => "1.0.0"
|
||||||
|
},
|
||||||
|
"trust_domain" => %{
|
||||||
|
"spiffe_trust_domain" => "{{trust_domain}}",
|
||||||
|
"attestation_tier" => 2
|
||||||
|
},
|
||||||
|
"identity_authority" => %{
|
||||||
|
"provider" => "keycloak",
|
||||||
|
"url" => "https://auth.guildhouse.dev",
|
||||||
|
"realm" => "guildhouse",
|
||||||
|
"client_prefix" => "{{guild_slug}}",
|
||||||
|
"trust_level" => "federated",
|
||||||
|
"mfa_required" => true
|
||||||
|
},
|
||||||
|
"members" => %{
|
||||||
|
"founding_master_did" => "{{registrant_did}}",
|
||||||
|
"initial_roles" => ["master"]
|
||||||
|
},
|
||||||
|
"infrastructure" => %{
|
||||||
|
"compute_attestation_tier" => 2
|
||||||
|
},
|
||||||
|
"ceremonies" => %{
|
||||||
|
"code_change" => %{
|
||||||
|
"type" => "single_approval",
|
||||||
|
"eligible_roles" => ["master", "journeyman"],
|
||||||
|
"quorum" => 1
|
||||||
|
},
|
||||||
|
"governance_change" => %{
|
||||||
|
"type" => "single_approval",
|
||||||
|
"eligible_roles" => ["master"],
|
||||||
|
"quorum" => 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"federation_peers" => %{
|
||||||
|
"mode" => "hub_spoke",
|
||||||
|
"hub_trust_domain" => "guildhouse.dev"
|
||||||
|
},
|
||||||
|
"attestation" => %{
|
||||||
|
"tier" => 2,
|
||||||
|
"require_tpm" => false,
|
||||||
|
"require_secure_boot" => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate/1" do
|
||||||
|
test "valid MSP template passes" do
|
||||||
|
assert :ok = Schema.validate(valid_msp_template())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects unknown sections" do
|
||||||
|
template = Map.put(valid_msp_template(), "bogus", %{"foo" => "bar"})
|
||||||
|
assert {:error, {:unknown_sections, ["bogus"]}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects nil template" do
|
||||||
|
assert {:error, {:missing_section, _}} = Schema.validate(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing required section" do
|
||||||
|
template = Map.delete(valid_msp_template(), "trust_domain")
|
||||||
|
assert {:error, {:missing_section, "trust_domain"}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing required field" do
|
||||||
|
template = put_in(valid_msp_template(), ["trust_domain", "spiffe_trust_domain"], nil)
|
||||||
|
template = Map.update!(template, "trust_domain", &Map.delete(&1, "spiffe_trust_domain"))
|
||||||
|
assert {:error, {:missing_field, "trust_domain.spiffe_trust_domain"}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid type for attestation_tier" do
|
||||||
|
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], "two")
|
||||||
|
assert {:error, {:invalid_type, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects attestation_tier out of range" do
|
||||||
|
template = put_in(valid_msp_template(), ["trust_domain", "attestation_tier"], 5)
|
||||||
|
assert {:error, {:invalid_value, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid ceremony type" do
|
||||||
|
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "type"], "whatever")
|
||||||
|
assert {:error, {:invalid_value, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid role in eligible_roles" do
|
||||||
|
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "eligible_roles"], ["admin"])
|
||||||
|
assert {:error, {:invalid_value, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "cross-section validation" do
|
||||||
|
test "detects tier mismatch" do
|
||||||
|
template = put_in(valid_msp_template(), ["attestation", "tier"], 3)
|
||||||
|
assert {:error, {:tier_mismatch, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detects federation mode mismatch for MSP" do
|
||||||
|
template = put_in(valid_msp_template(), ["federation_peers", "mode"], "mesh")
|
||||||
|
# mesh also requires wireguard, but federation mode check runs first
|
||||||
|
assert {:error, {:federation_mode_mismatch, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mesh requires wireguard" do
|
||||||
|
template =
|
||||||
|
valid_msp_template()
|
||||||
|
|> put_in(["meta", "template_name"], "nsp-founding")
|
||||||
|
|> put_in(["federation_peers", "mode"], "mesh")
|
||||||
|
|
||||||
|
assert {:error, {:mesh_requires_wireguard, _}} = Schema.validate(template)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "founding_override validation" do
|
||||||
|
test "rejects founding_override on single_approval" do
|
||||||
|
template = put_in(valid_msp_template(), ["ceremonies", "code_change", "founding_override"], 1)
|
||||||
|
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
|
||||||
|
assert msg =~ "only valid on multi_party"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects founding_override >= quorum" do
|
||||||
|
template =
|
||||||
|
valid_msp_template()
|
||||||
|
|> put_in(["ceremonies", "governance_change"], %{
|
||||||
|
"type" => "multi_party",
|
||||||
|
"eligible_roles" => ["master"],
|
||||||
|
"quorum" => 2,
|
||||||
|
"founding_override" => 2
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, {:invalid_value, msg}} = Schema.validate(template)
|
||||||
|
assert msg =~ "less than quorum"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts valid founding_override" do
|
||||||
|
template =
|
||||||
|
valid_msp_template()
|
||||||
|
|> put_in(["ceremonies", "governance_change"], %{
|
||||||
|
"type" => "multi_party",
|
||||||
|
"eligible_roles" => ["master"],
|
||||||
|
"quorum" => 2,
|
||||||
|
"founding_override" => 1
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ok = Schema.validate(template)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate_resolved/1" do
|
||||||
|
test "accepts valid resolved values" do
|
||||||
|
template =
|
||||||
|
valid_msp_template()
|
||||||
|
|> put_in(["trust_domain", "spiffe_trust_domain"], "test-guild.guildhouse.dev")
|
||||||
|
|> put_in(["members", "founding_master_did"], "did:web:guildhouse.dev:user:tking")
|
||||||
|
|> put_in(["identity_authority", "client_prefix"], "test-guild")
|
||||||
|
|
||||||
|
assert :ok = Schema.validate_resolved(template)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid DID format" do
|
||||||
|
template =
|
||||||
|
valid_msp_template()
|
||||||
|
|> put_in(["members", "founding_master_did"], "not-a-did")
|
||||||
|
|> put_in(["trust_domain", "spiffe_trust_domain"], "test.guildhouse.dev")
|
||||||
|
|> put_in(["identity_authority", "client_prefix"], "test")
|
||||||
|
|
||||||
|
assert {:error, {:invalid_resolved_values, errors}} = Schema.validate_resolved(template)
|
||||||
|
assert Enum.any?(errors, &String.contains?(&1, "DID"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "TOML template loading" do
|
||||||
|
test "all three templates validate" do
|
||||||
|
for type <- ["msp", "isv", "nsp"] do
|
||||||
|
{:ok, template} = Guildhall.Orchestrator.SchematicTemplate.load_template(type)
|
||||||
|
assert :ok = Schema.validate(template), "#{type} template failed validation"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.VariableResolverTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate.VariableResolver
|
||||||
|
|
||||||
|
@valid_params %{
|
||||||
|
"trust_domain" => "test-guild.guildhouse.dev",
|
||||||
|
"guild_slug" => "test-guild",
|
||||||
|
"guild_name" => "Test Guild",
|
||||||
|
"registrant_did" => "did:web:guildhouse.dev:user:tking"
|
||||||
|
}
|
||||||
|
|
||||||
|
defp template_with_placeholders do
|
||||||
|
%{
|
||||||
|
"trust_domain" => %{
|
||||||
|
"spiffe_trust_domain" => "{{trust_domain}}"
|
||||||
|
},
|
||||||
|
"members" => %{
|
||||||
|
"founding_master_did" => "{{registrant_did}}"
|
||||||
|
},
|
||||||
|
"identity_authority" => %{
|
||||||
|
"client_prefix" => "{{guild_slug}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "resolve/2" do
|
||||||
|
test "resolves all placeholders" do
|
||||||
|
{:ok, resolved} = VariableResolver.resolve(template_with_placeholders(), @valid_params)
|
||||||
|
assert resolved["trust_domain"]["spiffe_trust_domain"] == "test-guild.guildhouse.dev"
|
||||||
|
assert resolved["members"]["founding_master_did"] == "did:web:guildhouse.dev:user:tking"
|
||||||
|
assert resolved["identity_authority"]["client_prefix"] == "test-guild"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for missing required param" do
|
||||||
|
params = Map.delete(@valid_params, "trust_domain")
|
||||||
|
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
|
||||||
|
assert Enum.any?(errors, fn {type, key} -> type == :missing_param and key == "trust_domain" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for empty param" do
|
||||||
|
params = Map.put(@valid_params, "guild_slug", "")
|
||||||
|
assert {:error, {:param_errors, errors}} = VariableResolver.resolve(template_with_placeholders(), params)
|
||||||
|
assert Enum.any?(errors, fn {type, key} -> type == :empty_param and key == "guild_slug" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for non-string param" do
|
||||||
|
params = Map.put(@valid_params, "guild_slug", 42)
|
||||||
|
assert {:error, {:param_errors, _}} = VariableResolver.resolve(template_with_placeholders(), params)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves nested lists" do
|
||||||
|
template = %{
|
||||||
|
"roles" => ["{{guild_slug}}-admin", "{{guild_slug}}-user"],
|
||||||
|
"trust_domain" => %{"spiffe_trust_domain" => "x"},
|
||||||
|
"members" => %{"founding_master_did" => "x"},
|
||||||
|
"identity_authority" => %{"client_prefix" => "x"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
|
||||||
|
assert resolved["roles"] == ["test-guild-admin", "test-guild-user"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "leaves non-string values unchanged" do
|
||||||
|
template = %{
|
||||||
|
"tier" => 2,
|
||||||
|
"enabled" => true,
|
||||||
|
"trust_domain" => %{"spiffe_trust_domain" => "x"},
|
||||||
|
"members" => %{"founding_master_did" => "x"},
|
||||||
|
"identity_authority" => %{"client_prefix" => "x"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, resolved} = VariableResolver.resolve(template, @valid_params)
|
||||||
|
assert resolved["tier"] == 2
|
||||||
|
assert resolved["enabled"] == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "known_variables/0" do
|
||||||
|
test "returns the resolution manifest" do
|
||||||
|
vars = VariableResolver.known_variables()
|
||||||
|
assert "trust_domain" in vars
|
||||||
|
assert "guild_slug" in vars
|
||||||
|
assert "guild_name" in vars
|
||||||
|
assert "registrant_did" in vars
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
defmodule Guildhall.Orchestrator.SchematicTemplate.WireEncoderTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Guildhall.Orchestrator.SchematicTemplate.WireEncoder
|
||||||
|
|
||||||
|
defp resolved_template do
|
||||||
|
%{
|
||||||
|
"meta" => %{
|
||||||
|
"template_name" => "msp-founding",
|
||||||
|
"description" => "MSP schematic",
|
||||||
|
"source_schematic" => "guildhouse-msp-base",
|
||||||
|
"source_version" => "1.0.0"
|
||||||
|
},
|
||||||
|
"trust_domain" => %{
|
||||||
|
"spiffe_trust_domain" => "test.guildhouse.dev",
|
||||||
|
"attestation_tier" => 2
|
||||||
|
},
|
||||||
|
"identity_authority" => %{
|
||||||
|
"provider" => "keycloak",
|
||||||
|
"url" => "https://auth.guildhouse.dev",
|
||||||
|
"realm" => "guildhouse",
|
||||||
|
"client_prefix" => "test-guild",
|
||||||
|
"trust_level" => "federated",
|
||||||
|
"mfa_required" => true
|
||||||
|
},
|
||||||
|
"members" => %{
|
||||||
|
"founding_master_did" => "did:web:guildhouse.dev:user:tking",
|
||||||
|
"initial_roles" => ["master"]
|
||||||
|
},
|
||||||
|
"infrastructure" => %{
|
||||||
|
"compute_attestation_tier" => 2
|
||||||
|
},
|
||||||
|
"ceremonies" => %{
|
||||||
|
"code_change" => %{
|
||||||
|
"type" => "single_approval",
|
||||||
|
"eligible_roles" => ["master", "journeyman"],
|
||||||
|
"quorum" => 1
|
||||||
|
},
|
||||||
|
"governance_change" => %{
|
||||||
|
"type" => "single_approval",
|
||||||
|
"eligible_roles" => ["master"],
|
||||||
|
"quorum" => 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"federation_peers" => %{
|
||||||
|
"mode" => "hub_spoke",
|
||||||
|
"hub_trust_domain" => "guildhouse.dev"
|
||||||
|
},
|
||||||
|
"attestation" => %{
|
||||||
|
"tier" => 2,
|
||||||
|
"require_tpm" => false,
|
||||||
|
"require_secure_boot" => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "encode/4" do
|
||||||
|
test "produces manifest YAML and 7 section files" do
|
||||||
|
{:ok, manifest_yaml, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
|
||||||
|
|
||||||
|
assert is_binary(manifest_yaml)
|
||||||
|
assert length(files) == 7
|
||||||
|
|
||||||
|
file_paths = Enum.map(files, fn {path, _} -> path end)
|
||||||
|
assert "trust-domain.yaml" in file_paths
|
||||||
|
assert "identity-authority.yaml" in file_paths
|
||||||
|
assert "members.yaml" in file_paths
|
||||||
|
assert "infrastructure.yaml" in file_paths
|
||||||
|
assert "ceremonies.yaml" in file_paths
|
||||||
|
assert "federation-peers.yaml" in file_paths
|
||||||
|
assert "attestation.yaml" in file_paths
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manifest has correct structure" do
|
||||||
|
{:ok, manifest_yaml, _files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
|
||||||
|
manifest = Jason.decode!(manifest_yaml)
|
||||||
|
|
||||||
|
assert manifest["apiVersion"] == "guildhouse.dev/v1"
|
||||||
|
assert manifest["kind"] == "FfcSchematic"
|
||||||
|
assert manifest["metadata"]["name"] == "test-msp"
|
||||||
|
assert manifest["metadata"]["version"] == "1.0.0"
|
||||||
|
assert manifest["spec"]["consortium"]["did"] == "did:web:guildhouse.dev"
|
||||||
|
assert is_list(manifest["spec"]["stakeholders"])
|
||||||
|
assert manifest["spec"]["approval"]["strategy"] == "unanimous"
|
||||||
|
assert is_list(manifest["spec"]["sections"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manifest uses custom hub_did" do
|
||||||
|
{:ok, manifest_yaml, _} =
|
||||||
|
WireEncoder.encode(resolved_template(), "test", "1.0.0", hub_did: "did:web:custom.dev")
|
||||||
|
|
||||||
|
manifest = Jason.decode!(manifest_yaml)
|
||||||
|
assert manifest["spec"]["consortium"]["did"] == "did:web:custom.dev"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "section file content is valid JSON" do
|
||||||
|
{:ok, _manifest, files} = WireEncoder.encode(resolved_template(), "test-msp", "1.0.0")
|
||||||
|
|
||||||
|
for {path, content} <- files do
|
||||||
|
assert {:ok, _} = Jason.decode(content), "#{path} is not valid JSON"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "founding_override stripping" do
|
||||||
|
test "strips founding_override from ceremonies wire output" do
|
||||||
|
template =
|
||||||
|
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
|
||||||
|
|
||||||
|
{:ok, _manifest, files} = WireEncoder.encode(template, "test", "1.0.0")
|
||||||
|
|
||||||
|
{_, ceremonies_json} = Enum.find(files, fn {p, _} -> p == "ceremonies.yaml" end)
|
||||||
|
ceremonies = Jason.decode!(ceremonies_json)
|
||||||
|
|
||||||
|
refute Map.has_key?(ceremonies["governance_change"], "founding_override")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "extract_founding_overrides/1" do
|
||||||
|
test "extracts override values" do
|
||||||
|
template =
|
||||||
|
put_in(resolved_template(), ["ceremonies", "governance_change", "founding_override"], 1)
|
||||||
|
|
||||||
|
overrides = WireEncoder.extract_founding_overrides(template)
|
||||||
|
assert overrides["ceremonies.governance_change.founding_override"] == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map when no overrides" do
|
||||||
|
assert %{} = WireEncoder.extract_founding_overrides(resolved_template())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "section_paths/0" do
|
||||||
|
test "returns all 7 section path mappings" do
|
||||||
|
paths = WireEncoder.section_paths()
|
||||||
|
assert map_size(paths) == 7
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -7,13 +7,29 @@ defmodule Guildhall.Application do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
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,
|
GuildhallWeb.Telemetry,
|
||||||
{DNSCluster, query: Application.get_env(:guildhall_web, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:guildhall_web, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: Guildhall.PubSub},
|
{Phoenix.PubSub, name: Guildhall.PubSub}
|
||||||
# Start a worker by calling: Guildhall.Worker.start_link(arg)
|
] ++
|
||||||
# {Guildhall.Worker, arg},
|
oidc_children ++
|
||||||
# Start to serve requests, typically the last entry
|
[
|
||||||
GuildhallWeb.Endpoint
|
GuildhallWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
defmodule GuildhallWeb.AuthController do
|
||||||
|
use GuildhallWeb, :controller
|
||||||
|
|
||||||
|
@provider_name GuildhallWeb.OidcProvider
|
||||||
|
|
||||||
|
def login(conn, _params) do
|
||||||
|
oidc_config = Application.fetch_env!(:guildhall_web, :oidc)
|
||||||
|
client_id = oidc_config[:client_id]
|
||||||
|
client_secret = oidc_config[:client_secret]
|
||||||
|
redirect_uri = oidc_config[:redirect_uri]
|
||||||
|
|
||||||
|
nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
|
||||||
|
state = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
|
||||||
|
|
||||||
|
case Oidcc.create_redirect_url(@provider_name, client_id, client_secret, %{
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scopes: ["openid", "profile", "email"],
|
||||||
|
state: state,
|
||||||
|
nonce: nonce
|
||||||
|
}) do
|
||||||
|
{:ok, url} ->
|
||||||
|
conn
|
||||||
|
|> put_session(:oidc_state, state)
|
||||||
|
|> put_session(:oidc_nonce, nonce)
|
||||||
|
|> redirect(external: url)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Failed to initiate login: #{inspect(reason)}")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def callback(conn, %{"code" => code, "state" => state}) do
|
||||||
|
saved_state = get_session(conn, :oidc_state)
|
||||||
|
|
||||||
|
if state != saved_state do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid state parameter.")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
else
|
||||||
|
oidc_config = Application.fetch_env!(:guildhall_web, :oidc)
|
||||||
|
client_id = oidc_config[:client_id]
|
||||||
|
client_secret = oidc_config[:client_secret]
|
||||||
|
redirect_uri = oidc_config[:redirect_uri]
|
||||||
|
nonce = get_session(conn, :oidc_nonce)
|
||||||
|
|
||||||
|
case Oidcc.retrieve_token(code, @provider_name, client_id, client_secret, %{
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
nonce: nonce
|
||||||
|
}) do
|
||||||
|
{:ok, token} ->
|
||||||
|
claims = extract_claims(token)
|
||||||
|
|
||||||
|
preferred_username =
|
||||||
|
claims["preferred_username"] || claims["email"] || claims["sub"]
|
||||||
|
|
||||||
|
current_user = %{
|
||||||
|
"sub" => claims["sub"],
|
||||||
|
"email" => claims["email"],
|
||||||
|
"name" => claims["name"] || preferred_username,
|
||||||
|
"preferred_username" => preferred_username,
|
||||||
|
"did" => "did:web:guildhouse.dev:user:#{preferred_username}"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> delete_session(:oidc_state)
|
||||||
|
|> delete_session(:oidc_nonce)
|
||||||
|
|> put_session(:current_user, current_user)
|
||||||
|
|> put_flash(:info, "Welcome, #{current_user["name"]}.")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Authentication failed: #{inspect(reason)}")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def callback(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Missing authorization code.")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
def logout(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> configure_session(drop: true)
|
||||||
|
|> put_flash(:info, "Signed out.")
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_claims(%Oidcc.Token{id: %Oidcc.Token.Id{claims: claims}}), do: claims
|
||||||
|
defp extract_claims(_), do: %{}
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
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
|
||||||
16
apps/guildhall_web/lib/guildhall_web_web/live/auth_hooks.ex
Normal file
16
apps/guildhall_web/lib/guildhall_web_web/live/auth_hooks.ex
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule GuildhallWeb.AuthHooks do
|
||||||
|
@moduledoc false
|
||||||
|
import Phoenix.LiveView
|
||||||
|
import Phoenix.Component
|
||||||
|
|
||||||
|
def on_mount(:require_auth, _params, session, socket) do
|
||||||
|
case session["current_user"] do
|
||||||
|
nil -> {:halt, redirect(socket, to: "/auth/login")}
|
||||||
|
user -> {:cont, assign(socket, :current_user, user)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:fetch_user, _params, session, socket) do
|
||||||
|
{:cont, assign(socket, :current_user, session["current_user"])}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule GuildhallWeb.DashboardLive do
|
||||||
"""
|
"""
|
||||||
use GuildhallWeb, :live_view
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult}
|
alias Guildhall.OpsDb.{Repo, GovernedArtifact, DeploymentState, VerificationResult, Guilds}
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -35,6 +35,9 @@ defmodule GuildhallWeb.DashboardLive do
|
||||||
|> assign(:healthy_count, count_by_drift("match"))
|
|> assign(:healthy_count, count_by_drift("match"))
|
||||||
|> assign(:drifted_count, count_by_drift("drift"))
|
|> assign(:drifted_count, count_by_drift("drift"))
|
||||||
|> assign(:recent_verifications, recent_verifications(5))
|
|> 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
|
end
|
||||||
|
|
||||||
defp count_by_drift(status) do
|
defp count_by_drift(status) do
|
||||||
|
|
@ -77,7 +80,23 @@ defmodule GuildhallWeb.DashboardLive do
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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"/ceremonies"} class="text-blue-600 underline">Ceremonies</.link>
|
||||||
<.link navigate={~p"/artifacts"} class="text-blue-600 underline">Artifacts</.link>
|
<.link navigate={~p"/artifacts"} class="text-blue-600 underline">Artifacts</.link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
defmodule GuildhallWeb.GuildLive.Index do
|
||||||
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.Guilds
|
||||||
|
alias Guildhall.Orchestrator.CeremonyClient
|
||||||
|
|
||||||
|
@hub_operator_did "did:web:guildhouse.dev:user:tking"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
if connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Guildhall.PubSub, "guild:*")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Guilds")
|
||||||
|
|> assign(:guilds, Guilds.list_guilds())
|
||||||
|
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("approve_guild", %{"id" => id}, socket) do
|
||||||
|
guild = Guilds.get_guild!(id)
|
||||||
|
|
||||||
|
case CeremonyClient.approve_ceremony(
|
||||||
|
guild.registration_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"hub_operator"
|
||||||
|
) do
|
||||||
|
{:ok, %{status: "approved"}} ->
|
||||||
|
{:ok, _} = Guilds.update_guild(guild, %{status: "approved"})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "#{guild.name} approved.")
|
||||||
|
|> assign(:guilds, Guilds.list_guilds())}
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Approval recorded for #{guild.name}.")
|
||||||
|
|> assign(:guilds, Guilds.list_guilds())}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Approval failed: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("deny_guild", %{"id" => id}, socket) do
|
||||||
|
guild = Guilds.get_guild!(id)
|
||||||
|
|
||||||
|
case CeremonyClient.deny_ceremony(
|
||||||
|
guild.registration_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"hub_operator",
|
||||||
|
"Denied by hub operator"
|
||||||
|
) do
|
||||||
|
{:ok, _} ->
|
||||||
|
{:ok, _} = Guilds.update_guild(guild, %{status: "denied"})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "#{guild.name} denied.")
|
||||||
|
|> assign(:guilds, Guilds.list_guilds())}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Denial failed: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:guild_updated, _}, socket) do
|
||||||
|
{:noreply, assign(socket, :guilds, Guilds.list_guilds())}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
defp status_class("pending_approval"), do: "text-amber-600"
|
||||||
|
defp status_class("approved"), do: "text-emerald-600"
|
||||||
|
defp status_class("active"), do: "text-emerald-600"
|
||||||
|
defp status_class("denied"), do: "text-red-600"
|
||||||
|
defp status_class("suspended"), do: "text-zinc-500"
|
||||||
|
defp status_class(_), do: "text-zinc-500"
|
||||||
|
|
||||||
|
defp type_label("msp"), do: "MSP"
|
||||||
|
defp type_label("isv"), do: "ISV"
|
||||||
|
defp type_label("nsp"), do: "NSP"
|
||||||
|
defp type_label(other), do: String.upcase(other)
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-4xl p-6 space-y-4">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<.link navigate={~p"/"} class="text-sm text-blue-600 underline">← 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
|
||||||
100
apps/guildhall_web/lib/guildhall_web_web/live/guild_live/join.ex
Normal file
100
apps/guildhall_web/lib/guildhall_web_web/live/guild_live/join.ex
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
defmodule GuildhallWeb.GuildLive.Join do
|
||||||
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.{Guilds, GuildMemberships}
|
||||||
|
alias Guildhall.Orchestrator.CeremonyClient
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
|
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
existing = GuildMemberships.find_membership(guild.id, user["did"])
|
||||||
|
|
||||||
|
if existing do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "You already have a membership in #{guild.name}.")
|
||||||
|
|> push_navigate(to: ~p"/guilds/#{slug}")}
|
||||||
|
else
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Join #{guild.name}")
|
||||||
|
|> assign(:guild, guild)
|
||||||
|
|> assign(:submitted, false)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("request_membership", _params, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
|
||||||
|
case GuildMemberships.request_membership(%{
|
||||||
|
guild_id: guild.id,
|
||||||
|
user_did: user["did"],
|
||||||
|
user_email: user["email"],
|
||||||
|
display_name: user["name"],
|
||||||
|
keycloak_sub: user["sub"],
|
||||||
|
role: "apprentice",
|
||||||
|
status: "pending"
|
||||||
|
}) do
|
||||||
|
{:ok, membership} ->
|
||||||
|
masters = GuildMemberships.masters_for_guild(guild.id)
|
||||||
|
approver_did = if masters != [], do: hd(masters).user_did, else: guild.registrant_did
|
||||||
|
|
||||||
|
case CeremonyClient.create_guild_registration_ceremony(
|
||||||
|
"membership:#{guild.slug}:#{user["preferred_username"]}",
|
||||||
|
user["did"],
|
||||||
|
approver_did
|
||||||
|
) do
|
||||||
|
{:ok, response} ->
|
||||||
|
GuildMemberships.get_membership!(membership.id)
|
||||||
|
|> Guildhall.OpsDb.GuildMembership.changeset(%{
|
||||||
|
membership_ceremony_id: response.ceremony_id
|
||||||
|
})
|
||||||
|
|> Guildhall.OpsDb.Repo.update()
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:submitted, true)
|
||||||
|
|> put_flash(:info, "Membership request submitted.")}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to submit membership request.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-lg p-6 space-y-6">
|
||||||
|
<header>
|
||||||
|
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">← {@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
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
defmodule GuildhallWeb.GuildLive.Members do
|
||||||
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.{Guilds, GuildMemberships}
|
||||||
|
alias Guildhall.Orchestrator.CeremonyClient
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
|
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
user_membership = GuildMemberships.find_membership(guild.id, user["did"])
|
||||||
|
|
||||||
|
is_master = user_membership && user_membership.role == "master" && user_membership.status == "active"
|
||||||
|
|
||||||
|
unless is_master do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Only guild masters can manage members.")
|
||||||
|
|> push_navigate(to: ~p"/guilds/#{slug}")}
|
||||||
|
else
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "#{guild.name} — Members")
|
||||||
|
|> assign(:guild, guild)
|
||||||
|
|> assign(:memberships, GuildMemberships.list_memberships(guild.id))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("approve_member", %{"id" => id}, socket) do
|
||||||
|
membership = GuildMemberships.get_membership!(id)
|
||||||
|
|
||||||
|
if membership.membership_ceremony_id do
|
||||||
|
CeremonyClient.approve_ceremony(
|
||||||
|
membership.membership_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"guild_master"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, _} = GuildMemberships.approve_membership(membership, socket.assigns.current_user["did"])
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Member approved.")
|
||||||
|
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("deny_member", %{"id" => id}, socket) do
|
||||||
|
membership = GuildMemberships.get_membership!(id)
|
||||||
|
|
||||||
|
if membership.membership_ceremony_id do
|
||||||
|
CeremonyClient.deny_ceremony(
|
||||||
|
membership.membership_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"guild_master",
|
||||||
|
"Denied by guild master"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, _} = GuildMemberships.deny_membership(membership, socket.assigns.current_user["did"])
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Member denied.")
|
||||||
|
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("update_role", %{"id" => id, "role" => role}, socket) do
|
||||||
|
membership = GuildMemberships.get_membership!(id)
|
||||||
|
{:ok, _} = GuildMemberships.update_role(membership, role)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Role updated.")
|
||||||
|
|> assign(:memberships, GuildMemberships.list_memberships(socket.assigns.guild.id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_class("pending"), do: "text-amber-600"
|
||||||
|
defp status_class("active"), do: "text-emerald-600"
|
||||||
|
defp status_class("denied"), do: "text-red-600"
|
||||||
|
defp status_class("suspended"), do: "text-zinc-500"
|
||||||
|
defp status_class(_), do: "text-zinc-500"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-4xl p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<.link navigate={~p"/guilds/#{@guild.slug}"} class="text-sm text-blue-600 underline">← {@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
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
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">← {@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> ·
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
defmodule GuildhallWeb.GuildLive.Register do
|
||||||
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.{Guild, Guilds}
|
||||||
|
alias Guildhall.Orchestrator.CeremonyClient
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
changeset = Guild.changeset(%Guild{}, %{})
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Register Guild")
|
||||||
|
|> assign(:form, to_form(changeset))
|
||||||
|
|> assign(:submitting, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"guild" => params}, socket) do
|
||||||
|
changeset =
|
||||||
|
%Guild{}
|
||||||
|
|> Guild.changeset(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("register", %{"guild" => params}, socket) do
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
guild_id = Guilds.next_guild_id()
|
||||||
|
slug = slugify(params["name"] || "")
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
params
|
||||||
|
|> Map.put("guild_id", guild_id)
|
||||||
|
|> Map.put("slug", slug)
|
||||||
|
|> Map.put("registrant_did", user["did"])
|
||||||
|
|> Map.put("contact_did", user["did"])
|
||||||
|
|> Map.put("trust_domain", "#{slug}.guildhouse.dev")
|
||||||
|
|> Map.put("status", "pending_approval")
|
||||||
|
|
||||||
|
case Guilds.create_guild(attrs) do
|
||||||
|
{:ok, guild} ->
|
||||||
|
ceremony_result =
|
||||||
|
CeremonyClient.create_guild_registration_ceremony(
|
||||||
|
guild.name,
|
||||||
|
user["did"],
|
||||||
|
"did:web:guildhouse.dev:user:tking"
|
||||||
|
)
|
||||||
|
|
||||||
|
case ceremony_result do
|
||||||
|
{:ok, response} ->
|
||||||
|
Guilds.update_guild(guild, %{registration_ceremony_id: response.ceremony_id})
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Guild registration submitted for approval.")
|
||||||
|
|> push_navigate(to: ~p"/guilds/#{guild.slug}")}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-lg p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<.link navigate={~p"/guilds"} class="text-sm text-blue-600 underline">← 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
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
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">← {@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
|
||||||
227
apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex
Normal file
227
apps/guildhall_web/lib/guildhall_web_web/live/guild_live/show.ex
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
defmodule GuildhallWeb.GuildLive.Show do
|
||||||
|
use GuildhallWeb, :live_view
|
||||||
|
|
||||||
|
alias Guildhall.OpsDb.{Guilds, GuildSchematics, GuildMemberships}
|
||||||
|
alias Guildhall.Orchestrator.CeremonyClient
|
||||||
|
|
||||||
|
@hub_operator_did "did:web:guildhouse.dev:user:tking"
|
||||||
|
@poll_interval_ms 5_000
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
|
guild = Guilds.get_guild_by_slug(slug) || raise Ecto.NoResultsError, queryable: Guildhall.OpsDb.Guild
|
||||||
|
|
||||||
|
if connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Guildhall.PubSub, "guild:#{slug}")
|
||||||
|
|
||||||
|
if guild.status == "pending_approval" && guild.registration_ceremony_id do
|
||||||
|
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, guild.name)
|
||||||
|
|> assign(:guild, guild)
|
||||||
|
|> assign(:ceremony_status, nil)
|
||||||
|
|> assign(:is_hub_operator, socket.assigns.current_user["did"] == @hub_operator_did)
|
||||||
|
|> assign(:schematic, GuildSchematics.get_for_guild(guild.id))
|
||||||
|
|> assign(:member_count, length(GuildMemberships.active_members(guild.id)))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:poll_ceremony, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
|
||||||
|
case CeremonyClient.get_ceremony(guild.registration_ceremony_id) do
|
||||||
|
{:ok, response} ->
|
||||||
|
socket = assign(socket, :ceremony_status, response.status)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case response.status do
|
||||||
|
"approved" ->
|
||||||
|
{:ok, %{guild: updated}} = Guilds.approve_guild(guild)
|
||||||
|
assign(socket, :guild, updated)
|
||||||
|
|
||||||
|
"denied" ->
|
||||||
|
{:ok, updated} = Guilds.update_guild(guild, %{status: "denied"})
|
||||||
|
assign(socket, :guild, updated)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
Process.send_after(self(), :poll_ceremony, @poll_interval_ms)
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:guild_updated, _}, socket) do
|
||||||
|
guild = Guilds.get_guild_by_slug(socket.assigns.guild.slug)
|
||||||
|
{:noreply, assign(socket, :guild, guild)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("approve_guild", _params, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
|
||||||
|
case CeremonyClient.approve_ceremony(
|
||||||
|
guild.registration_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"hub_operator"
|
||||||
|
) do
|
||||||
|
{:ok, %{status: "approved"}} ->
|
||||||
|
{:ok, %{guild: updated}} = Guilds.approve_guild(guild)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:guild, updated)
|
||||||
|
|> assign(:ceremony_status, "approved")
|
||||||
|
|> put_flash(:info, "#{guild.name} approved.")}
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply, put_flash(socket, :info, "Approval recorded.")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Approval failed: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("deny_guild", _params, socket) do
|
||||||
|
guild = socket.assigns.guild
|
||||||
|
|
||||||
|
case CeremonyClient.deny_ceremony(
|
||||||
|
guild.registration_ceremony_id,
|
||||||
|
socket.assigns.current_user["did"],
|
||||||
|
"hub_operator",
|
||||||
|
"Denied by hub operator"
|
||||||
|
) do
|
||||||
|
{:ok, _} ->
|
||||||
|
{:ok, updated} = Guilds.update_guild(guild, %{status: "denied"})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:guild, updated)
|
||||||
|
|> assign(:ceremony_status, "denied")
|
||||||
|
|> put_flash(:info, "#{guild.name} denied.")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Denial failed: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_class("pending_approval"), do: "bg-amber-100 text-amber-700"
|
||||||
|
defp status_class("approved"), do: "bg-emerald-100 text-emerald-700"
|
||||||
|
defp status_class("active"), do: "bg-emerald-100 text-emerald-700"
|
||||||
|
defp status_class("denied"), do: "bg-red-100 text-red-700"
|
||||||
|
defp status_class("suspended"), do: "bg-zinc-100 text-zinc-700"
|
||||||
|
defp status_class(_), do: "bg-zinc-100 text-zinc-700"
|
||||||
|
|
||||||
|
defp schematic_status_class("realized"), do: "text-emerald-600"
|
||||||
|
defp schematic_status_class("realizing"), do: "text-amber-600"
|
||||||
|
defp schematic_status_class("failed"), do: "text-red-600"
|
||||||
|
defp schematic_status_class(_), do: "text-zinc-500"
|
||||||
|
|
||||||
|
defp type_label("msp"), do: "Managed Service Provider"
|
||||||
|
defp type_label("isv"), do: "Independent Software Vendor"
|
||||||
|
defp type_label("nsp"), do: "Network Service Provider"
|
||||||
|
defp type_label(other), do: other
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-2xl p-6 space-y-6">
|
||||||
|
<header>
|
||||||
|
<.link navigate={~p"/guilds"} class="text-sm text-blue-600 underline">← 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
|
||||||
24
apps/guildhall_web/lib/guildhall_web_web/plugs/auth.ex
Normal file
24
apps/guildhall_web/lib/guildhall_web_web/plugs/auth.ex
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
defmodule GuildhallWeb.Plugs.Auth do
|
||||||
|
@moduledoc false
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
case get_session(conn, :current_user) do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Please sign in to continue.")
|
||||||
|
|> redirect(to: "/auth/login")
|
||||||
|
|> halt()
|
||||||
|
|
||||||
|
user ->
|
||||||
|
assign(conn, :current_user, user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_current_user(conn, _opts) do
|
||||||
|
assign(conn, :current_user, get_session(conn, :current_user))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule GuildhallWeb.Router do
|
defmodule GuildhallWeb.Router do
|
||||||
use GuildhallWeb, :router
|
use GuildhallWeb, :router
|
||||||
|
|
||||||
|
import GuildhallWeb.Plugs.Auth, only: [fetch_current_user: 2]
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
plug :fetch_session
|
plug :fetch_session
|
||||||
|
|
@ -8,17 +10,43 @@ defmodule GuildhallWeb.Router do
|
||||||
plug :put_root_layout, html: {GuildhallWeb.Layouts, :root}
|
plug :put_root_layout, html: {GuildhallWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
|
plug :fetch_current_user
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
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
|
scope "/", GuildhallWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
live_session :authenticated, on_mount: {GuildhallWeb.AuthHooks, :require_auth} do
|
||||||
live "/", DashboardLive, :index
|
live "/", DashboardLive, :index
|
||||||
live "/ceremonies", CeremonyLive.Index, :index
|
live "/ceremonies", CeremonyLive.Index, :index
|
||||||
live "/artifacts", ArtifactLive.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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ defmodule GuildhallWeb.MixProject do
|
||||||
{:gettext, "~> 1.0"},
|
{:gettext, "~> 1.0"},
|
||||||
{:jason, "~> 1.2"},
|
{:jason, "~> 1.2"},
|
||||||
{:dns_cluster, "~> 0.2.0"},
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
{:bandit, "~> 1.5"}
|
{:bandit, "~> 1.5"},
|
||||||
|
{:oidcc, "~> 3.2"},
|
||||||
|
{:req, "~> 0.5"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,14 @@ config :phoenix_live_view,
|
||||||
debug_heex_annotations: true,
|
debug_heex_annotations: true,
|
||||||
debug_attributes: true,
|
debug_attributes: true,
|
||||||
enable_expensive_runtime_checks: 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"
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,20 @@ if config_env() == :prod do
|
||||||
http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
|
http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
|
||||||
secret_key_base: secret_key_base
|
secret_key_base: secret_key_base
|
||||||
|
|
||||||
# K8s cluster connection (future — orchestrator will use this)
|
config :guildhall_web, :oidc,
|
||||||
# config :guildhall_orchestrator,
|
issuer:
|
||||||
# kubeconfig: System.get_env("KUBECONFIG") || "~/.kube/config",
|
System.get_env("OIDC_ISSUER") || "https://auth.guildhouse.dev/realms/guildhouse",
|
||||||
# context: System.get_env("K8S_CONTEXT")
|
client_id: System.get_env("OIDC_CLIENT_ID") || "guildhall-web",
|
||||||
|
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||||
|
redirect_uri:
|
||||||
|
System.get_env("OIDC_REDIRECT_URI") ||
|
||||||
|
"https://guildhall.guildhouse.dev/auth/callback"
|
||||||
|
|
||||||
# Keycloak OIDC (future — auth)
|
config :guildhall_orchestrator,
|
||||||
# config :guildhall_web, :oidc,
|
ceremony_service_url:
|
||||||
# issuer: System.get_env("OIDC_ISSUER") || "https://auth.guildhouse.dev/realms/guildhouse",
|
System.get_env("CEREMONY_SERVICE_URL") || "localhost:50053",
|
||||||
# client_id: System.get_env("OIDC_CLIENT_ID"),
|
schematic_service_url:
|
||||||
# client_secret: System.get_env("OIDC_CLIENT_SECRET")
|
System.get_env("SCHEMATIC_SERVICE_URL") || "localhost:9091",
|
||||||
|
ffc_schematic_service_url:
|
||||||
|
System.get_env("FFC_SCHEMATIC_SERVICE_URL") || "localhost:9091"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
7
k8s/00-namespace.yaml
Normal file
7
k8s/00-namespace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: guildhall
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
36
k8s/10-registry-secret-template.yaml
Normal file
36
k8s/10-registry-secret-template.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# 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>"}}}>
|
||||||
16
k8s/20-postgres-pvc.yaml
Normal file
16
k8s/20-postgres-pvc.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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
|
||||||
90
k8s/30-postgres-deployment.yaml
Normal file
90
k8s/30-postgres-deployment.yaml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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
|
||||||
19
k8s/40-postgres-service.yaml
Normal file
19
k8s/40-postgres-service.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
77
k8s/50-guildhall-secrets-template.yaml
Normal file
77
k8s/50-guildhall-secrets-template.yaml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# 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
|
||||||
66
k8s/60-migration-job.yaml
Normal file
66
k8s/60-migration-job.yaml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# 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
|
||||||
117
k8s/70-guildhall-deployment.yaml
Normal file
117
k8s/70-guildhall-deployment.yaml
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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
|
||||||
32
k8s/80-guildhall-service.yaml
Normal file
32
k8s/80-guildhall-service.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
||||||
72
k8s/90-ceremony-service-deployment.yaml
Normal file
72
k8s/90-ceremony-service-deployment.yaml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ceremony-service
|
||||||
|
namespace: guildhall
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ceremony-service
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: ceremony-engine
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
app.kubernetes.io/version: v0.1.0
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ceremony-service
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ceremony-service
|
||||||
|
app.kubernetes.io/name: ceremony-service
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: ceremony-engine
|
||||||
|
app.kubernetes.io/version: v0.1.0
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: guildhall-registry
|
||||||
|
containers:
|
||||||
|
- name: ceremony-service
|
||||||
|
image: git.guildhouse.dev/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
|
||||||
19
k8s/91-ceremony-service-service.yaml
Normal file
19
k8s/91-ceremony-service-service.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ceremony-service
|
||||||
|
namespace: guildhall
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ceremony-service
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: ceremony-engine
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 50053
|
||||||
|
targetPort: grpc
|
||||||
|
protocol: TCP
|
||||||
|
name: grpc
|
||||||
|
selector:
|
||||||
|
app: ceremony-service
|
||||||
72
k8s/92-schematic-server-deployment.yaml
Normal file
72
k8s/92-schematic-server-deployment.yaml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ffc-schematic-server
|
||||||
|
namespace: guildhall
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ffc-schematic-server
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: schematic-engine
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
app.kubernetes.io/version: v0.1.0
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ffc-schematic-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ffc-schematic-server
|
||||||
|
app.kubernetes.io/name: ffc-schematic-server
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: schematic-engine
|
||||||
|
app.kubernetes.io/version: v0.1.0
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: guildhall-registry
|
||||||
|
containers:
|
||||||
|
- name: ffc-schematic-server
|
||||||
|
image: git.guildhouse.dev/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
|
||||||
19
k8s/93-schematic-server-service.yaml
Normal file
19
k8s/93-schematic-server-service.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ffc-schematic-server
|
||||||
|
namespace: guildhall
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ffc-schematic-server
|
||||||
|
app.kubernetes.io/part-of: guildhouse
|
||||||
|
app.kubernetes.io/component: schematic-engine
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 9091
|
||||||
|
targetPort: grpc
|
||||||
|
protocol: TCP
|
||||||
|
name: grpc
|
||||||
|
selector:
|
||||||
|
app: ffc-schematic-server
|
||||||
23
mix.exs
23
mix.exs
|
|
@ -6,7 +6,28 @@ defmodule Guildhall.MixProject do
|
||||||
apps_path: "apps",
|
apps_path: "apps",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
deps: deps()
|
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
|
||||||
|
]
|
||||||
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
19
mix.lock
19
mix.lock
|
|
@ -1,6 +1,8 @@
|
||||||
%{
|
%{
|
||||||
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
"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"},
|
"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"},
|
"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"},
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
|
|
@ -10,13 +12,25 @@
|
||||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
"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"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"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"},
|
"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"},
|
"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]},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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": {: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_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"},
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||||
|
|
@ -26,12 +40,17 @@
|
||||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
"plug": {: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"},
|
"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"},
|
"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"},
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
"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_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_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"},
|
"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"},
|
"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": {: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"},
|
"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"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue