16 Commits

Author SHA1 Message Date
f9f6f339f4 Add multi-address fallback for node connectivity
NodeConfig and MasterNodeConfig gain an optional addresses[] field
for fallback addresses tried in order after the primary address.
Provides resilience when Tailscale DNS is down or a node is only
reachable via LAN.

- dialAgentMulti: tries each address with a 3s health check, returns
  first success
- forEachNode: uses multi-address dialing
- AgentPool.AddNodeMulti: master tries all addresses when connecting
- AllAddresses(): deduplicates primary + fallback addresses

Config example:
  [[nodes]]
  name = "rift"
  address = "rift.scylla-hammerhead.ts.net:9444"
  addresses = ["100.95.252.120:9444", "192.168.88.181:9444"]

Existing configs without addresses[] work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:45:50 -07:00
5da307cab5 Add Dockerfile and docker-master build target
Two-stage build: golang:1.25-alpine builder, alpine:3.21 runtime.
Produces a minimal container image for mcp-master.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:52:10 -07:00
22a836812f Add public, tier, node fields to ServiceDef
RouteDef gains Public field (bool) for edge routing. ServiceDef gains
Tier field. Node validation relaxed: defaults to tier=worker when both
node and tier are empty (v2 compatibility).

ToProto/FromProto updated to round-trip all new fields. Without this,
public=true in TOML was silently dropped and edge routing never triggered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:42:00 -07:00
9918859705 Resolve node hostname to IP for DNS registration
Node addresses may be Tailscale DNS names (e.g., rift.scylla-hammerhead.ts.net:9444)
but MCNS needs an IPv4 address for A records. The master now resolves
the hostname via net.LookupHost before passing it to the DNS client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:58:21 -07:00
da59d60c2d Add master integration to CLI deploy and undeploy
- CLIConfig gains optional [master] section with address field
- dialMaster() creates McpMasterServiceClient (same TLS/token pattern)
- deploy: routes through master when [master] configured, --direct
  flag bypasses master for v1-style agent deployment
- undeploy: same master/direct routing pattern
- Master responses show per-step results (deploy, dns, edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:43:51 -07:00
598ea44e0b Add mcp-master binary and build target
New cmd/mcp-master/ entry point following the agent pattern:
cobra CLI with --config, version, and server commands.

Makefile: add mcp-master target, update all and clean targets.
Example config: deploy/examples/mcp-master.toml with all sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:41:43 -07:00
6fd81cacf2 Add master core: deploy, undeploy, status, placement, DNS
Master struct with Run() lifecycle following the agent pattern exactly:
open DB → bootstrap nodes → create agent pool → DNS client → TLS →
auth interceptor → gRPC server → signal handler.

RPC handlers:
- Deploy: place service (tier-aware), forward to agent, register DNS
  with Tailnet IP, detect public routes, validate against allowed
  domains, coordinate edge routing via SetupEdgeRoute, record placement
  and edge routes in master DB, return structured per-step results.
- Undeploy: undeploy on worker first, then remove edge routes, DNS,
  and DB records. Best-effort cleanup on failure.
- Status: query agents for service status, aggregate with placements
  and edge route info from master DB.
- ListNodes: return all nodes with placement counts.

Placement algorithm: fewest services, ties broken alphabetically.
DNS client: extracted from agent's DNSRegistrar with explicit nodeAddr
parameter (master registers for different nodes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:39:46 -07:00
20735e4b41 Add agent client and connection pool for master
AgentClient wraps a gRPC connection to a single agent with typed
forwarding methods (Deploy, UndeployService, SetupEdgeRoute, etc.).
AgentPool manages connections to multiple agents keyed by node name.

Follows the same TLS 1.3 + token interceptor pattern as cmd/mcp/dial.go
but runs server-side with the master's own MCIAS service token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:35:16 -07:00
3c0b55f9f8 Add master database with nodes, placements, and edge_routes
New internal/masterdb/ package for mcp-master cluster state. Separate
from the agent's registry because the schemas are fundamentally
different (cluster-wide placement vs node-local containers).

Tables: nodes, placements, edge_routes. Full CRUD with tests.
Follows the same Open/migrate pattern as internal/registry/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:26:04 -07:00
78890ed76a Add master config loader
MasterConfig with TOML loading, env overrides (MCP_MASTER_*), defaults,
and validation. Follows the exact pattern of AgentConfig. Includes:
server, database, MCIAS, edge (allowed_domains), registration
(allowed_agents, max_nodes), timeouts, MCNS, bootstrap [[nodes]], and
master service token path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:23:19 -07:00
c5ff5bb63c Add McpMasterService proto and v2 ServiceSpec fields
- New proto/mcp/v1/master.proto: McpMasterService with Deploy, Undeploy,
  Status, ListNodes RPCs and all message types per architecture v2 spec.
- ServiceSpec gains tier (field 5), node (field 6), snapshot (field 7).
- RouteSpec gains public (field 5) for edge routing.
- New SnapshotConfig message (method + excludes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:22:04 -07:00
ddd6f123ab Merge pull request 'Document v2 multi-node architecture in CLAUDE.md' (#3) from claude/update-claude-md-v2-multinode into master 2026-04-02 22:20:19 +00:00
90445507a3 Document v2 multi-node architecture in CLAUDE.md
Add v2 Development section covering multi-node fleet design (master,
agent self-registration, tier-based placement, edge routing). Update
project structure to reflect new agent subsystems (edge_rpc, dns,
proxy, certs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:14:20 -07:00
68d670b3ed Add edge CLI scaffolding for Phase 2 testing
Temporary CLI commands for testing edge routing RPCs directly
(before the master exists):

  mcp edge list -n svc
  mcp edge setup <hostname> -n svc --backend-hostname ... --backend-port ...
  mcp edge remove <hostname> -n svc

Verified end-to-end on svc: setup provisions route in mc-proxy and
persists in agent registry, remove cleans up both, list shows routes
with cert metadata.

Finding: MCNS registers LAN IPs for .svc.mcp. hostnames, not Tailnet
IPs. The v2 master needs to register Tailnet IPs in deploy flow step 3.

These commands will be removed or replaced when the master is built
(Phase 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:04:12 -07:00
714320c018 Add edge routing and health check RPCs (Phase 2)
New agent RPCs for v2 multi-node orchestration:

- SetupEdgeRoute: provisions TLS cert from Metacrypt, resolves backend
  hostname to Tailnet IP, validates it's in 100.64.0.0/10, registers
  L7 route in mc-proxy. Rejects backend_tls=false.
- RemoveEdgeRoute: removes mc-proxy route, cleans up TLS cert, removes
  registry entry.
- ListEdgeRoutes: returns all edge routes with cert serial/expiry.
- HealthCheck: returns agent health and container count.

New database table (migration 4): edge_routes stores hostname, backend
info, and cert paths for persistence across agent restarts.

ProxyRouter gains CertPath/KeyPath helpers for consistent cert path
construction.

Security:
- Backend hostname must resolve to a Tailnet IP (100.64.0.0/10)
- backend_tls=false is rejected (no cleartext to backends)
- Cert provisioning failure fails the setup (no route to missing cert)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:13:10 -07:00
fa8ba6fac1 Move ARCHITECTURE_V2.md to metacircular docs
The v2 architecture doc is platform-wide (covers master, agents,
edge routing, snapshots, migration across all nodes). Moved to
docs/architecture-v2.md in the metacircular workspace repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:09:09 -07:00
39 changed files with 5079 additions and 818 deletions

View File

@@ -1,502 +0,0 @@
# MCP v2 -- Multi-Node Control Plane
## Overview
MCP v2 introduces multi-node orchestration with a master/agent topology.
The CLI no longer dials agents directly. A dedicated **mcp-master** daemon
coordinates deployments across nodes, handles cross-node concerns (edge
routing, certificate provisioning, DNS), and serves as the single control
point for the platform.
### Motivation
v1 deployed successfully on a single node (rift) but exposed operational
pain points as services needed public-facing routes through svc:
- **Manual edge routing**: Exposing mcq.metacircular.net required hand-editing
mc-proxy's TOML config on svc, provisioning a TLS cert manually, updating
the SQLite database when the config and database diverged, and debugging
silent failures. Every redeployment risked breaking the public route.
- **Dynamic port instability**: The route system assigns ephemeral host ports
that change on every deploy. svc's mc-proxy pointed at a specific port
(e.g., `100.95.252.120:48080`), which went stale after redeployment.
Container ports are also localhost-only under rootless podman, requiring
explicit Tailscale IP bindings for external access.
- **$PORT env override conflict**: The mcdsl config loader overrides
`listen_addr` from `$PORT` when routes are present. This meant containers
ignored their configured port and listened on the route-allocated one
instead, breaking explicit port mappings that expected the config port.
- **Cert chain issues**: mc-proxy requires full certificate chains (leaf +
intermediates). Certs provisioned outside the standard metacrypt flow
were leaf-only and caused silent TLS handshake failures (`client_bytes=7
backend_bytes=0` with no error logged).
- **mc-proxy database divergence**: mc-proxy persists routes in SQLite.
Routes added via the admin API override the TOML config. Editing the TOML
alone had no effect until the database was manually updated -- a failure
mode that took hours to diagnose.
- **No cross-node coordination**: The v1 CLI talks directly to individual
agents. There is no mechanism for one agent to tell another "set up a
route for this service." Every cross-node operation was manual.
v2 addresses all of these by making the master the single coordination
point for deployments, with agents handling local concerns (containers,
mc-proxy routes, cert provisioning) on instruction from the master.
### What Changes from v1
| Concern | v1 | v2 |
|---------|----|----|
| CLI target | CLI dials agents directly | CLI dials the master |
| Node awareness | CLI routes by `node` field in service defs | Master owns the node registry |
| Service definitions | Live on operator workstation | Pushed to master, which distributes to agents |
| Edge routing | Manual mc-proxy config on svc | Master coordinates agent-to-agent setup |
| Cert provisioning | Agent provisions for local mc-proxy only | Any agent can provision certs (edge included) |
| DNS registration | Agent registers records on deploy | Master coordinates DNS across zones |
### What Stays the Same
The agent's core responsibilities are unchanged: it manages containers via
podman, stores its local registry in SQLite, monitors for drift, and alerts
the operator. The agent gains new RPCs for edge routing but does not become
aware of other nodes -- the master handles all cross-node coordination.
---
## Topology
```
Operator workstation (vade)
┌──────────────────────────┐
│ mcp (CLI) │
│ │
│ gRPC ───────────────────┼─── overlay ───┐
└──────────────────────────┘ │
Master node (straylight)
┌──────────────────────────────────────────────────────┐
│ mcp-master │
│ ├── node registry (all nodes, roles, addresses) │
│ ├── service definitions (pushed from CLI) │
│ └── deployment coordinator │
│ │
│ mcp-agent │
│ ├── mcns container │
│ ├── metacrypt container │
│ ├── mcr container │
│ └── mc-proxy (straylight) │
└──────────┬──────────────────────────┬────────────────┘
│ │
overlay overlay
│ │
▼ ▼
Worker node (rift) Edge node (svc)
┌─────────────────────┐ ┌─────────────────────────┐
│ mcp-agent │ │ mcp-agent │
│ ├── exo │ │ ├── mc-proxy (svc) │
│ ├── mcq │ │ └── (edge routes only) │
│ ├── mcdoc │ │ │
│ ├── sgard │ │ Edge routes: │
│ ├── kls │ │ mcq.metacircular.net │
│ └── mc-proxy │ │ mcdoc.metacircular.net │
│ (rift) │ │ exo.metacircular.net │
└─────────────────────┘ │ sgard.metacircular.net │
└─────────────────────────┘
```
### Node Roles
| Role | Purpose | Nodes |
|------|---------|-------|
| **master** | Runs mcp-master + mcp-agent. Hosts core infrastructure (mcns, metacrypt, mcr). Single coordination point. | straylight |
| **worker** | Runs mcp-agent. Hosts application services. | rift |
| **edge** | Runs mcp-agent. Terminates public TLS, forwards to internal services. No application containers. | svc |
Every node runs an mcp-agent. The master node also runs mcp-master.
The master's local agent manages the infrastructure services (mcns,
metacrypt, mcr) the same way rift's agent manages application services.
### mc-proxy Mesh
Each node runs its own mc-proxy instance. They form a routing mesh:
```
mc-proxy (straylight)
├── :443 L7 routes for metacrypt-web, mcr-web
├── :8443 L4 passthrough for metacrypt-api, mcr-api
└── :9443 L4 passthrough for gRPC services
mc-proxy (rift)
├── :443 L7 routes for internal .svc.mcp hostnames
└── :8443 L4/L7 routes for internal APIs
mc-proxy (svc)
└── :443 L7 termination for public hostnames
→ forwards to internal .svc.mcp endpoints
```
---
## mcp-master
The master is a new binary that coordinates cross-node operations. It is
**not** a replacement for the agent -- it sits above agents and orchestrates
them.
### Responsibilities
1. **Accept CLI commands** via gRPC (deploy, undeploy, status, sync).
2. **Route deployments** to the correct agent based on the service
definition's `node` field.
3. **Detect public hostnames** in service definitions and coordinate edge
routing with the edge node's agent.
4. **Validate public hostnames** against a configured allowlist of domains
(e.g., `metacircular.net`, `wntrmute.net`).
5. **Resolve edge nodes** by checking DNS CNAME records to determine which
node handles public traffic for a given hostname.
6. **Coordinate undeploy** across nodes: tear down the service on the
worker, then clean up edge routes on the edge node.
### What the Master Does NOT Do
- Store container state (agents own their registries).
- Manage container lifecycle directly (agents do this).
- Run containers (the co-located agent does).
- Replace the agent on any node.
### Master Configuration
```toml
[server]
grpc_addr = "100.x.x.x:9555" # master listens on overlay
tls_cert = "/srv/mcp-master/certs/cert.pem"
tls_key = "/srv/mcp-master/certs/key.pem"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-master"
# Allowed public domains. Hostnames in service definitions must fall
# under one of these suffixes.
[edge]
allowed_domains = ["metacircular.net", "wntrmute.net"]
# Node registry. The master knows about all nodes.
[[nodes]]
name = "straylight"
address = "100.x.x.x:9444"
role = "master"
[[nodes]]
name = "rift"
address = "100.95.252.120:9444"
role = "worker"
[[nodes]]
name = "svc"
address = "100.x.x.x:9444"
role = "edge"
```
---
## Edge Routing
The core v2 feature: when a service declares a public hostname, the
master automatically provisions the edge route.
### Service Definition
Public hostnames are declared in the route's `hostname` field. The
master distinguishes public from internal hostnames by checking whether
they fall under a `.svc.mcp.` subdomain:
- `mcq.svc.mcp.metacircular.net` → internal (handled by local mc-proxy)
- `mcq.metacircular.net` → public (requires edge routing)
```toml
name = "mcq"
node = "rift"
active = true
[[components]]
name = "mcq"
image = "mcr.svc.mcp.metacircular.net:8443/mcq:v0.4.0"
volumes = ["/srv/mcq:/srv/mcq"]
cmd = ["server", "--config", "/srv/mcq/mcq.toml"]
# Internal route: handled by rift's mc-proxy.
[[components.routes]]
name = "internal"
port = 8443
mode = "l7"
# Public route: master detects this and sets up edge routing on svc.
[[components.routes]]
name = "public"
port = 8443
mode = "l7"
hostname = "mcq.metacircular.net"
```
### Deploy Flow with Edge Routing
When the master receives `Deploy(mcq)`:
1. **Route to worker**: Master sends `Deploy` RPC to rift's agent with
the full service spec. Rift's agent deploys the container and
registers mc-proxy routes for all hostnames (both internal and public)
on its local mc-proxy.
2. **Detect public hostnames**: Master inspects the service spec for
hostnames that are not `.svc.mcp.` subdomains.
3. **Validate domains**: Master checks that `mcq.metacircular.net` falls
under an allowed domain (`metacircular.net` ✓).
4. **Resolve edge node**: Master performs a DNS lookup for
`mcq.metacircular.net`. If it's a CNAME to `svc.metacircular.net`,
the master resolves `svc.metacircular.net` to identify the edge node
as `svc`. If DNS is not yet configured (no CNAME), the master uses
the default edge node from config.
5. **Set up edge route**: Master sends an `SetupEdgeRoute` RPC to svc's
agent:
```
SetupEdgeRoute(
hostname: "mcq.metacircular.net"
backend_hostname: "mcq.svc.mcp.metacircular.net"
backend_port: 8443
)
```
6. **Svc agent provisions**: On receiving `SetupEdgeRoute`, svc's agent:
a. Provisions a TLS certificate from Metacrypt for
`mcq.metacircular.net`.
b. Registers an L7 route in its local mc-proxy:
`mcq.metacircular.net:443 → mcq.svc.mcp.metacircular.net:8443`
with the provisioned cert.
7. **Master records the edge route** in its own registry for undeploy
cleanup.
### Undeploy Flow
When the master receives `Undeploy(mcq)`:
1. **Look up edge routes**: Master checks its registry for edge routes
associated with mcq.
2. **Remove edge route**: Master sends `RemoveEdgeRoute(mcq.metacircular.net)`
to svc's agent. Svc's agent removes the mc-proxy route and cleans up
the cert.
3. **Undeploy on worker**: Master sends `Undeploy` RPC to rift's agent.
Rift's agent tears down the container, routes, DNS, and certs as in v1.
### Edge Node DNS Resolution
The master determines which edge node handles a public hostname by
checking DNS:
1. Look up `mcq.metacircular.net` → CNAME `svc.metacircular.net`
2. Look up `svc.metacircular.net` → IP address
3. Match the IP against known edge nodes
If no CNAME exists yet (operator hasn't set it up), the master warns but
does not fail. The operator sets up DNS manually at Hurricane Electric.
The master can provide a `mcp dns check` command that verifies all public
hostnames resolve correctly.
---
## Agent Changes for v2
### New RPCs
```protobuf
// Edge routing -- called by master on edge nodes.
rpc SetupEdgeRoute(SetupEdgeRouteRequest) returns (SetupEdgeRouteResponse);
rpc RemoveEdgeRoute(RemoveEdgeRouteRequest) returns (RemoveEdgeRouteResponse);
rpc ListEdgeRoutes(ListEdgeRoutesRequest) returns (ListEdgeRoutesResponse);
message SetupEdgeRouteRequest {
string hostname = 1; // public hostname (e.g. "mcq.metacircular.net")
string backend_hostname = 2; // internal hostname (e.g. "mcq.svc.mcp.metacircular.net")
int32 backend_port = 3; // port on the worker's mc-proxy (e.g. 8443)
}
message SetupEdgeRouteResponse {}
message RemoveEdgeRouteRequest {
string hostname = 1;
}
message RemoveEdgeRouteResponse {}
message ListEdgeRoutesRequest {}
message ListEdgeRoutesResponse {
repeated EdgeRoute routes = 1;
}
message EdgeRoute {
string hostname = 1;
string backend_hostname = 2;
int32 backend_port = 3;
string cert_serial = 4;
string cert_expires = 5;
}
```
### SetupEdgeRoute Implementation
When the agent receives `SetupEdgeRoute`:
1. **Resolve backend address**: The agent resolves `backend_hostname` to
an IP address (the worker node's overlay IP). It uses the port from
the request to form the backend address (e.g., `100.95.252.120:8443`).
2. **Provision TLS cert**: The agent calls Metacrypt's CA API to issue a
certificate for the public hostname. The cert and key are written to
the mc-proxy cert directory.
3. **Register mc-proxy route**: The agent adds an L7 route to its local
mc-proxy:
- Listener: `:443`
- Hostname: `mcq.metacircular.net`
- Backend: `100.95.252.120:8443`
- Mode: `l7`
- TLS cert/key: the provisioned cert
- Backend TLS: `true` (worker's mc-proxy serves TLS)
4. **Record the edge route** in the agent's local registry for listing
and cleanup.
### Cert Provisioning on All Agents
All agents need Metacrypt configuration to provision certs:
```toml
[metacrypt]
server_url = "https://metacrypt.svc.mcp.metacircular.net:8443"
ca_cert = "/srv/mcp/certs/metacircular-ca.pem"
mount = "pki"
issuer = "infra"
token_path = "/srv/mcp/metacrypt-token"
```
The svc agent provisions certs for public hostnames. The rift agent
provisions certs for internal hostnames. Both use the same Metacrypt API.
---
## CLI Changes for v2
The CLI's `[[nodes]]` config is replaced by a single master address:
```toml
[master]
address = "100.x.x.x:9555"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp"
[auth]
token_path = "/home/kyle/.config/mcp/token"
[services]
dir = "/home/kyle/.config/mcp/services"
```
Commands that currently iterate over nodes (`mcp ps`, `mcp list`,
`mcp node list`) instead query the master, which aggregates from all
agents.
Service definition files remain on the operator's workstation. The CLI
pushes them to the master on `mcp deploy` and `mcp sync`.
---
## Migration Plan
### Phase 1: Deploy mcp-agent on svc
svc currently has no mcp-agent. Install and configure one:
- Create `mcp` user on svc (Debian: `install-agent.sh`)
- Install mcp-agent binary
- Configure with Metacrypt and mc-proxy socket access
- Verify with `mcp node list` (svc shows up)
### Phase 2: Add edge routing RPCs to agents
Implement `SetupEdgeRoute`, `RemoveEdgeRoute`, `ListEdgeRoutes` on the
agent. Test by calling them directly from the CLI before the master exists.
### Phase 3: Build mcp-master
Start with the core coordination loop:
1. Accept `Deploy` from CLI
2. Forward to the correct agent
3. Detect public hostnames
4. Call `SetupEdgeRoute` on the edge agent
### Phase 4: Provision straylight
New node (straylight) takes over as master and hosts core infrastructure:
1. Deploy mcp-agent on straylight
2. Migrate mcns, metacrypt, mcr from rift to straylight
3. Deploy mcp-master on straylight
4. Update CLI config to point at master
### Phase 5: Cut over
- Update DNS to point `*.svc.mcp.metacircular.net` at straylight
- Update service definitions to use new node assignments
- Verify all services via `mcp ps` and public endpoint tests
---
## Open Questions
1. **Master HA**: mcp-master is a single point of failure. For v2, this
is acceptable (operator can SSH to agents directly if master is down).
v3 could add master replication or make agents self-sufficient for
local operations when the master is unreachable.
2. **Service placement**: v2 still requires explicit `node` assignment
in service definitions. Automatic placement based on resource
availability is a future concern.
3. **Cert renewal on edge**: Edge certs have a 90-day TTL. The edge
agent needs a renewal loop (similar to the existing `renewWindow`
check in `EnsureCert`) or the master needs to periodically re-check
edge routes.
4. **mc-proxy database vs config**: mc-proxy persists routes in SQLite,
which can diverge from the TOML config. The agent should be the sole
manager of mc-proxy routes via the gRPC admin API, not the TOML file.
This avoids the stale-database problem encountered during v1
operations on svc.
5. **straylight hardware**: What hardware is straylight? Does it run
NixOS or Debian? Does it use rootless podman like rift?
6. **Mono-repo for core infrastructure**: The current layout has each
service as a separate git repo under `~/src/metacircular/`. A
mono-repo for core infrastructure (mcp, mcp-master, mcns, metacrypt,
mcr, mc-proxy, mcdsl) would simplify coordinated changes (e.g., a
proto change that touches agent + CLI + mc-proxy client), eliminate
the `uses_mcdsl` build flag / vendoring, enable a single CI pipeline,
and allow atomic platform versioning (one tag per release). Non-core
application services (exo, mcq, mcdoc, sgard, kls, mcat) would
remain as separate repos with independent release cadences. This is
a large migration best tackled after straylight is running and the
master exists, when the build/deploy pipeline is already being
reorganized.

View File

@@ -12,6 +12,21 @@ MCP has two components:
Services have one or more components (containers). Container naming: `<service>-<component>`.
## v2 Development (Multi-Node)
MCP v2 extends the single-node agent model to a multi-node fleet with a central master process. See the root repo's `docs/phase-e-plan.md` and `docs/architecture-v2.md` for the full design.
**Current state:**
- **svc** is operational as an edge node (manages mc-proxy routing only, no containers)
- **rift** runs the agent with full container management
- **orion** is provisioned but offline for maintenance
**Key v2 concepts (in development):**
- **mcp-master** — central orchestrator on rift. Accepts CLI commands, dispatches to agents, maintains node registry, coordinates edge routing.
- **Agent self-registration** — agents register with the master on startup (name, role, address, arch). No static node config required after bootstrap.
- **Tier-based placement** — `tier = "core"` runs on the master node, `tier = "worker"` (default) is auto-placed on a worker with capacity, `node = "<name>"` overrides for pinned services.
- **Edge routing** — `public = true` on routes declares intent; the master assigns the route to an edge node (currently svc).
## Build Commands
```bash
@@ -33,7 +48,7 @@ Run a single test: `go test ./internal/registry/ -run TestComponentCRUD`
- `cmd/mcp/` — CLI entry point
- `cmd/mcp-agent/` — Agent entry point
- `internal/agent/` — Agent core (deploy, lifecycle, sync, adopt, status, files)
- `internal/agent/` — Agent core (deploy, lifecycle, sync, adopt, status, files, edge_rpc, dns, proxy, certs)
- `internal/runtime/` — Container runtime abstraction (podman)
- `internal/registry/` — SQLite registry (services, components, events)
- `internal/monitor/` — Monitoring subsystem (watch loop, alerting)

22
Dockerfile.master Normal file
View File

@@ -0,0 +1,22 @@
FROM golang:1.25-alpine AS builder
ARG VERSION=dev
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o /mcp-master ./cmd/mcp-master
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /mcp-master /usr/local/bin/mcp-master
WORKDIR /srv/mcp-master
EXPOSE 9555
ENTRYPOINT ["mcp-master"]
CMD ["server", "--config", "/srv/mcp-master/mcp-master.toml"]

View File

@@ -8,6 +8,9 @@ mcp:
mcp-agent:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcp-agent ./cmd/mcp-agent
mcp-master:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcp-master ./cmd/mcp-master
build:
go build ./...
@@ -29,7 +32,12 @@ proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
clean:
rm -f mcp mcp-agent
docker-master:
podman build -f Dockerfile.master \
--build-arg VERSION=$(shell git describe --tags --always --dirty) \
-t mcr.svc.mcp.metacircular.net:8443/mcp-master:$(shell git describe --tags --always --dirty) .
all: vet lint test mcp mcp-agent
clean:
rm -f mcp mcp-agent mcp-master
all: vet lint test mcp mcp-agent mcp-master

49
cmd/mcp-master/main.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"log"
"os"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/master"
"github.com/spf13/cobra"
)
var (
version = "dev"
cfgPath string
)
func main() {
root := &cobra.Command{
Use: "mcp-master",
Short: "Metacircular Control Plane master",
}
root.PersistentFlags().StringVarP(&cfgPath, "config", "c", "", "config file path")
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version)
},
})
root.AddCommand(&cobra.Command{
Use: "server",
Short: "Start the master server",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadMasterConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return master.Run(cfg, version)
},
})
if err := root.Execute(); err != nil {
log.Fatal(err)
os.Exit(1)
}
}

View File

@@ -15,6 +15,8 @@ import (
)
func deployCmd() *cobra.Command {
var direct bool
cmd := &cobra.Command{
Use: "deploy <service>[/<component>]",
Short: "Deploy service from service definition",
@@ -40,6 +42,12 @@ func deployCmd() *cobra.Command {
spec := servicedef.ToProto(def)
// Route through master if configured and not in direct mode.
if cfg.Master != nil && cfg.Master.Address != "" && !direct {
return deployViaMaster(cfg, spec)
}
// Direct mode: deploy to agent.
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
@@ -64,9 +72,48 @@ func deployCmd() *cobra.Command {
},
}
cmd.Flags().StringP("file", "f", "", "service definition file")
cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, deploy directly to agent (v1 mode)")
return cmd
}
func deployViaMaster(cfg *config.CLIConfig, spec *mcpv1.ServiceSpec) error {
client, conn, err := dialMaster(cfg.Master.Address, cfg)
if err != nil {
return fmt.Errorf("dial master: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.Deploy(context.Background(), &mcpv1.MasterDeployRequest{
Service: spec,
})
if err != nil {
return fmt.Errorf("master deploy: %w", err)
}
fmt.Printf(" %s: placed on %s\n", spec.GetName(), resp.GetNode())
if r := resp.GetDeployResult(); r != nil {
printStepResult("deploy", r)
}
if r := resp.GetDnsResult(); r != nil {
printStepResult("dns", r)
}
if r := resp.GetEdgeRouteResult(); r != nil {
printStepResult("edge", r)
}
if !resp.GetSuccess() {
return fmt.Errorf("deploy failed: %s", resp.GetError())
}
return nil
}
func printStepResult(name string, r *mcpv1.StepResult) {
if r.GetSuccess() {
fmt.Printf(" %s: ok\n", name)
} else {
fmt.Printf(" %s: FAILED — %s\n", name, r.GetError())
}
}
// parseServiceArg splits a "service/component" argument into its parts.
func parseServiceArg(arg string) (service, component string) {
parts := strings.SplitN(arg, "/", 2)
@@ -125,8 +172,9 @@ func loadServiceDef(cmd *cobra.Command, cfg *config.CLIConfig, serviceName strin
// ServiceInfo, not ServiceSpec.
func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
Name: info.GetName(),
Active: info.GetActive(),
Name: info.GetName(),
Active: info.GetActive(),
Comment: info.GetComment(),
}
for _, c := range info.GetComponents() {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strings"
"time"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
@@ -52,6 +53,75 @@ func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClie
return mcpv1.NewMcpAgentServiceClient(conn), conn, nil
}
// dialAgentMulti tries each address in order and returns the first successful
// connection. Provides resilience when Tailscale DNS is down or a node is
// reachable via LAN but not Tailnet.
func dialAgentMulti(addresses []string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClient, *grpc.ClientConn, error) {
if len(addresses) == 0 {
return nil, nil, fmt.Errorf("no addresses to dial")
}
if len(addresses) == 1 {
return dialAgent(addresses[0], cfg)
}
var lastErr error
for _, addr := range addresses {
client, conn, err := dialAgent(addr, cfg)
if err != nil {
lastErr = fmt.Errorf("%s: %w", addr, err)
continue
}
// Quick health check to verify the connection actually works.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_, err = client.NodeStatus(ctx, &mcpv1.NodeStatusRequest{})
cancel()
if err != nil {
_ = conn.Close()
lastErr = fmt.Errorf("%s: %w", addr, err)
continue
}
return client, conn, nil
}
return nil, nil, fmt.Errorf("all addresses failed, last error: %w", lastErr)
}
// dialMaster connects to the master at the given address and returns a gRPC
// client for the McpMasterService.
func dialMaster(address string, cfg *config.CLIConfig) (mcpv1.McpMasterServiceClient, *grpc.ClientConn, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.MCIAS.CACert != "" {
caCert, err := os.ReadFile(cfg.MCIAS.CACert) //nolint:gosec // trusted config path
if err != nil {
return nil, nil, fmt.Errorf("read CA cert %q: %w", cfg.MCIAS.CACert, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, nil, fmt.Errorf("invalid CA cert %q", cfg.MCIAS.CACert)
}
tlsConfig.RootCAs = pool
}
token, err := loadBearerToken(cfg)
if err != nil {
return nil, nil, fmt.Errorf("load token: %w", err)
}
conn, err := grpc.NewClient(
address,
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
grpc.WithUnaryInterceptor(tokenInterceptor(token)),
grpc.WithStreamInterceptor(streamTokenInterceptor(token)),
)
if err != nil {
return nil, nil, fmt.Errorf("dial master %q: %w", address, err)
}
return mcpv1.NewMcpMasterServiceClient(conn), conn, nil
}
// tokenInterceptor returns a gRPC client interceptor that attaches the
// bearer token to outgoing RPC metadata.
func tokenInterceptor(token string) grpc.UnaryClientInterceptor {

180
cmd/mcp/edge.go Normal file
View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
)
func edgeCmd() *cobra.Command {
var nodeName string
cmd := &cobra.Command{
Use: "edge",
Short: "Manage edge routes (scaffolding — will be replaced by master)",
}
list := &cobra.Command{
Use: "list",
Short: "List edge routes on a node",
RunE: func(_ *cobra.Command, _ []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
return runEdgeList(nodeName)
},
}
var (
backendHostname string
backendPort int
)
setup := &cobra.Command{
Use: "setup <hostname>",
Short: "Set up an edge route (provisions cert, registers mc-proxy route)",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
if backendHostname == "" {
return fmt.Errorf("--backend-hostname is required")
}
if backendPort == 0 {
return fmt.Errorf("--backend-port is required")
}
return runEdgeSetup(nodeName, args[0], backendHostname, backendPort)
},
}
setup.Flags().StringVar(&backendHostname, "backend-hostname", "", "internal .svc.mcp hostname")
setup.Flags().IntVar(&backendPort, "backend-port", 0, "port on worker's mc-proxy")
remove := &cobra.Command{
Use: "remove <hostname>",
Short: "Remove an edge route",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if nodeName == "" {
return fmt.Errorf("--node is required")
}
return runEdgeRemove(nodeName, args[0])
},
}
cmd.PersistentFlags().StringVarP(&nodeName, "node", "n", "", "target node (required)")
cmd.AddCommand(list, setup, remove)
return cmd
}
func runEdgeList(nodeName string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.ListEdgeRoutes(ctx, &mcpv1.ListEdgeRoutesRequest{})
if err != nil {
return fmt.Errorf("list edge routes: %w", err)
}
if len(resp.GetRoutes()) == 0 {
fmt.Printf("No edge routes on %s\n", nodeName)
return nil
}
fmt.Printf("Edge routes on %s:\n", nodeName)
for _, r := range resp.GetRoutes() {
expires := r.GetCertExpires()
if expires == "" {
expires = "unknown"
}
fmt.Printf(" %s → %s:%d cert_expires=%s\n",
r.GetHostname(), r.GetBackendHostname(), r.GetBackendPort(), expires)
}
return nil
}
func runEdgeSetup(nodeName, hostname, backendHostname string, backendPort int) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err = client.SetupEdgeRoute(ctx, &mcpv1.SetupEdgeRouteRequest{
Hostname: hostname,
BackendHostname: backendHostname,
BackendPort: int32(backendPort), //nolint:gosec // port is a small positive integer
BackendTls: true,
})
if err != nil {
return fmt.Errorf("setup edge route: %w", err)
}
fmt.Printf("edge route established: %s → %s:%d on %s\n", hostname, backendHostname, backendPort, nodeName)
return nil
}
func runEdgeRemove(nodeName, hostname string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
address, err := findNodeAddress(cfg, nodeName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = client.RemoveEdgeRoute(ctx, &mcpv1.RemoveEdgeRouteRequest{
Hostname: hostname,
})
if err != nil {
return fmt.Errorf("remove edge route: %w", err)
}
fmt.Printf("edge route removed: %s on %s\n", hostname, nodeName)
return nil
}

View File

@@ -9,7 +9,7 @@ import (
)
// findNodeAddress looks up a node by name in the CLI config and returns
// its address.
// its primary address.
func findNodeAddress(cfg *config.CLIConfig, nodeName string) (string, error) {
for _, n := range cfg.Nodes {
if n.Name == nodeName {
@@ -19,6 +19,16 @@ func findNodeAddress(cfg *config.CLIConfig, nodeName string) (string, error) {
return "", fmt.Errorf("node %q not found in config", nodeName)
}
// findNode looks up a node by name in the CLI config.
func findNode(cfg *config.CLIConfig, nodeName string) (*config.NodeConfig, error) {
for i := range cfg.Nodes {
if cfg.Nodes[i].Name == nodeName {
return &cfg.Nodes[i], nil
}
}
return nil, fmt.Errorf("node %q not found in config", nodeName)
}
// printComponentResults prints the result of each component operation.
func printComponentResults(results []*mcpv1.ComponentResult) {
for _, r := range results {

View File

@@ -54,6 +54,7 @@ func main() {
root.AddCommand(editCmd())
root.AddCommand(dnsCmd())
root.AddCommand(routeCmd())
root.AddCommand(edgeCmd())
if err := root.Execute(); err != nil {
log.Fatal(err)

View File

@@ -27,7 +27,7 @@ func forEachNode(fn func(node config.NodeConfig, client mcpv1.McpAgentServiceCli
}
for _, node := range cfg.Nodes {
client, conn, err := dialAgent(node.Address, cfg)
client, conn, err := dialAgentMulti(node.AllAddresses(), cfg)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: %v\n", node.Name, err)
continue
@@ -85,7 +85,7 @@ func psCmd() *cobra.Command {
Short: "Live check: query runtime on all agents",
RunE: func(cmd *cobra.Command, args []string) error {
w := newTable()
_, _ = fmt.Fprintln(w, "SERVICE\tCOMPONENT\tNODE\tSTATE\tVERSION\tUPTIME")
_, _ = fmt.Fprintln(w, "SERVICE\tCOMPONENT\tNODE\tSTATE\tVERSION\tUPTIME\t")
now := time.Now()
if err := forEachNode(func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error {
@@ -96,19 +96,25 @@ func psCmd() *cobra.Command {
}
for _, svc := range resp.GetServices() {
comment := svc.GetComment()
for _, comp := range svc.GetComponents() {
uptime := "-"
if comp.GetStarted() != nil {
d := now.Sub(comp.GetStarted().AsTime())
uptime = formatDuration(d)
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
col7 := ""
if comment != "" {
col7 = "# " + comment
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
svc.GetName(),
comp.GetName(),
node.Name,
comp.GetObservedState(),
comp.GetVersion(),
uptime,
col7,
)
}
}

View File

@@ -13,7 +13,9 @@ import (
)
func undeployCmd() *cobra.Command {
return &cobra.Command{
var direct bool
cmd := &cobra.Command{
Use: "undeploy <service>",
Short: "Fully undeploy a service: remove routes, DNS, certs, and containers",
Args: cobra.ExactArgs(1),
@@ -38,6 +40,11 @@ func undeployCmd() *cobra.Command {
return fmt.Errorf("write service def: %w", err)
}
// Route through master if configured and not in direct mode.
if cfg.Master != nil && cfg.Master.Address != "" && !direct {
return undeployViaMaster(cfg, serviceName)
}
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
@@ -60,4 +67,28 @@ func undeployCmd() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, undeploy directly via agent")
return cmd
}
func undeployViaMaster(cfg *config.CLIConfig, serviceName string) error {
client, conn, err := dialMaster(cfg.Master.Address, cfg)
if err != nil {
return fmt.Errorf("dial master: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.Undeploy(context.Background(), &mcpv1.MasterUndeployRequest{
ServiceName: serviceName,
})
if err != nil {
return fmt.Errorf("master undeploy: %w", err)
}
if resp.GetSuccess() {
fmt.Printf(" %s: undeployed\n", serviceName)
} else {
return fmt.Errorf("undeploy failed: %s", resp.GetError())
}
return nil
}

View File

@@ -0,0 +1,94 @@
# MCP Master configuration
#
# Default location: /srv/mcp-master/mcp-master.toml
# Override with: mcp-master server --config /path/to/mcp-master.toml
# ------------------------------------------------------------------
# gRPC server
# ------------------------------------------------------------------
[server]
# Listen address for the gRPC server. Bind to the Tailnet interface.
grpc_addr = "100.95.252.120:9555"
tls_cert = "/srv/mcp-master/certs/cert.pem"
tls_key = "/srv/mcp-master/certs/key.pem"
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
[database]
path = "/srv/mcp-master/master.db"
# ------------------------------------------------------------------
# MCIAS (for validating inbound CLI/agent tokens)
# ------------------------------------------------------------------
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = "/srv/mcp-master/certs/ca.pem"
service_name = "mcp-master"
# ------------------------------------------------------------------
# Master identity (for dialing agents)
# ------------------------------------------------------------------
[master]
# Path to the MCIAS service token file used by the master to
# authenticate to agents when forwarding deploys and edge routes.
service_token_path = "/srv/mcp-master/mcias-token"
# CA cert for verifying agent TLS certificates.
ca_cert = "/srv/mcp-master/certs/ca.pem"
# ------------------------------------------------------------------
# Edge routing
# ------------------------------------------------------------------
[edge]
# Public hostnames in service definitions must fall under one of these
# domains. Validation uses proper domain label matching.
allowed_domains = ["metacircular.net", "wntrmute.net"]
# ------------------------------------------------------------------
# Agent registration
# ------------------------------------------------------------------
[registration]
# MCIAS service identities permitted to register.
allowed_agents = ["agent-rift", "agent-svc", "agent-orion"]
# Maximum registered nodes.
max_nodes = 16
# ------------------------------------------------------------------
# Timeouts
# ------------------------------------------------------------------
[timeouts]
deploy = "5m"
edge_route = "30s"
health_check = "5s"
undeploy = "2m"
snapshot = "10m"
# ------------------------------------------------------------------
# DNS (MCNS)
# ------------------------------------------------------------------
[mcns]
server_url = "https://mcns.svc.mcp.metacircular.net:8443"
ca_cert = "/srv/mcp-master/certs/ca.pem"
token_path = "/srv/mcp-master/mcns-token"
zone = "svc.mcp.metacircular.net"
# ------------------------------------------------------------------
# Logging
# ------------------------------------------------------------------
[log]
level = "info"
# ------------------------------------------------------------------
# Bootstrap nodes
# ------------------------------------------------------------------
[[nodes]]
name = "rift"
address = "100.95.252.120:9444"
role = "master"
[[nodes]]
name = "svc"
address = "100.106.232.4:9555"
role = "edge"

849
gen/mcp/v1/master.pb.go Normal file
View File

@@ -0,0 +1,849 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcp/v1/master.proto
package mcpv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type MasterDeployRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Service *ServiceSpec `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterDeployRequest) Reset() {
*x = MasterDeployRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterDeployRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterDeployRequest) ProtoMessage() {}
func (x *MasterDeployRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterDeployRequest.ProtoReflect.Descriptor instead.
func (*MasterDeployRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{0}
}
func (x *MasterDeployRequest) GetService() *ServiceSpec {
if x != nil {
return x.Service
}
return nil
}
type MasterDeployResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` // node the service was placed on
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` // true only if ALL steps succeeded
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
// Per-step results for operator visibility.
DeployResult *StepResult `protobuf:"bytes,4,opt,name=deploy_result,json=deployResult,proto3" json:"deploy_result,omitempty"`
EdgeRouteResult *StepResult `protobuf:"bytes,5,opt,name=edge_route_result,json=edgeRouteResult,proto3" json:"edge_route_result,omitempty"`
DnsResult *StepResult `protobuf:"bytes,6,opt,name=dns_result,json=dnsResult,proto3" json:"dns_result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterDeployResponse) Reset() {
*x = MasterDeployResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterDeployResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterDeployResponse) ProtoMessage() {}
func (x *MasterDeployResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterDeployResponse.ProtoReflect.Descriptor instead.
func (*MasterDeployResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{1}
}
func (x *MasterDeployResponse) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *MasterDeployResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *MasterDeployResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *MasterDeployResponse) GetDeployResult() *StepResult {
if x != nil {
return x.DeployResult
}
return nil
}
func (x *MasterDeployResponse) GetEdgeRouteResult() *StepResult {
if x != nil {
return x.EdgeRouteResult
}
return nil
}
func (x *MasterDeployResponse) GetDnsResult() *StepResult {
if x != nil {
return x.DnsResult
}
return nil
}
type StepResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
Step string `protobuf:"bytes,1,opt,name=step,proto3" json:"step,omitempty"`
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StepResult) Reset() {
*x = StepResult{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StepResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StepResult) ProtoMessage() {}
func (x *StepResult) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StepResult.ProtoReflect.Descriptor instead.
func (*StepResult) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{2}
}
func (x *StepResult) GetStep() string {
if x != nil {
return x.Step
}
return ""
}
func (x *StepResult) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *StepResult) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type MasterUndeployRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterUndeployRequest) Reset() {
*x = MasterUndeployRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterUndeployRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterUndeployRequest) ProtoMessage() {}
func (x *MasterUndeployRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterUndeployRequest.ProtoReflect.Descriptor instead.
func (*MasterUndeployRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{3}
}
func (x *MasterUndeployRequest) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
type MasterUndeployResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterUndeployResponse) Reset() {
*x = MasterUndeployResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterUndeployResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterUndeployResponse) ProtoMessage() {}
func (x *MasterUndeployResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterUndeployResponse.ProtoReflect.Descriptor instead.
func (*MasterUndeployResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{4}
}
func (x *MasterUndeployResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *MasterUndeployResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type MasterStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // empty = all services
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterStatusRequest) Reset() {
*x = MasterStatusRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterStatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterStatusRequest) ProtoMessage() {}
func (x *MasterStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterStatusRequest.ProtoReflect.Descriptor instead.
func (*MasterStatusRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{5}
}
func (x *MasterStatusRequest) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
type MasterStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Services []*ServiceStatus `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MasterStatusResponse) Reset() {
*x = MasterStatusResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MasterStatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MasterStatusResponse) ProtoMessage() {}
func (x *MasterStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MasterStatusResponse.ProtoReflect.Descriptor instead.
func (*MasterStatusResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{6}
}
func (x *MasterStatusResponse) GetServices() []*ServiceStatus {
if x != nil {
return x.Services
}
return nil
}
type ServiceStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Node string `protobuf:"bytes,2,opt,name=node,proto3" json:"node,omitempty"`
Tier string `protobuf:"bytes,3,opt,name=tier,proto3" json:"tier,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` // "running", "stopped", "unhealthy", "unknown"
EdgeRoutes []*EdgeRouteStatus `protobuf:"bytes,5,rep,name=edge_routes,json=edgeRoutes,proto3" json:"edge_routes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ServiceStatus) Reset() {
*x = ServiceStatus{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ServiceStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServiceStatus) ProtoMessage() {}
func (x *ServiceStatus) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ServiceStatus.ProtoReflect.Descriptor instead.
func (*ServiceStatus) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{7}
}
func (x *ServiceStatus) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ServiceStatus) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *ServiceStatus) GetTier() string {
if x != nil {
return x.Tier
}
return ""
}
func (x *ServiceStatus) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *ServiceStatus) GetEdgeRoutes() []*EdgeRouteStatus {
if x != nil {
return x.EdgeRoutes
}
return nil
}
type EdgeRouteStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"`
EdgeNode string `protobuf:"bytes,2,opt,name=edge_node,json=edgeNode,proto3" json:"edge_node,omitempty"`
CertExpires string `protobuf:"bytes,3,opt,name=cert_expires,json=certExpires,proto3" json:"cert_expires,omitempty"` // RFC3339
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EdgeRouteStatus) Reset() {
*x = EdgeRouteStatus{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EdgeRouteStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EdgeRouteStatus) ProtoMessage() {}
func (x *EdgeRouteStatus) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EdgeRouteStatus.ProtoReflect.Descriptor instead.
func (*EdgeRouteStatus) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{8}
}
func (x *EdgeRouteStatus) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
func (x *EdgeRouteStatus) GetEdgeNode() string {
if x != nil {
return x.EdgeNode
}
return ""
}
func (x *EdgeRouteStatus) GetCertExpires() string {
if x != nil {
return x.CertExpires
}
return ""
}
type ListNodesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListNodesRequest) Reset() {
*x = ListNodesRequest{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListNodesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListNodesRequest) ProtoMessage() {}
func (x *ListNodesRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead.
func (*ListNodesRequest) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{9}
}
type ListNodesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Nodes []*NodeInfo `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListNodesResponse) Reset() {
*x = ListNodesResponse{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListNodesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListNodesResponse) ProtoMessage() {}
func (x *ListNodesResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead.
func (*ListNodesResponse) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{10}
}
func (x *ListNodesResponse) GetNodes() []*NodeInfo {
if x != nil {
return x.Nodes
}
return nil
}
type NodeInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"`
Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "healthy", "unhealthy", "unknown"
Containers int32 `protobuf:"varint,6,opt,name=containers,proto3" json:"containers,omitempty"`
LastHeartbeat string `protobuf:"bytes,7,opt,name=last_heartbeat,json=lastHeartbeat,proto3" json:"last_heartbeat,omitempty"` // RFC3339
Services int32 `protobuf:"varint,8,opt,name=services,proto3" json:"services,omitempty"` // placement count
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NodeInfo) Reset() {
*x = NodeInfo{}
mi := &file_proto_mcp_v1_master_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NodeInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NodeInfo) ProtoMessage() {}
func (x *NodeInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcp_v1_master_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NodeInfo.ProtoReflect.Descriptor instead.
func (*NodeInfo) Descriptor() ([]byte, []int) {
return file_proto_mcp_v1_master_proto_rawDescGZIP(), []int{11}
}
func (x *NodeInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *NodeInfo) GetRole() string {
if x != nil {
return x.Role
}
return ""
}
func (x *NodeInfo) GetAddress() string {
if x != nil {
return x.Address
}
return ""
}
func (x *NodeInfo) GetArch() string {
if x != nil {
return x.Arch
}
return ""
}
func (x *NodeInfo) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *NodeInfo) GetContainers() int32 {
if x != nil {
return x.Containers
}
return 0
}
func (x *NodeInfo) GetLastHeartbeat() string {
if x != nil {
return x.LastHeartbeat
}
return ""
}
func (x *NodeInfo) GetServices() int32 {
if x != nil {
return x.Services
}
return 0
}
var File_proto_mcp_v1_master_proto protoreflect.FileDescriptor
const file_proto_mcp_v1_master_proto_rawDesc = "" +
"\n" +
"\x19proto/mcp/v1/master.proto\x12\x06mcp.v1\x1a\x16proto/mcp/v1/mcp.proto\"D\n" +
"\x13MasterDeployRequest\x12-\n" +
"\aservice\x18\x01 \x01(\v2\x13.mcp.v1.ServiceSpecR\aservice\"\x86\x02\n" +
"\x14MasterDeployResponse\x12\x12\n" +
"\x04node\x18\x01 \x01(\tR\x04node\x12\x18\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\x127\n" +
"\rdeploy_result\x18\x04 \x01(\v2\x12.mcp.v1.StepResultR\fdeployResult\x12>\n" +
"\x11edge_route_result\x18\x05 \x01(\v2\x12.mcp.v1.StepResultR\x0fedgeRouteResult\x121\n" +
"\n" +
"dns_result\x18\x06 \x01(\v2\x12.mcp.v1.StepResultR\tdnsResult\"P\n" +
"\n" +
"StepResult\x12\x12\n" +
"\x04step\x18\x01 \x01(\tR\x04step\x12\x18\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\":\n" +
"\x15MasterUndeployRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\"H\n" +
"\x16MasterUndeployResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"8\n" +
"\x13MasterStatusRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\"I\n" +
"\x14MasterStatusResponse\x121\n" +
"\bservices\x18\x01 \x03(\v2\x15.mcp.v1.ServiceStatusR\bservices\"\x9d\x01\n" +
"\rServiceStatus\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04node\x18\x02 \x01(\tR\x04node\x12\x12\n" +
"\x04tier\x18\x03 \x01(\tR\x04tier\x12\x16\n" +
"\x06status\x18\x04 \x01(\tR\x06status\x128\n" +
"\vedge_routes\x18\x05 \x03(\v2\x17.mcp.v1.EdgeRouteStatusR\n" +
"edgeRoutes\"m\n" +
"\x0fEdgeRouteStatus\x12\x1a\n" +
"\bhostname\x18\x01 \x01(\tR\bhostname\x12\x1b\n" +
"\tedge_node\x18\x02 \x01(\tR\bedgeNode\x12!\n" +
"\fcert_expires\x18\x03 \x01(\tR\vcertExpires\"\x12\n" +
"\x10ListNodesRequest\";\n" +
"\x11ListNodesResponse\x12&\n" +
"\x05nodes\x18\x01 \x03(\v2\x10.mcp.v1.NodeInfoR\x05nodes\"\xdb\x01\n" +
"\bNodeInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\x12\x18\n" +
"\aaddress\x18\x03 \x01(\tR\aaddress\x12\x12\n" +
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12\x1e\n" +
"\n" +
"containers\x18\x06 \x01(\x05R\n" +
"containers\x12%\n" +
"\x0elast_heartbeat\x18\a \x01(\tR\rlastHeartbeat\x12\x1a\n" +
"\bservices\x18\b \x01(\x05R\bservices2\xa9\x02\n" +
"\x10McpMasterService\x12C\n" +
"\x06Deploy\x12\x1b.mcp.v1.MasterDeployRequest\x1a\x1c.mcp.v1.MasterDeployResponse\x12I\n" +
"\bUndeploy\x12\x1d.mcp.v1.MasterUndeployRequest\x1a\x1e.mcp.v1.MasterUndeployResponse\x12C\n" +
"\x06Status\x12\x1b.mcp.v1.MasterStatusRequest\x1a\x1c.mcp.v1.MasterStatusResponse\x12@\n" +
"\tListNodes\x12\x18.mcp.v1.ListNodesRequest\x1a\x19.mcp.v1.ListNodesResponseB*Z(git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1b\x06proto3"
var (
file_proto_mcp_v1_master_proto_rawDescOnce sync.Once
file_proto_mcp_v1_master_proto_rawDescData []byte
)
func file_proto_mcp_v1_master_proto_rawDescGZIP() []byte {
file_proto_mcp_v1_master_proto_rawDescOnce.Do(func() {
file_proto_mcp_v1_master_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcp_v1_master_proto_rawDesc), len(file_proto_mcp_v1_master_proto_rawDesc)))
})
return file_proto_mcp_v1_master_proto_rawDescData
}
var file_proto_mcp_v1_master_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_proto_mcp_v1_master_proto_goTypes = []any{
(*MasterDeployRequest)(nil), // 0: mcp.v1.MasterDeployRequest
(*MasterDeployResponse)(nil), // 1: mcp.v1.MasterDeployResponse
(*StepResult)(nil), // 2: mcp.v1.StepResult
(*MasterUndeployRequest)(nil), // 3: mcp.v1.MasterUndeployRequest
(*MasterUndeployResponse)(nil), // 4: mcp.v1.MasterUndeployResponse
(*MasterStatusRequest)(nil), // 5: mcp.v1.MasterStatusRequest
(*MasterStatusResponse)(nil), // 6: mcp.v1.MasterStatusResponse
(*ServiceStatus)(nil), // 7: mcp.v1.ServiceStatus
(*EdgeRouteStatus)(nil), // 8: mcp.v1.EdgeRouteStatus
(*ListNodesRequest)(nil), // 9: mcp.v1.ListNodesRequest
(*ListNodesResponse)(nil), // 10: mcp.v1.ListNodesResponse
(*NodeInfo)(nil), // 11: mcp.v1.NodeInfo
(*ServiceSpec)(nil), // 12: mcp.v1.ServiceSpec
}
var file_proto_mcp_v1_master_proto_depIdxs = []int32{
12, // 0: mcp.v1.MasterDeployRequest.service:type_name -> mcp.v1.ServiceSpec
2, // 1: mcp.v1.MasterDeployResponse.deploy_result:type_name -> mcp.v1.StepResult
2, // 2: mcp.v1.MasterDeployResponse.edge_route_result:type_name -> mcp.v1.StepResult
2, // 3: mcp.v1.MasterDeployResponse.dns_result:type_name -> mcp.v1.StepResult
7, // 4: mcp.v1.MasterStatusResponse.services:type_name -> mcp.v1.ServiceStatus
8, // 5: mcp.v1.ServiceStatus.edge_routes:type_name -> mcp.v1.EdgeRouteStatus
11, // 6: mcp.v1.ListNodesResponse.nodes:type_name -> mcp.v1.NodeInfo
0, // 7: mcp.v1.McpMasterService.Deploy:input_type -> mcp.v1.MasterDeployRequest
3, // 8: mcp.v1.McpMasterService.Undeploy:input_type -> mcp.v1.MasterUndeployRequest
5, // 9: mcp.v1.McpMasterService.Status:input_type -> mcp.v1.MasterStatusRequest
9, // 10: mcp.v1.McpMasterService.ListNodes:input_type -> mcp.v1.ListNodesRequest
1, // 11: mcp.v1.McpMasterService.Deploy:output_type -> mcp.v1.MasterDeployResponse
4, // 12: mcp.v1.McpMasterService.Undeploy:output_type -> mcp.v1.MasterUndeployResponse
6, // 13: mcp.v1.McpMasterService.Status:output_type -> mcp.v1.MasterStatusResponse
10, // 14: mcp.v1.McpMasterService.ListNodes:output_type -> mcp.v1.ListNodesResponse
11, // [11:15] is the sub-list for method output_type
7, // [7:11] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_proto_mcp_v1_master_proto_init() }
func file_proto_mcp_v1_master_proto_init() {
if File_proto_mcp_v1_master_proto != nil {
return
}
file_proto_mcp_v1_mcp_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcp_v1_master_proto_rawDesc), len(file_proto_mcp_v1_master_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcp_v1_master_proto_goTypes,
DependencyIndexes: file_proto_mcp_v1_master_proto_depIdxs,
MessageInfos: file_proto_mcp_v1_master_proto_msgTypes,
}.Build()
File_proto_mcp_v1_master_proto = out.File
file_proto_mcp_v1_master_proto_goTypes = nil
file_proto_mcp_v1_master_proto_depIdxs = nil
}

View File

@@ -0,0 +1,247 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcp/v1/master.proto
package mcpv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
McpMasterService_Deploy_FullMethodName = "/mcp.v1.McpMasterService/Deploy"
McpMasterService_Undeploy_FullMethodName = "/mcp.v1.McpMasterService/Undeploy"
McpMasterService_Status_FullMethodName = "/mcp.v1.McpMasterService/Status"
McpMasterService_ListNodes_FullMethodName = "/mcp.v1.McpMasterService/ListNodes"
)
// McpMasterServiceClient is the client API for McpMasterService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
type McpMasterServiceClient interface {
// CLI operations.
Deploy(ctx context.Context, in *MasterDeployRequest, opts ...grpc.CallOption) (*MasterDeployResponse, error)
Undeploy(ctx context.Context, in *MasterUndeployRequest, opts ...grpc.CallOption) (*MasterUndeployResponse, error)
Status(ctx context.Context, in *MasterStatusRequest, opts ...grpc.CallOption) (*MasterStatusResponse, error)
ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error)
}
type mcpMasterServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMcpMasterServiceClient(cc grpc.ClientConnInterface) McpMasterServiceClient {
return &mcpMasterServiceClient{cc}
}
func (c *mcpMasterServiceClient) Deploy(ctx context.Context, in *MasterDeployRequest, opts ...grpc.CallOption) (*MasterDeployResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterDeployResponse)
err := c.cc.Invoke(ctx, McpMasterService_Deploy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) Undeploy(ctx context.Context, in *MasterUndeployRequest, opts ...grpc.CallOption) (*MasterUndeployResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterUndeployResponse)
err := c.cc.Invoke(ctx, McpMasterService_Undeploy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) Status(ctx context.Context, in *MasterStatusRequest, opts ...grpc.CallOption) (*MasterStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MasterStatusResponse)
err := c.cc.Invoke(ctx, McpMasterService_Status_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpMasterServiceClient) ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListNodesResponse)
err := c.cc.Invoke(ctx, McpMasterService_ListNodes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// McpMasterServiceServer is the server API for McpMasterService service.
// All implementations must embed UnimplementedMcpMasterServiceServer
// for forward compatibility.
//
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
type McpMasterServiceServer interface {
// CLI operations.
Deploy(context.Context, *MasterDeployRequest) (*MasterDeployResponse, error)
Undeploy(context.Context, *MasterUndeployRequest) (*MasterUndeployResponse, error)
Status(context.Context, *MasterStatusRequest) (*MasterStatusResponse, error)
ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error)
mustEmbedUnimplementedMcpMasterServiceServer()
}
// UnimplementedMcpMasterServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMcpMasterServiceServer struct{}
func (UnimplementedMcpMasterServiceServer) Deploy(context.Context, *MasterDeployRequest) (*MasterDeployResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Deploy not implemented")
}
func (UnimplementedMcpMasterServiceServer) Undeploy(context.Context, *MasterUndeployRequest) (*MasterUndeployResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Undeploy not implemented")
}
func (UnimplementedMcpMasterServiceServer) Status(context.Context, *MasterStatusRequest) (*MasterStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Status not implemented")
}
func (UnimplementedMcpMasterServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented")
}
func (UnimplementedMcpMasterServiceServer) mustEmbedUnimplementedMcpMasterServiceServer() {}
func (UnimplementedMcpMasterServiceServer) testEmbeddedByValue() {}
// UnsafeMcpMasterServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to McpMasterServiceServer will
// result in compilation errors.
type UnsafeMcpMasterServiceServer interface {
mustEmbedUnimplementedMcpMasterServiceServer()
}
func RegisterMcpMasterServiceServer(s grpc.ServiceRegistrar, srv McpMasterServiceServer) {
// If the following call panics, it indicates UnimplementedMcpMasterServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&McpMasterService_ServiceDesc, srv)
}
func _McpMasterService_Deploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterDeployRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Deploy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Deploy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Deploy(ctx, req.(*MasterDeployRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_Undeploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterUndeployRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Undeploy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Undeploy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Undeploy(ctx, req.(*MasterUndeployRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MasterStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).Status(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_Status_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).Status(ctx, req.(*MasterStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpMasterService_ListNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListNodesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpMasterServiceServer).ListNodes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpMasterService_ListNodes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpMasterServiceServer).ListNodes(ctx, req.(*ListNodesRequest))
}
return interceptor(ctx, in, info, handler)
}
// McpMasterService_ServiceDesc is the grpc.ServiceDesc for McpMasterService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var McpMasterService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcp.v1.McpMasterService",
HandlerType: (*McpMasterServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Deploy",
Handler: _McpMasterService_Deploy_Handler,
},
{
MethodName: "Undeploy",
Handler: _McpMasterService_Undeploy_Handler,
},
{
MethodName: "Status",
Handler: _McpMasterService_Status_Handler,
},
{
MethodName: "ListNodes",
Handler: _McpMasterService_ListNodes_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcp/v1/master.proto",
}

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,10 @@ const (
McpAgentService_ListProxyRoutes_FullMethodName = "/mcp.v1.McpAgentService/ListProxyRoutes"
McpAgentService_AddProxyRoute_FullMethodName = "/mcp.v1.McpAgentService/AddProxyRoute"
McpAgentService_RemoveProxyRoute_FullMethodName = "/mcp.v1.McpAgentService/RemoveProxyRoute"
McpAgentService_SetupEdgeRoute_FullMethodName = "/mcp.v1.McpAgentService/SetupEdgeRoute"
McpAgentService_RemoveEdgeRoute_FullMethodName = "/mcp.v1.McpAgentService/RemoveEdgeRoute"
McpAgentService_ListEdgeRoutes_FullMethodName = "/mcp.v1.McpAgentService/ListEdgeRoutes"
McpAgentService_HealthCheck_FullMethodName = "/mcp.v1.McpAgentService/HealthCheck"
McpAgentService_Logs_FullMethodName = "/mcp.v1.McpAgentService/Logs"
)
@@ -71,6 +75,12 @@ type McpAgentServiceClient interface {
ListProxyRoutes(ctx context.Context, in *ListProxyRoutesRequest, opts ...grpc.CallOption) (*ListProxyRoutesResponse, error)
AddProxyRoute(ctx context.Context, in *AddProxyRouteRequest, opts ...grpc.CallOption) (*AddProxyRouteResponse, error)
RemoveProxyRoute(ctx context.Context, in *RemoveProxyRouteRequest, opts ...grpc.CallOption) (*RemoveProxyRouteResponse, error)
// Edge routing (called by master on edge nodes)
SetupEdgeRoute(ctx context.Context, in *SetupEdgeRouteRequest, opts ...grpc.CallOption) (*SetupEdgeRouteResponse, error)
RemoveEdgeRoute(ctx context.Context, in *RemoveEdgeRouteRequest, opts ...grpc.CallOption) (*RemoveEdgeRouteResponse, error)
ListEdgeRoutes(ctx context.Context, in *ListEdgeRoutesRequest, opts ...grpc.CallOption) (*ListEdgeRoutesResponse, error)
// Health (called by master on missed heartbeats)
HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error)
// Logs
Logs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogsResponse], error)
}
@@ -263,6 +273,46 @@ func (c *mcpAgentServiceClient) RemoveProxyRoute(ctx context.Context, in *Remove
return out, nil
}
func (c *mcpAgentServiceClient) SetupEdgeRoute(ctx context.Context, in *SetupEdgeRouteRequest, opts ...grpc.CallOption) (*SetupEdgeRouteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetupEdgeRouteResponse)
err := c.cc.Invoke(ctx, McpAgentService_SetupEdgeRoute_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpAgentServiceClient) RemoveEdgeRoute(ctx context.Context, in *RemoveEdgeRouteRequest, opts ...grpc.CallOption) (*RemoveEdgeRouteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveEdgeRouteResponse)
err := c.cc.Invoke(ctx, McpAgentService_RemoveEdgeRoute_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpAgentServiceClient) ListEdgeRoutes(ctx context.Context, in *ListEdgeRoutesRequest, opts ...grpc.CallOption) (*ListEdgeRoutesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListEdgeRoutesResponse)
err := c.cc.Invoke(ctx, McpAgentService_ListEdgeRoutes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpAgentServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthCheckResponse)
err := c.cc.Invoke(ctx, McpAgentService_HealthCheck_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpAgentServiceClient) Logs(ctx context.Context, in *LogsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[LogsResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &McpAgentService_ServiceDesc.Streams[0], McpAgentService_Logs_FullMethodName, cOpts...)
@@ -313,6 +363,12 @@ type McpAgentServiceServer interface {
ListProxyRoutes(context.Context, *ListProxyRoutesRequest) (*ListProxyRoutesResponse, error)
AddProxyRoute(context.Context, *AddProxyRouteRequest) (*AddProxyRouteResponse, error)
RemoveProxyRoute(context.Context, *RemoveProxyRouteRequest) (*RemoveProxyRouteResponse, error)
// Edge routing (called by master on edge nodes)
SetupEdgeRoute(context.Context, *SetupEdgeRouteRequest) (*SetupEdgeRouteResponse, error)
RemoveEdgeRoute(context.Context, *RemoveEdgeRouteRequest) (*RemoveEdgeRouteResponse, error)
ListEdgeRoutes(context.Context, *ListEdgeRoutesRequest) (*ListEdgeRoutesResponse, error)
// Health (called by master on missed heartbeats)
HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
// Logs
Logs(*LogsRequest, grpc.ServerStreamingServer[LogsResponse]) error
mustEmbedUnimplementedMcpAgentServiceServer()
@@ -379,6 +435,18 @@ func (UnimplementedMcpAgentServiceServer) AddProxyRoute(context.Context, *AddPro
func (UnimplementedMcpAgentServiceServer) RemoveProxyRoute(context.Context, *RemoveProxyRouteRequest) (*RemoveProxyRouteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveProxyRoute not implemented")
}
func (UnimplementedMcpAgentServiceServer) SetupEdgeRoute(context.Context, *SetupEdgeRouteRequest) (*SetupEdgeRouteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetupEdgeRoute not implemented")
}
func (UnimplementedMcpAgentServiceServer) RemoveEdgeRoute(context.Context, *RemoveEdgeRouteRequest) (*RemoveEdgeRouteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveEdgeRoute not implemented")
}
func (UnimplementedMcpAgentServiceServer) ListEdgeRoutes(context.Context, *ListEdgeRoutesRequest) (*ListEdgeRoutesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListEdgeRoutes not implemented")
}
func (UnimplementedMcpAgentServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) {
return nil, status.Error(codes.Unimplemented, "method HealthCheck not implemented")
}
func (UnimplementedMcpAgentServiceServer) Logs(*LogsRequest, grpc.ServerStreamingServer[LogsResponse]) error {
return status.Error(codes.Unimplemented, "method Logs not implemented")
}
@@ -727,6 +795,78 @@ func _McpAgentService_RemoveProxyRoute_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_SetupEdgeRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetupEdgeRouteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpAgentServiceServer).SetupEdgeRoute(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpAgentService_SetupEdgeRoute_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpAgentServiceServer).SetupEdgeRoute(ctx, req.(*SetupEdgeRouteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_RemoveEdgeRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveEdgeRouteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpAgentServiceServer).RemoveEdgeRoute(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpAgentService_RemoveEdgeRoute_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpAgentServiceServer).RemoveEdgeRoute(ctx, req.(*RemoveEdgeRouteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_ListEdgeRoutes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListEdgeRoutesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpAgentServiceServer).ListEdgeRoutes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpAgentService_ListEdgeRoutes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpAgentServiceServer).ListEdgeRoutes(ctx, req.(*ListEdgeRoutesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthCheckRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpAgentServiceServer).HealthCheck(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpAgentService_HealthCheck_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpAgentServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_Logs_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(LogsRequest)
if err := stream.RecvMsg(m); err != nil {
@@ -817,6 +957,22 @@ var McpAgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RemoveProxyRoute",
Handler: _McpAgentService_RemoveProxyRoute_Handler,
},
{
MethodName: "SetupEdgeRoute",
Handler: _McpAgentService_SetupEdgeRoute_Handler,
},
{
MethodName: "RemoveEdgeRoute",
Handler: _McpAgentService_RemoveEdgeRoute_Handler,
},
{
MethodName: "ListEdgeRoutes",
Handler: _McpAgentService_ListEdgeRoutes_Handler,
},
{
MethodName: "HealthCheck",
Handler: _McpAgentService_HealthCheck_Handler,
},
},
Streams: []grpc.StreamDesc{
{

196
internal/agent/edge_rpc.go Normal file
View File

@@ -0,0 +1,196 @@
package agent
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
mcproxy "git.wntrmute.dev/mc/mc-proxy/client/mcproxy"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
// SetupEdgeRoute provisions a TLS cert and registers an mc-proxy route for a
// public hostname. Called by the master on edge nodes.
func (a *Agent) SetupEdgeRoute(ctx context.Context, req *mcpv1.SetupEdgeRouteRequest) (*mcpv1.SetupEdgeRouteResponse, error) {
a.Logger.Info("SetupEdgeRoute", "hostname", req.GetHostname(),
"backend_hostname", req.GetBackendHostname(), "backend_port", req.GetBackendPort())
// Validate required fields.
if req.GetHostname() == "" {
return nil, status.Error(codes.InvalidArgument, "hostname is required")
}
if req.GetBackendHostname() == "" {
return nil, status.Error(codes.InvalidArgument, "backend_hostname is required")
}
if req.GetBackendPort() == 0 {
return nil, status.Error(codes.InvalidArgument, "backend_port is required")
}
if !req.GetBackendTls() {
return nil, status.Error(codes.InvalidArgument, "backend_tls must be true")
}
if a.Proxy == nil {
return nil, status.Error(codes.FailedPrecondition, "mc-proxy not configured")
}
// Resolve the backend hostname to a Tailnet IP.
ips, err := net.LookupHost(req.GetBackendHostname())
if err != nil || len(ips) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "cannot resolve backend_hostname %q: %v", req.GetBackendHostname(), err)
}
backendIP := ips[0]
// Validate the resolved IP is a Tailnet address (100.64.0.0/10).
ip := net.ParseIP(backendIP)
if ip == nil {
return nil, status.Errorf(codes.InvalidArgument, "resolved IP %q is not valid", backendIP)
}
_, tailnet, _ := net.ParseCIDR("100.64.0.0/10")
if !tailnet.Contains(ip) {
return nil, status.Errorf(codes.InvalidArgument, "resolved IP %s is not a Tailnet address", backendIP)
}
backend := fmt.Sprintf("%s:%d", backendIP, req.GetBackendPort())
// Provision TLS cert for the public hostname if cert provisioner is available.
certPath := ""
keyPath := ""
if a.Certs != nil {
if err := a.Certs.EnsureCert(ctx, req.GetHostname(), []string{req.GetHostname()}); err != nil {
return nil, status.Errorf(codes.Internal, "provision cert for %s: %v", req.GetHostname(), err)
}
certPath = a.Proxy.CertPath(req.GetHostname())
keyPath = a.Proxy.KeyPath(req.GetHostname())
} else {
// No cert provisioner — check if certs already exist on disk.
certPath = a.Proxy.CertPath(req.GetHostname())
keyPath = a.Proxy.KeyPath(req.GetHostname())
if _, err := os.Stat(certPath); err != nil {
return nil, status.Errorf(codes.FailedPrecondition, "no cert provisioner and cert not found at %s", certPath)
}
}
// Register the L7 route in mc-proxy.
route := mcproxy.Route{
Hostname: req.GetHostname(),
Backend: backend,
Mode: "l7",
TLSCert: certPath,
TLSKey: keyPath,
BackendTLS: true,
}
if err := a.Proxy.AddRoute(ctx, ":443", route); err != nil {
return nil, status.Errorf(codes.Internal, "add mc-proxy route: %v", err)
}
// Persist the edge route in the registry.
if err := registry.CreateEdgeRoute(a.DB, req.GetHostname(), req.GetBackendHostname(), int(req.GetBackendPort()), certPath, keyPath); err != nil {
a.Logger.Warn("failed to persist edge route", "hostname", req.GetHostname(), "err", err)
}
a.Logger.Info("edge route established",
"hostname", req.GetHostname(), "backend", backend, "cert", certPath)
return &mcpv1.SetupEdgeRouteResponse{}, nil
}
// RemoveEdgeRoute removes an mc-proxy route and cleans up the TLS cert for a
// public hostname. Called by the master on edge nodes.
func (a *Agent) RemoveEdgeRoute(ctx context.Context, req *mcpv1.RemoveEdgeRouteRequest) (*mcpv1.RemoveEdgeRouteResponse, error) {
a.Logger.Info("RemoveEdgeRoute", "hostname", req.GetHostname())
if req.GetHostname() == "" {
return nil, status.Error(codes.InvalidArgument, "hostname is required")
}
if a.Proxy == nil {
return nil, status.Error(codes.FailedPrecondition, "mc-proxy not configured")
}
// Remove the mc-proxy route.
if err := a.Proxy.RemoveRoute(ctx, ":443", req.GetHostname()); err != nil {
a.Logger.Warn("remove mc-proxy route", "hostname", req.GetHostname(), "err", err)
// Continue — clean up cert and registry even if route removal fails.
}
// Remove the TLS cert.
if a.Certs != nil {
if err := a.Certs.RemoveCert(req.GetHostname()); err != nil {
a.Logger.Warn("remove cert", "hostname", req.GetHostname(), "err", err)
}
}
// Remove from registry.
if err := registry.DeleteEdgeRoute(a.DB, req.GetHostname()); err != nil {
a.Logger.Warn("delete edge route from registry", "hostname", req.GetHostname(), "err", err)
}
a.Logger.Info("edge route removed", "hostname", req.GetHostname())
return &mcpv1.RemoveEdgeRouteResponse{}, nil
}
// ListEdgeRoutes returns all edge routes managed by this agent.
func (a *Agent) ListEdgeRoutes(_ context.Context, _ *mcpv1.ListEdgeRoutesRequest) (*mcpv1.ListEdgeRoutesResponse, error) {
a.Logger.Debug("ListEdgeRoutes called")
routes, err := registry.ListEdgeRoutes(a.DB)
if err != nil {
return nil, status.Errorf(codes.Internal, "list edge routes: %v", err)
}
resp := &mcpv1.ListEdgeRoutesResponse{}
for _, r := range routes {
er := &mcpv1.EdgeRoute{
Hostname: r.Hostname,
BackendHostname: r.BackendHostname,
BackendPort: int32(r.BackendPort), //nolint:gosec // port is a small positive integer
}
// Read cert metadata if available.
if r.TLSCert != "" {
if certData, readErr := os.ReadFile(r.TLSCert); readErr == nil { //nolint:gosec // path from registry, not user input
if block, _ := pem.Decode(certData); block != nil {
if cert, parseErr := x509.ParseCertificate(block.Bytes); parseErr == nil {
er.CertSerial = cert.SerialNumber.String()
er.CertExpires = cert.NotAfter.UTC().Format(time.RFC3339)
}
}
}
}
resp.Routes = append(resp.Routes, er)
}
return resp, nil
}
// HealthCheck returns the agent's health status. Called by the master when
// heartbeats are missed.
func (a *Agent) HealthCheck(_ context.Context, _ *mcpv1.HealthCheckRequest) (*mcpv1.HealthCheckResponse, error) {
a.Logger.Debug("HealthCheck called")
st := "healthy"
containers := int32(0)
// Count running containers if the runtime is available.
if a.Runtime != nil {
if list, err := a.Runtime.List(context.Background()); err == nil {
containers = int32(len(list)) //nolint:gosec // container count is small
} else {
st = "degraded"
}
}
return &mcpv1.HealthCheckResponse{
Status: st,
Containers: containers,
}, nil
}

View File

@@ -48,6 +48,16 @@ func (p *ProxyRouter) Close() error {
return p.client.Close()
}
// CertPath returns the expected TLS certificate path for a given name.
func (p *ProxyRouter) CertPath(name string) string {
return filepath.Join(p.certDir, name+".pem")
}
// KeyPath returns the expected TLS key path for a given name.
func (p *ProxyRouter) KeyPath(name string) string {
return filepath.Join(p.certDir, name+".key")
}
// GetStatus returns the mc-proxy server status.
func (p *ProxyRouter) GetStatus(ctx context.Context) (*mcproxy.Status, error) {
if p == nil {

View File

@@ -10,11 +10,19 @@ import (
// CLIConfig is the configuration for the mcp CLI binary.
type CLIConfig struct {
Services ServicesConfig `toml:"services"`
Build BuildConfig `toml:"build"`
MCIAS MCIASConfig `toml:"mcias"`
Auth AuthConfig `toml:"auth"`
Nodes []NodeConfig `toml:"nodes"`
Services ServicesConfig `toml:"services"`
Build BuildConfig `toml:"build"`
MCIAS MCIASConfig `toml:"mcias"`
Auth AuthConfig `toml:"auth"`
Nodes []NodeConfig `toml:"nodes"`
Master *CLIMasterConfig `toml:"master,omitempty"`
}
// CLIMasterConfig holds the optional master connection settings.
// When configured, deploy/undeploy/status go through the master
// instead of directly to agents.
type CLIMasterConfig struct {
Address string `toml:"address"` // master gRPC address (e.g. "100.95.252.120:9555")
}
// BuildConfig holds settings for building container images.
@@ -42,9 +50,28 @@ type AuthConfig struct {
}
// NodeConfig defines a managed node that the CLI connects to.
// Address is the primary address. Addresses is an optional list of
// fallback addresses tried in order if the primary fails. This
// provides resilience when Tailscale DNS is down or a node is
// reachable via LAN but not Tailnet.
type NodeConfig struct {
Name string `toml:"name"`
Address string `toml:"address"`
Name string `toml:"name"`
Address string `toml:"address"`
Addresses []string `toml:"addresses,omitempty"`
}
// AllAddresses returns the node's primary address followed by any
// fallback addresses, deduplicated.
func (n NodeConfig) AllAddresses() []string {
seen := make(map[string]bool)
var addrs []string
for _, a := range append([]string{n.Address}, n.Addresses...) {
if a != "" && !seen[a] {
seen[a] = true
addrs = append(addrs, a)
}
}
return addrs
}
// LoadCLIConfig reads and validates a CLI configuration file.

183
internal/config/master.go Normal file
View File

@@ -0,0 +1,183 @@
package config
import (
"fmt"
"os"
"time"
toml "github.com/pelletier/go-toml/v2"
)
// MasterConfig is the configuration for the mcp-master daemon.
type MasterConfig struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"`
Edge EdgeConfig `toml:"edge"`
Registration RegistrationConfig `toml:"registration"`
Timeouts TimeoutsConfig `toml:"timeouts"`
MCNS MCNSConfig `toml:"mcns"`
Log LogConfig `toml:"log"`
Nodes []MasterNodeConfig `toml:"nodes"`
// Master holds the master's own MCIAS service token for dialing agents.
Master MasterSettings `toml:"master"`
}
// MasterSettings holds settings specific to the master's own identity.
type MasterSettings struct {
// ServiceTokenPath is the path to the MCIAS service token file
// used by the master to authenticate to agents.
ServiceTokenPath string `toml:"service_token_path"`
// CACert is the path to the CA certificate for verifying agent TLS.
CACert string `toml:"ca_cert"`
}
// EdgeConfig holds settings for edge route management.
type EdgeConfig struct {
// AllowedDomains is the list of domains that public hostnames
// must fall under. Validation uses proper domain label matching.
AllowedDomains []string `toml:"allowed_domains"`
}
// RegistrationConfig holds agent registration settings.
type RegistrationConfig struct {
// AllowedAgents is the list of MCIAS service identities permitted
// to register with the master (e.g., "agent-rift", "agent-svc").
AllowedAgents []string `toml:"allowed_agents"`
// MaxNodes is the maximum number of registered nodes.
MaxNodes int `toml:"max_nodes"`
}
// TimeoutsConfig holds timeout durations for master operations.
type TimeoutsConfig struct {
Deploy Duration `toml:"deploy"`
EdgeRoute Duration `toml:"edge_route"`
HealthCheck Duration `toml:"health_check"`
Undeploy Duration `toml:"undeploy"`
Snapshot Duration `toml:"snapshot"`
}
// MasterNodeConfig is a bootstrap node entry in the master config.
type MasterNodeConfig struct {
Name string `toml:"name"`
Address string `toml:"address"`
Addresses []string `toml:"addresses,omitempty"`
Role string `toml:"role"` // "worker", "edge", or "master"
}
// AllAddresses returns the node's primary address followed by any
// fallback addresses, deduplicated.
func (n MasterNodeConfig) AllAddresses() []string {
seen := make(map[string]bool)
var addrs []string
for _, a := range append([]string{n.Address}, n.Addresses...) {
if a != "" && !seen[a] {
seen[a] = true
addrs = append(addrs, a)
}
}
return addrs
}
// LoadMasterConfig reads and validates a master configuration file.
func LoadMasterConfig(path string) (*MasterConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // config path from trusted CLI flag
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
var cfg MasterConfig
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
applyMasterDefaults(&cfg)
applyMasterEnvOverrides(&cfg)
if err := validateMasterConfig(&cfg); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
func applyMasterDefaults(cfg *MasterConfig) {
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Registration.MaxNodes == 0 {
cfg.Registration.MaxNodes = 16
}
if cfg.Timeouts.Deploy.Duration == 0 {
cfg.Timeouts.Deploy.Duration = 5 * time.Minute
}
if cfg.Timeouts.EdgeRoute.Duration == 0 {
cfg.Timeouts.EdgeRoute.Duration = 30 * time.Second
}
if cfg.Timeouts.HealthCheck.Duration == 0 {
cfg.Timeouts.HealthCheck.Duration = 5 * time.Second
}
if cfg.Timeouts.Undeploy.Duration == 0 {
cfg.Timeouts.Undeploy.Duration = 2 * time.Minute
}
if cfg.Timeouts.Snapshot.Duration == 0 {
cfg.Timeouts.Snapshot.Duration = 10 * time.Minute
}
if cfg.MCNS.Zone == "" {
cfg.MCNS.Zone = "svc.mcp.metacircular.net"
}
for i := range cfg.Nodes {
if cfg.Nodes[i].Role == "" {
cfg.Nodes[i].Role = "worker"
}
}
}
func applyMasterEnvOverrides(cfg *MasterConfig) {
if v := os.Getenv("MCP_MASTER_SERVER_GRPC_ADDR"); v != "" {
cfg.Server.GRPCAddr = v
}
if v := os.Getenv("MCP_MASTER_SERVER_TLS_CERT"); v != "" {
cfg.Server.TLSCert = v
}
if v := os.Getenv("MCP_MASTER_SERVER_TLS_KEY"); v != "" {
cfg.Server.TLSKey = v
}
if v := os.Getenv("MCP_MASTER_DATABASE_PATH"); v != "" {
cfg.Database.Path = v
}
if v := os.Getenv("MCP_MASTER_LOG_LEVEL"); v != "" {
cfg.Log.Level = v
}
}
func validateMasterConfig(cfg *MasterConfig) error {
if cfg.Server.GRPCAddr == "" {
return fmt.Errorf("server.grpc_addr is required")
}
if cfg.Server.TLSCert == "" {
return fmt.Errorf("server.tls_cert is required")
}
if cfg.Server.TLSKey == "" {
return fmt.Errorf("server.tls_key is required")
}
if cfg.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
if cfg.MCIAS.ServerURL == "" {
return fmt.Errorf("mcias.server_url is required")
}
if cfg.MCIAS.ServiceName == "" {
return fmt.Errorf("mcias.service_name is required")
}
if len(cfg.Nodes) == 0 {
return fmt.Errorf("at least one [[nodes]] entry is required")
}
if cfg.Master.ServiceTokenPath == "" {
return fmt.Errorf("master.service_token_path is required")
}
return nil
}

View File

@@ -0,0 +1,199 @@
// Package master implements the mcp-master orchestrator.
package master
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"sync"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
// AgentClient wraps a gRPC connection to a single mcp-agent.
type AgentClient struct {
conn *grpc.ClientConn
client mcpv1.McpAgentServiceClient
Node string
}
// DialAgent connects to an agent at the given address using TLS 1.3.
// The token is attached to every outgoing RPC via metadata.
func DialAgent(address, caCertPath, token string) (*AgentClient, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if caCertPath != "" {
caCert, err := os.ReadFile(caCertPath) //nolint:gosec // trusted config path
if err != nil {
return nil, fmt.Errorf("read CA cert %q: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("invalid CA cert %q", caCertPath)
}
tlsConfig.RootCAs = pool
}
conn, err := grpc.NewClient(
address,
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
grpc.WithUnaryInterceptor(agentTokenInterceptor(token)),
grpc.WithStreamInterceptor(agentStreamTokenInterceptor(token)),
)
if err != nil {
return nil, fmt.Errorf("dial agent %q: %w", address, err)
}
return &AgentClient{
conn: conn,
client: mcpv1.NewMcpAgentServiceClient(conn),
}, nil
}
// Close closes the underlying gRPC connection.
func (c *AgentClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
// Deploy forwards a deploy request to the agent.
func (c *AgentClient) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.DeployResponse, error) {
return c.client.Deploy(ctx, req)
}
// UndeployService forwards an undeploy request to the agent.
func (c *AgentClient) UndeployService(ctx context.Context, req *mcpv1.UndeployServiceRequest) (*mcpv1.UndeployServiceResponse, error) {
return c.client.UndeployService(ctx, req)
}
// GetServiceStatus queries a service's status on the agent.
func (c *AgentClient) GetServiceStatus(ctx context.Context, req *mcpv1.GetServiceStatusRequest) (*mcpv1.GetServiceStatusResponse, error) {
return c.client.GetServiceStatus(ctx, req)
}
// ListServices lists all services on the agent.
func (c *AgentClient) ListServices(ctx context.Context, req *mcpv1.ListServicesRequest) (*mcpv1.ListServicesResponse, error) {
return c.client.ListServices(ctx, req)
}
// SetupEdgeRoute sets up an edge route on the agent.
func (c *AgentClient) SetupEdgeRoute(ctx context.Context, req *mcpv1.SetupEdgeRouteRequest) (*mcpv1.SetupEdgeRouteResponse, error) {
return c.client.SetupEdgeRoute(ctx, req)
}
// RemoveEdgeRoute removes an edge route from the agent.
func (c *AgentClient) RemoveEdgeRoute(ctx context.Context, req *mcpv1.RemoveEdgeRouteRequest) (*mcpv1.RemoveEdgeRouteResponse, error) {
return c.client.RemoveEdgeRoute(ctx, req)
}
// ListEdgeRoutes lists edge routes on the agent.
func (c *AgentClient) ListEdgeRoutes(ctx context.Context, req *mcpv1.ListEdgeRoutesRequest) (*mcpv1.ListEdgeRoutesResponse, error) {
return c.client.ListEdgeRoutes(ctx, req)
}
// HealthCheck checks the agent's health.
func (c *AgentClient) HealthCheck(ctx context.Context, req *mcpv1.HealthCheckRequest) (*mcpv1.HealthCheckResponse, error) {
return c.client.HealthCheck(ctx, req)
}
// agentTokenInterceptor attaches the bearer token to outgoing RPCs.
func agentTokenInterceptor(token string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func agentStreamTokenInterceptor(token string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
return streamer(ctx, desc, cc, method, opts...)
}
}
// AgentPool manages connections to multiple agents, keyed by node name.
type AgentPool struct {
mu sync.RWMutex
clients map[string]*AgentClient
caCert string
token string
}
// NewAgentPool creates a pool with the given CA cert and service token.
func NewAgentPool(caCertPath, token string) *AgentPool {
return &AgentPool{
clients: make(map[string]*AgentClient),
caCert: caCertPath,
token: token,
}
}
// AddNode dials an agent and adds it to the pool.
func (p *AgentPool) AddNode(name, address string) error {
return p.AddNodeMulti(name, []string{address})
}
// AddNodeMulti tries each address in order and adds the first successful
// connection to the pool.
func (p *AgentPool) AddNodeMulti(name string, addresses []string) error {
var lastErr error
for _, addr := range addresses {
client, err := DialAgent(addr, p.caCert, p.token)
if err != nil {
lastErr = fmt.Errorf("%s: %w", addr, err)
continue
}
client.Node = name
p.mu.Lock()
if old, ok := p.clients[name]; ok {
_ = old.Close()
}
p.clients[name] = client
p.mu.Unlock()
return nil
}
return fmt.Errorf("add node %s: all addresses failed: %w", name, lastErr)
}
// Get returns the agent client for a node.
func (p *AgentPool) Get(name string) (*AgentClient, error) {
p.mu.RLock()
defer p.mu.RUnlock()
client, ok := p.clients[name]
if !ok {
return nil, fmt.Errorf("node %q not found in pool", name)
}
return client, nil
}
// Close closes all agent connections.
func (p *AgentPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, c := range p.clients {
_ = c.Close()
}
p.clients = make(map[string]*AgentClient)
}
// LoadServiceToken reads a token from a file path.
func LoadServiceToken(path string) (string, error) {
data, err := os.ReadFile(path) //nolint:gosec // trusted config path
if err != nil {
return "", fmt.Errorf("read service token %q: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}

222
internal/master/deploy.go Normal file
View File

@@ -0,0 +1,222 @@
package master
import (
"context"
"fmt"
"net"
"strings"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Deploy handles the MasterDeployRequest: places the service, forwards to
// the agent, registers DNS, and coordinates edge routing.
func (m *Master) Deploy(ctx context.Context, req *mcpv1.MasterDeployRequest) (*mcpv1.MasterDeployResponse, error) {
spec := req.GetService()
if spec == nil || spec.GetName() == "" {
return nil, fmt.Errorf("service spec with name is required")
}
serviceName := spec.GetName()
tier := spec.GetTier()
if tier == "" {
tier = "worker"
}
m.Logger.Info("Deploy", "service", serviceName, "tier", tier, "node_override", spec.GetNode())
resp := &mcpv1.MasterDeployResponse{}
// Step 1: Place service.
nodeName := spec.GetNode()
if nodeName == "" {
var err error
switch tier {
case "core":
nodeName, err = FindMasterNode(m.DB)
default:
nodeName, err = PickNode(m.DB)
}
if err != nil {
resp.Error = fmt.Sprintf("placement failed: %v", err)
return resp, nil
}
}
resp.Node = nodeName
node, err := masterdb.GetNode(m.DB, nodeName)
if err != nil || node == nil {
resp.Error = fmt.Sprintf("node %q not found", nodeName)
return resp, nil
}
// Resolve the node's address to an IP for DNS registration.
// Node addresses may be Tailscale DNS names (e.g., rift.scylla-hammerhead.ts.net:9444)
// but MCNS needs an IP address for A records.
nodeHost, _, err := net.SplitHostPort(node.Address)
if err != nil {
resp.Error = fmt.Sprintf("invalid node address %q: %v", node.Address, err)
return resp, nil
}
// If nodeHost is not an IP, resolve it.
if net.ParseIP(nodeHost) == nil {
ips, lookupErr := net.LookupHost(nodeHost)
if lookupErr != nil || len(ips) == 0 {
m.Logger.Warn("cannot resolve node address", "host", nodeHost, "err", lookupErr)
} else {
nodeHost = ips[0]
}
}
// Step 2: Forward deploy to the agent.
client, err := m.Pool.Get(nodeName)
if err != nil {
resp.Error = fmt.Sprintf("agent connection: %v", err)
return resp, nil
}
deployCtx, deployCancel := context.WithTimeout(ctx, m.Config.Timeouts.Deploy.Duration)
defer deployCancel()
deployResp, err := client.Deploy(deployCtx, &mcpv1.DeployRequest{
Service: spec,
})
if err != nil {
resp.DeployResult = &mcpv1.StepResult{Step: "deploy", Error: err.Error()}
resp.Error = fmt.Sprintf("agent deploy failed: %v", err)
return resp, nil
}
resp.DeployResult = &mcpv1.StepResult{Step: "deploy", Success: true}
// Check agent-side results for failures.
for _, cr := range deployResp.GetResults() {
if !cr.GetSuccess() {
resp.DeployResult.Success = false
resp.DeployResult.Error = fmt.Sprintf("component %s: %s", cr.GetName(), cr.GetError())
resp.Error = resp.DeployResult.Error
return resp, nil
}
}
// Step 3: Register DNS — Tailnet IP from the node address.
if m.DNS != nil {
if err := m.DNS.EnsureRecord(ctx, serviceName, nodeHost); err != nil {
m.Logger.Warn("DNS registration failed", "service", serviceName, "err", err)
resp.DnsResult = &mcpv1.StepResult{Step: "dns", Error: err.Error()}
} else {
resp.DnsResult = &mcpv1.StepResult{Step: "dns", Success: true}
}
}
// Record placement.
if err := masterdb.CreatePlacement(m.DB, serviceName, nodeName, tier); err != nil {
m.Logger.Error("record placement", "service", serviceName, "err", err)
}
// Steps 4-9: Detect public routes and coordinate edge routing.
edgeResult := m.setupEdgeRoutes(ctx, spec, serviceName, nodeHost)
if edgeResult != nil {
resp.EdgeRouteResult = edgeResult
}
// Compute overall success.
resp.Success = true
if resp.DeployResult != nil && !resp.DeployResult.Success {
resp.Success = false
}
if resp.EdgeRouteResult != nil && !resp.EdgeRouteResult.Success {
resp.Success = false
}
m.Logger.Info("deploy complete", "service", serviceName, "node", nodeName, "success", resp.Success)
return resp, nil
}
// setupEdgeRoutes detects public routes and coordinates edge routing.
func (m *Master) setupEdgeRoutes(ctx context.Context, spec *mcpv1.ServiceSpec, serviceName, nodeHost string) *mcpv1.StepResult {
var publicRoutes []*mcpv1.RouteSpec
for _, comp := range spec.GetComponents() {
for _, route := range comp.GetRoutes() {
if route.GetPublic() && route.GetHostname() != "" {
publicRoutes = append(publicRoutes, route)
}
}
}
if len(publicRoutes) == 0 {
return nil
}
// Find the edge node.
edgeNodeName, err := FindEdgeNode(m.DB)
if err != nil {
return &mcpv1.StepResult{Step: "edge_route", Error: fmt.Sprintf("no edge node: %v", err)}
}
edgeClient, err := m.Pool.Get(edgeNodeName)
if err != nil {
return &mcpv1.StepResult{Step: "edge_route", Error: fmt.Sprintf("edge agent connection: %v", err)}
}
var lastErr string
for _, route := range publicRoutes {
hostname := route.GetHostname()
// Validate hostname against allowed domains.
if !m.isAllowedDomain(hostname) {
lastErr = fmt.Sprintf("hostname %q not under an allowed domain", hostname)
m.Logger.Warn("edge route rejected", "hostname", hostname, "reason", lastErr)
continue
}
// Construct the backend hostname: <component>.svc.mcp.<zone>
// For simplicity, use the service name as the component name.
zone := "metacircular.net"
if m.DNS != nil && m.DNS.Zone() != "" {
zone = m.DNS.Zone()
}
backendHostname := serviceName + "." + zone
edgeCtx, edgeCancel := context.WithTimeout(ctx, m.Config.Timeouts.EdgeRoute.Duration)
_, setupErr := edgeClient.SetupEdgeRoute(edgeCtx, &mcpv1.SetupEdgeRouteRequest{
Hostname: hostname,
BackendHostname: backendHostname,
BackendPort: route.GetPort(),
BackendTls: true,
})
edgeCancel()
if setupErr != nil {
lastErr = fmt.Sprintf("setup edge route %s: %v", hostname, setupErr)
m.Logger.Warn("edge route setup failed", "hostname", hostname, "err", setupErr)
continue
}
// Record edge route in master DB.
if dbErr := masterdb.CreateEdgeRoute(m.DB, hostname, serviceName, edgeNodeName, backendHostname, int(route.GetPort())); dbErr != nil {
m.Logger.Warn("record edge route", "hostname", hostname, "err", dbErr)
}
m.Logger.Info("edge route established", "hostname", hostname, "edge_node", edgeNodeName)
}
if lastErr != "" {
return &mcpv1.StepResult{Step: "edge_route", Error: lastErr}
}
return &mcpv1.StepResult{Step: "edge_route", Success: true}
}
// isAllowedDomain checks if hostname falls under one of the configured
// allowed domains using proper domain label matching.
func (m *Master) isAllowedDomain(hostname string) bool {
if len(m.Config.Edge.AllowedDomains) == 0 {
return true // no restrictions configured
}
for _, domain := range m.Config.Edge.AllowedDomains {
if hostname == domain || strings.HasSuffix(hostname, "."+domain) {
return true
}
}
return false
}

252
internal/master/dns.go Normal file
View File

@@ -0,0 +1,252 @@
package master
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// DNSClient creates and removes A records in MCNS. Unlike the agent's
// DNSRegistrar, the master registers records for different node IPs
// (the nodeAddr is a per-call parameter, not a fixed config value).
type DNSClient struct {
serverURL string
token string
zone string
httpClient *http.Client
logger *slog.Logger
}
type dnsRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
Type string `json:"Type"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
// NewDNSClient creates a DNS client. Returns (nil, nil) if serverURL is empty.
func NewDNSClient(cfg config.MCNSConfig, logger *slog.Logger) (*DNSClient, error) {
if cfg.ServerURL == "" {
logger.Info("mcns not configured, DNS registration disabled")
return nil, nil
}
token, err := auth.LoadToken(cfg.TokenPath)
if err != nil {
return nil, fmt.Errorf("load mcns token: %w", err)
}
httpClient, err := newHTTPClient(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("create mcns HTTP client: %w", err)
}
logger.Info("master DNS client enabled", "server", cfg.ServerURL, "zone", cfg.Zone)
return &DNSClient{
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
token: token,
zone: cfg.Zone,
httpClient: httpClient,
logger: logger,
}, nil
}
// Zone returns the configured DNS zone.
func (d *DNSClient) Zone() string {
if d == nil {
return ""
}
return d.zone
}
// EnsureRecord ensures an A record exists for serviceName pointing to nodeAddr.
func (d *DNSClient) EnsureRecord(ctx context.Context, serviceName, nodeAddr string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
for _, r := range existing {
if r.Value == nodeAddr {
d.logger.Debug("DNS record exists", "service", serviceName, "value", r.Value)
return nil
}
}
if len(existing) > 0 {
d.logger.Info("updating DNS record", "service", serviceName,
"old_value", existing[0].Value, "new_value", nodeAddr)
return d.updateRecord(ctx, existing[0].ID, serviceName, nodeAddr)
}
d.logger.Info("creating DNS record", "service", serviceName,
"record", serviceName+"."+d.zone, "value", nodeAddr)
return d.createRecord(ctx, serviceName, nodeAddr)
}
// RemoveRecord removes A records for serviceName.
func (d *DNSClient) RemoveRecord(ctx context.Context, serviceName string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
for _, r := range existing {
d.logger.Info("removing DNS record", "service", serviceName, "id", r.ID)
if err := d.deleteRecord(ctx, r.ID); err != nil {
return err
}
}
return nil
}
func (d *DNSClient) listRecords(ctx context.Context, serviceName string) ([]dnsRecord, error) {
url := fmt.Sprintf("%s/v1/zones/%s/records?name=%s&type=A", d.serverURL, d.zone, serviceName)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read list response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list records: mcns returned %d: %s", resp.StatusCode, string(body))
}
var envelope struct {
Records []dnsRecord `json:"records"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("parse list response: %w", err)
}
return envelope.Records, nil
}
func (d *DNSClient) createRecord(ctx context.Context, serviceName, nodeAddr string) error {
reqBody, _ := json.Marshal(map[string]interface{}{
"name": serviceName, "type": "A", "value": nodeAddr, "ttl": 300,
})
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, d.zone)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("create record request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("create record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("create record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (d *DNSClient) updateRecord(ctx context.Context, recordID int, serviceName, nodeAddr string) error {
reqBody, _ := json.Marshal(map[string]interface{}{
"name": serviceName, "type": "A", "value": nodeAddr, "ttl": 300,
})
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("update record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("update record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (d *DNSClient) deleteRecord(ctx context.Context, recordID int) error {
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func newHTTPClient(caCertPath string) (*http.Client, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if caCertPath != "" {
caCert, err := os.ReadFile(caCertPath) //nolint:gosec // path from trusted config
if err != nil {
return nil, fmt.Errorf("read CA cert %q: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("parse CA cert %q: no valid certificates found", caCertPath)
}
tlsConfig.RootCAs = pool
}
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}, nil
}

159
internal/master/master.go Normal file
View File

@@ -0,0 +1,159 @@
package master
import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Master is the MCP cluster master. It coordinates multi-node deployments,
// manages edge routes, and stores cluster state.
type Master struct {
mcpv1.UnimplementedMcpMasterServiceServer
Config *config.MasterConfig
DB *sql.DB
Pool *AgentPool
DNS *DNSClient
Logger *slog.Logger
Version string
}
// Run starts the master: opens the database, bootstraps nodes, sets up the
// gRPC server with TLS and auth, and blocks until SIGINT/SIGTERM.
func Run(cfg *config.MasterConfig, version string) error {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
}))
// Open master database.
db, err := masterdb.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("open master database: %w", err)
}
defer func() { _ = db.Close() }()
// Bootstrap nodes from config.
for _, n := range cfg.Nodes {
if err := masterdb.UpsertNode(db, n.Name, n.Address, n.Role, "amd64"); err != nil {
return fmt.Errorf("bootstrap node %s: %w", n.Name, err)
}
logger.Info("bootstrapped node", "name", n.Name, "address", n.Address, "role", n.Role)
}
// Load service token for dialing agents.
token, err := LoadServiceToken(cfg.Master.ServiceTokenPath)
if err != nil {
return fmt.Errorf("load service token: %w", err)
}
// Create agent connection pool.
pool := NewAgentPool(cfg.Master.CACert, token)
for _, n := range cfg.Nodes {
if addErr := pool.AddNodeMulti(n.Name, n.AllAddresses()); addErr != nil {
logger.Warn("failed to connect to agent", "node", n.Name, "err", addErr)
// Non-fatal: the node may come up later.
}
}
// Create DNS client.
dns, err := NewDNSClient(cfg.MCNS, logger)
if err != nil {
return fmt.Errorf("create DNS client: %w", err)
}
m := &Master{
Config: cfg,
DB: db,
Pool: pool,
DNS: dns,
Logger: logger,
Version: version,
}
// TLS.
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("load TLS cert: %w", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS13,
}
// Auth interceptor (same as agent — validates MCIAS tokens).
validator, err := auth.NewMCIASValidator(cfg.MCIAS.ServerURL, cfg.MCIAS.CACert)
if err != nil {
return fmt.Errorf("create MCIAS validator: %w", err)
}
// gRPC server.
server := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsConfig)),
grpc.ChainUnaryInterceptor(
auth.AuthInterceptor(validator),
),
grpc.ChainStreamInterceptor(
auth.StreamAuthInterceptor(validator),
),
)
mcpv1.RegisterMcpMasterServiceServer(server, m)
// Listen.
lis, err := net.Listen("tcp", cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("listen %q: %w", cfg.Server.GRPCAddr, err)
}
logger.Info("master starting",
"addr", cfg.Server.GRPCAddr,
"version", version,
"nodes", len(cfg.Nodes),
)
// Signal handling.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(lis)
}()
select {
case <-ctx.Done():
logger.Info("shutting down")
server.GracefulStop()
pool.Close()
return nil
case err := <-errCh:
pool.Close()
return fmt.Errorf("serve: %w", err)
}
}
func parseLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,64 @@
package master
import (
"database/sql"
"fmt"
"sort"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// PickNode selects the best worker node for a new service deployment.
// Algorithm: fewest placed services, ties broken alphabetically.
func PickNode(db *sql.DB) (string, error) {
workers, err := masterdb.ListWorkerNodes(db)
if err != nil {
return "", fmt.Errorf("list workers: %w", err)
}
if len(workers) == 0 {
return "", fmt.Errorf("no worker nodes available")
}
counts, err := masterdb.CountPlacementsPerNode(db)
if err != nil {
return "", fmt.Errorf("count placements: %w", err)
}
// Sort: fewest placements first, then alphabetically.
sort.Slice(workers, func(i, j int) bool {
ci := counts[workers[i].Name]
cj := counts[workers[j].Name]
if ci != cj {
return ci < cj
}
return workers[i].Name < workers[j].Name
})
return workers[0].Name, nil
}
// FindMasterNode returns the name of the node with role "master".
func FindMasterNode(db *sql.DB) (string, error) {
nodes, err := masterdb.ListNodes(db)
if err != nil {
return "", fmt.Errorf("list nodes: %w", err)
}
for _, n := range nodes {
if n.Role == "master" {
return n.Name, nil
}
}
return "", fmt.Errorf("no master node found")
}
// FindEdgeNode returns the name of the first edge node.
func FindEdgeNode(db *sql.DB) (string, error) {
edges, err := masterdb.ListEdgeNodes(db)
if err != nil {
return "", fmt.Errorf("list edge nodes: %w", err)
}
if len(edges) == 0 {
return "", fmt.Errorf("no edge nodes available")
}
return edges[0].Name, nil
}

130
internal/master/status.go Normal file
View File

@@ -0,0 +1,130 @@
package master
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Status returns the status of services across the fleet.
func (m *Master) Status(ctx context.Context, req *mcpv1.MasterStatusRequest) (*mcpv1.MasterStatusResponse, error) {
m.Logger.Debug("Status", "service", req.GetServiceName())
resp := &mcpv1.MasterStatusResponse{}
// If a specific service is requested, look up its placement.
if name := req.GetServiceName(); name != "" {
placement, err := masterdb.GetPlacement(m.DB, name)
if err != nil {
return nil, fmt.Errorf("lookup placement: %w", err)
}
if placement == nil {
return resp, nil // empty — service not found
}
ss := m.getServiceStatus(ctx, placement)
resp.Services = append(resp.Services, ss)
return resp, nil
}
// All services.
placements, err := masterdb.ListPlacements(m.DB)
if err != nil {
return nil, fmt.Errorf("list placements: %w", err)
}
for _, p := range placements {
ss := m.getServiceStatus(ctx, p)
resp.Services = append(resp.Services, ss)
}
return resp, nil
}
func (m *Master) getServiceStatus(ctx context.Context, p *masterdb.Placement) *mcpv1.ServiceStatus {
ss := &mcpv1.ServiceStatus{
Name: p.ServiceName,
Node: p.Node,
Tier: p.Tier,
Status: "unknown",
}
// Query the agent for live status.
client, err := m.Pool.Get(p.Node)
if err != nil {
ss.Status = "unreachable"
return ss
}
statusCtx, cancel := context.WithTimeout(ctx, m.Config.Timeouts.HealthCheck.Duration)
defer cancel()
agentResp, err := client.GetServiceStatus(statusCtx, &mcpv1.GetServiceStatusRequest{
Name: p.ServiceName,
})
if err != nil {
ss.Status = "unreachable"
return ss
}
// Map agent status to master status.
for _, info := range agentResp.GetServices() {
if info.GetName() == p.ServiceName {
if info.GetActive() {
ss.Status = "running"
} else {
ss.Status = "stopped"
}
break
}
}
// Attach edge route info.
edgeRoutes, err := masterdb.ListEdgeRoutesForService(m.DB, p.ServiceName)
if err == nil {
for _, er := range edgeRoutes {
ss.EdgeRoutes = append(ss.EdgeRoutes, &mcpv1.EdgeRouteStatus{
Hostname: er.Hostname,
EdgeNode: er.EdgeNode,
})
}
}
return ss
}
// ListNodes returns all nodes in the registry with placement counts.
func (m *Master) ListNodes(_ context.Context, _ *mcpv1.ListNodesRequest) (*mcpv1.ListNodesResponse, error) {
m.Logger.Debug("ListNodes")
nodes, err := masterdb.ListNodes(m.DB)
if err != nil {
return nil, fmt.Errorf("list nodes: %w", err)
}
counts, err := masterdb.CountPlacementsPerNode(m.DB)
if err != nil {
return nil, fmt.Errorf("count placements: %w", err)
}
resp := &mcpv1.ListNodesResponse{}
for _, n := range nodes {
ni := &mcpv1.NodeInfo{
Name: n.Name,
Role: n.Role,
Address: n.Address,
Arch: n.Arch,
Status: n.Status,
Containers: int32(n.Containers), //nolint:gosec // small number
Services: int32(counts[n.Name]), //nolint:gosec // small number
}
if n.LastHeartbeat != nil {
ni.LastHeartbeat = n.LastHeartbeat.Format("2006-01-02T15:04:05Z")
}
resp.Nodes = append(resp.Nodes, ni)
}
return resp, nil
}

View File

@@ -0,0 +1,94 @@
package master
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/masterdb"
)
// Undeploy handles MasterUndeployRequest: removes edge routes, DNS, then
// forwards the undeploy to the worker agent.
func (m *Master) Undeploy(ctx context.Context, req *mcpv1.MasterUndeployRequest) (*mcpv1.MasterUndeployResponse, error) {
serviceName := req.GetServiceName()
if serviceName == "" {
return nil, fmt.Errorf("service_name is required")
}
m.Logger.Info("Undeploy", "service", serviceName)
// Look up placement.
placement, err := masterdb.GetPlacement(m.DB, serviceName)
if err != nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("lookup placement: %v", err)}, nil
}
if placement == nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("service %q not found in placements", serviceName)}, nil
}
// Step 1: Undeploy on worker first (stops the backend).
client, err := m.Pool.Get(placement.Node)
if err != nil {
return &mcpv1.MasterUndeployResponse{Error: fmt.Sprintf("agent connection: %v", err)}, nil
}
undeployCtx, undeployCancel := context.WithTimeout(ctx, m.Config.Timeouts.Undeploy.Duration)
defer undeployCancel()
_, undeployErr := client.UndeployService(undeployCtx, &mcpv1.UndeployServiceRequest{
Name: serviceName,
})
if undeployErr != nil {
m.Logger.Warn("agent undeploy failed", "service", serviceName, "node", placement.Node, "err", undeployErr)
// Continue — still clean up edge routes and records.
}
// Step 2: Remove edge routes.
edgeRoutes, err := masterdb.ListEdgeRoutesForService(m.DB, serviceName)
if err != nil {
m.Logger.Warn("list edge routes for undeploy", "service", serviceName, "err", err)
}
for _, er := range edgeRoutes {
edgeClient, getErr := m.Pool.Get(er.EdgeNode)
if getErr != nil {
m.Logger.Warn("edge agent connection", "edge_node", er.EdgeNode, "err", getErr)
continue
}
edgeCtx, edgeCancel := context.WithTimeout(ctx, m.Config.Timeouts.EdgeRoute.Duration)
_, removeErr := edgeClient.RemoveEdgeRoute(edgeCtx, &mcpv1.RemoveEdgeRouteRequest{
Hostname: er.Hostname,
})
edgeCancel()
if removeErr != nil {
m.Logger.Warn("remove edge route", "hostname", er.Hostname, "err", removeErr)
} else {
m.Logger.Info("edge route removed", "hostname", er.Hostname, "edge_node", er.EdgeNode)
}
}
// Step 3: Remove DNS.
if m.DNS != nil {
if dnsErr := m.DNS.RemoveRecord(ctx, serviceName); dnsErr != nil {
m.Logger.Warn("DNS removal failed", "service", serviceName, "err", dnsErr)
}
}
// Step 4: Clean up records.
_ = masterdb.DeleteEdgeRoutesForService(m.DB, serviceName)
_ = masterdb.DeletePlacement(m.DB, serviceName)
success := undeployErr == nil
var errMsg string
if !success {
errMsg = fmt.Sprintf("agent undeploy: %v", undeployErr)
}
m.Logger.Info("undeploy complete", "service", serviceName, "success", success)
return &mcpv1.MasterUndeployResponse{
Success: success,
Error: errMsg,
}, nil
}

106
internal/masterdb/db.go Normal file
View File

@@ -0,0 +1,106 @@
// Package masterdb provides the SQLite database for the mcp-master daemon.
// It stores the cluster-wide node registry, service placements, and edge routes.
// This is separate from the agent's registry (internal/registry/) because the
// master and agent have fundamentally different schemas.
package masterdb
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens the master database at the given path and runs migrations.
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
for _, pragma := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA foreign_keys = ON",
"PRAGMA busy_timeout = 5000",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("exec %q: %w", pragma, err)
}
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for i, m := range migrations {
version := i + 1
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count); err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if count > 0 {
continue
}
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("run migration %d: %w", version, err)
}
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
}
return nil
}
var migrations = []string{
// Migration 1: cluster state
`
CREATE TABLE IF NOT EXISTS nodes (
name TEXT PRIMARY KEY,
address TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'worker',
arch TEXT NOT NULL DEFAULT 'amd64',
status TEXT NOT NULL DEFAULT 'unknown',
containers INTEGER NOT NULL DEFAULT 0,
last_heartbeat TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS placements (
service_name TEXT PRIMARY KEY,
node TEXT NOT NULL REFERENCES nodes(name),
tier TEXT NOT NULL DEFAULT 'worker',
deployed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS edge_routes (
hostname TEXT PRIMARY KEY,
service_name TEXT NOT NULL,
edge_node TEXT NOT NULL REFERENCES nodes(name),
backend_hostname TEXT NOT NULL,
backend_port INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_edge_routes_service
ON edge_routes(service_name);
`,
}

View File

@@ -0,0 +1,185 @@
package masterdb
import (
"database/sql"
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestOpenAndMigrate(t *testing.T) {
openTestDB(t)
}
func TestNodeCRUD(t *testing.T) {
db := openTestDB(t)
if err := UpsertNode(db, "rift", "100.95.252.120:9444", "master", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
if err := UpsertNode(db, "svc", "100.106.232.4:9555", "edge", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
if err := UpsertNode(db, "orion", "100.1.2.3:9444", "worker", "amd64"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
// Get.
n, err := GetNode(db, "rift")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if n == nil || n.Address != "100.95.252.120:9444" {
t.Errorf("GetNode(rift) = %+v", n)
}
// Get nonexistent.
n, err = GetNode(db, "nonexistent")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if n != nil {
t.Errorf("expected nil for nonexistent node")
}
// List all.
nodes, err := ListNodes(db)
if err != nil {
t.Fatalf("ListNodes: %v", err)
}
if len(nodes) != 3 {
t.Errorf("ListNodes: got %d, want 3", len(nodes))
}
// List workers (includes master role).
workers, err := ListWorkerNodes(db)
if err != nil {
t.Fatalf("ListWorkerNodes: %v", err)
}
if len(workers) != 2 {
t.Errorf("ListWorkerNodes: got %d, want 2 (rift+orion)", len(workers))
}
// List edge.
edges, err := ListEdgeNodes(db)
if err != nil {
t.Fatalf("ListEdgeNodes: %v", err)
}
if len(edges) != 1 || edges[0].Name != "svc" {
t.Errorf("ListEdgeNodes: got %v", edges)
}
// Update status.
if err := UpdateNodeStatus(db, "rift", "healthy"); err != nil {
t.Fatalf("UpdateNodeStatus: %v", err)
}
n, _ = GetNode(db, "rift")
if n.Status != "healthy" {
t.Errorf("status = %q, want healthy", n.Status)
}
}
func TestPlacementCRUD(t *testing.T) {
db := openTestDB(t)
_ = UpsertNode(db, "rift", "100.95.252.120:9444", "master", "amd64")
_ = UpsertNode(db, "orion", "100.1.2.3:9444", "worker", "amd64")
if err := CreatePlacement(db, "mcq", "rift", "worker"); err != nil {
t.Fatalf("CreatePlacement: %v", err)
}
if err := CreatePlacement(db, "mcdoc", "orion", "worker"); err != nil {
t.Fatalf("CreatePlacement: %v", err)
}
p, err := GetPlacement(db, "mcq")
if err != nil {
t.Fatalf("GetPlacement: %v", err)
}
if p == nil || p.Node != "rift" {
t.Errorf("GetPlacement(mcq) = %+v", p)
}
p, _ = GetPlacement(db, "nonexistent")
if p != nil {
t.Errorf("expected nil for nonexistent placement")
}
counts, err := CountPlacementsPerNode(db)
if err != nil {
t.Fatalf("CountPlacementsPerNode: %v", err)
}
if counts["rift"] != 1 || counts["orion"] != 1 {
t.Errorf("counts = %v", counts)
}
placements, err := ListPlacements(db)
if err != nil {
t.Fatalf("ListPlacements: %v", err)
}
if len(placements) != 2 {
t.Errorf("ListPlacements: got %d", len(placements))
}
if err := DeletePlacement(db, "mcq"); err != nil {
t.Fatalf("DeletePlacement: %v", err)
}
p, _ = GetPlacement(db, "mcq")
if p != nil {
t.Errorf("expected nil after delete")
}
}
func TestEdgeRouteCRUD(t *testing.T) {
db := openTestDB(t)
_ = UpsertNode(db, "svc", "100.106.232.4:9555", "edge", "amd64")
if err := CreateEdgeRoute(db, "mcq.metacircular.net", "mcq", "svc", "mcq.svc.mcp.metacircular.net", 8443); err != nil {
t.Fatalf("CreateEdgeRoute: %v", err)
}
if err := CreateEdgeRoute(db, "docs.metacircular.net", "mcdoc", "svc", "mcdoc.svc.mcp.metacircular.net", 443); err != nil {
t.Fatalf("CreateEdgeRoute: %v", err)
}
routes, err := ListEdgeRoutes(db)
if err != nil {
t.Fatalf("ListEdgeRoutes: %v", err)
}
if len(routes) != 2 {
t.Errorf("ListEdgeRoutes: got %d", len(routes))
}
routes, err = ListEdgeRoutesForService(db, "mcq")
if err != nil {
t.Fatalf("ListEdgeRoutesForService: %v", err)
}
if len(routes) != 1 || routes[0].Hostname != "mcq.metacircular.net" {
t.Errorf("ListEdgeRoutesForService(mcq) = %v", routes)
}
if err := DeleteEdgeRoute(db, "mcq.metacircular.net"); err != nil {
t.Fatalf("DeleteEdgeRoute: %v", err)
}
routes, _ = ListEdgeRoutes(db)
if len(routes) != 1 {
t.Errorf("expected 1 route after delete, got %d", len(routes))
}
_ = CreateEdgeRoute(db, "docs2.metacircular.net", "mcdoc", "svc", "mcdoc.svc.mcp.metacircular.net", 443)
if err := DeleteEdgeRoutesForService(db, "mcdoc"); err != nil {
t.Fatalf("DeleteEdgeRoutesForService: %v", err)
}
routes, _ = ListEdgeRoutes(db)
if len(routes) != 0 {
t.Errorf("expected 0 routes after service delete, got %d", len(routes))
}
}

View File

@@ -0,0 +1,95 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// EdgeRoute records a public route managed by the master.
type EdgeRoute struct {
Hostname string
ServiceName string
EdgeNode string
BackendHostname string
BackendPort int
CreatedAt time.Time
}
// CreateEdgeRoute inserts or replaces an edge route record.
func CreateEdgeRoute(db *sql.DB, hostname, serviceName, edgeNode, backendHostname string, backendPort int) error {
_, err := db.Exec(`
INSERT INTO edge_routes (hostname, service_name, edge_node, backend_hostname, backend_port, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
service_name = excluded.service_name,
edge_node = excluded.edge_node,
backend_hostname = excluded.backend_hostname,
backend_port = excluded.backend_port
`, hostname, serviceName, edgeNode, backendHostname, backendPort)
if err != nil {
return fmt.Errorf("create edge route %s: %w", hostname, err)
}
return nil
}
// ListEdgeRoutes returns all edge routes.
func ListEdgeRoutes(db *sql.DB) ([]*EdgeRoute, error) {
return queryEdgeRoutes(db, `SELECT hostname, service_name, edge_node, backend_hostname, backend_port, created_at FROM edge_routes ORDER BY hostname`)
}
// ListEdgeRoutesForService returns edge routes for a specific service.
func ListEdgeRoutesForService(db *sql.DB, serviceName string) ([]*EdgeRoute, error) {
rows, err := db.Query(`
SELECT hostname, service_name, edge_node, backend_hostname, backend_port, created_at
FROM edge_routes WHERE service_name = ? ORDER BY hostname
`, serviceName)
if err != nil {
return nil, fmt.Errorf("list edge routes for %s: %w", serviceName, err)
}
defer func() { _ = rows.Close() }()
return scanEdgeRoutes(rows)
}
// DeleteEdgeRoute removes a single edge route by hostname.
func DeleteEdgeRoute(db *sql.DB, hostname string) error {
_, err := db.Exec(`DELETE FROM edge_routes WHERE hostname = ?`, hostname)
if err != nil {
return fmt.Errorf("delete edge route %s: %w", hostname, err)
}
return nil
}
// DeleteEdgeRoutesForService removes all edge routes for a service.
func DeleteEdgeRoutesForService(db *sql.DB, serviceName string) error {
_, err := db.Exec(`DELETE FROM edge_routes WHERE service_name = ?`, serviceName)
if err != nil {
return fmt.Errorf("delete edge routes for %s: %w", serviceName, err)
}
return nil
}
func queryEdgeRoutes(db *sql.DB, query string) ([]*EdgeRoute, error) {
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query edge routes: %w", err)
}
defer func() { _ = rows.Close() }()
return scanEdgeRoutes(rows)
}
func scanEdgeRoutes(rows *sql.Rows) ([]*EdgeRoute, error) {
var routes []*EdgeRoute
for rows.Next() {
var r EdgeRoute
var createdAt string
if err := rows.Scan(&r.Hostname, &r.ServiceName, &r.EdgeNode, &r.BackendHostname, &r.BackendPort, &createdAt); err != nil {
return nil, fmt.Errorf("scan edge route: %w", err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
routes = append(routes, &r)
}
return routes, rows.Err()
}

103
internal/masterdb/nodes.go Normal file
View File

@@ -0,0 +1,103 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// Node represents a registered node in the cluster.
type Node struct {
Name string
Address string
Role string
Arch string
Status string
Containers int
LastHeartbeat *time.Time
}
// UpsertNode inserts or updates a node in the registry.
func UpsertNode(db *sql.DB, name, address, role, arch string) error {
_, err := db.Exec(`
INSERT INTO nodes (name, address, role, arch, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(name) DO UPDATE SET
address = excluded.address,
role = excluded.role,
arch = excluded.arch,
updated_at = datetime('now')
`, name, address, role, arch)
if err != nil {
return fmt.Errorf("upsert node %s: %w", name, err)
}
return nil
}
// GetNode returns a single node by name.
func GetNode(db *sql.DB, name string) (*Node, error) {
var n Node
var lastHB sql.NullString
err := db.QueryRow(`
SELECT name, address, role, arch, status, containers, last_heartbeat
FROM nodes WHERE name = ?
`, name).Scan(&n.Name, &n.Address, &n.Role, &n.Arch, &n.Status, &n.Containers, &lastHB)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get node %s: %w", name, err)
}
if lastHB.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", lastHB.String)
n.LastHeartbeat = &t
}
return &n, nil
}
// ListNodes returns all nodes.
func ListNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes ORDER BY name`)
}
// ListWorkerNodes returns nodes with role "worker" or "master" (master is also a worker).
func ListWorkerNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes WHERE role IN ('worker', 'master') ORDER BY name`)
}
// ListEdgeNodes returns nodes with role "edge".
func ListEdgeNodes(db *sql.DB) ([]*Node, error) {
return queryNodes(db, `SELECT name, address, role, arch, status, containers, last_heartbeat FROM nodes WHERE role = 'edge' ORDER BY name`)
}
func queryNodes(db *sql.DB, query string) ([]*Node, error) {
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("query nodes: %w", err)
}
defer func() { _ = rows.Close() }()
var nodes []*Node
for rows.Next() {
var n Node
var lastHB sql.NullString
if err := rows.Scan(&n.Name, &n.Address, &n.Role, &n.Arch, &n.Status, &n.Containers, &lastHB); err != nil {
return nil, fmt.Errorf("scan node: %w", err)
}
if lastHB.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", lastHB.String)
n.LastHeartbeat = &t
}
nodes = append(nodes, &n)
}
return nodes, rows.Err()
}
// UpdateNodeStatus updates a node's status field.
func UpdateNodeStatus(db *sql.DB, name, status string) error {
_, err := db.Exec(`UPDATE nodes SET status = ?, updated_at = datetime('now') WHERE name = ?`, status, name)
if err != nil {
return fmt.Errorf("update node status %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,99 @@
package masterdb
import (
"database/sql"
"fmt"
"time"
)
// Placement records which node hosts which service.
type Placement struct {
ServiceName string
Node string
Tier string
DeployedAt time.Time
}
// CreatePlacement inserts or replaces a placement record.
func CreatePlacement(db *sql.DB, serviceName, node, tier string) error {
_, err := db.Exec(`
INSERT INTO placements (service_name, node, tier, deployed_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(service_name) DO UPDATE SET
node = excluded.node,
tier = excluded.tier,
deployed_at = datetime('now')
`, serviceName, node, tier)
if err != nil {
return fmt.Errorf("create placement %s: %w", serviceName, err)
}
return nil
}
// GetPlacement returns the placement for a service.
func GetPlacement(db *sql.DB, serviceName string) (*Placement, error) {
var p Placement
var deployedAt string
err := db.QueryRow(`
SELECT service_name, node, tier, deployed_at
FROM placements WHERE service_name = ?
`, serviceName).Scan(&p.ServiceName, &p.Node, &p.Tier, &deployedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get placement %s: %w", serviceName, err)
}
p.DeployedAt, _ = time.Parse("2006-01-02 15:04:05", deployedAt)
return &p, nil
}
// ListPlacements returns all placements.
func ListPlacements(db *sql.DB) ([]*Placement, error) {
rows, err := db.Query(`SELECT service_name, node, tier, deployed_at FROM placements ORDER BY service_name`)
if err != nil {
return nil, fmt.Errorf("list placements: %w", err)
}
defer func() { _ = rows.Close() }()
var placements []*Placement
for rows.Next() {
var p Placement
var deployedAt string
if err := rows.Scan(&p.ServiceName, &p.Node, &p.Tier, &deployedAt); err != nil {
return nil, fmt.Errorf("scan placement: %w", err)
}
p.DeployedAt, _ = time.Parse("2006-01-02 15:04:05", deployedAt)
placements = append(placements, &p)
}
return placements, rows.Err()
}
// DeletePlacement removes a placement record.
func DeletePlacement(db *sql.DB, serviceName string) error {
_, err := db.Exec(`DELETE FROM placements WHERE service_name = ?`, serviceName)
if err != nil {
return fmt.Errorf("delete placement %s: %w", serviceName, err)
}
return nil
}
// CountPlacementsPerNode returns a map of node name → number of placed services.
func CountPlacementsPerNode(db *sql.DB) (map[string]int, error) {
rows, err := db.Query(`SELECT node, COUNT(*) FROM placements GROUP BY node`)
if err != nil {
return nil, fmt.Errorf("count placements: %w", err)
}
defer func() { _ = rows.Close() }()
counts := make(map[string]int)
for rows.Next() {
var node string
var count int
if err := rows.Scan(&node, &count); err != nil {
return nil, fmt.Errorf("scan count: %w", err)
}
counts[node] = count
}
return counts, rows.Err()
}

View File

@@ -142,4 +142,18 @@ var migrations = []string{
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
);
`,
// Migration 3: service comment
`ALTER TABLE services ADD COLUMN comment TEXT NOT NULL DEFAULT '';`,
// Migration 4: edge routes (v2 — public routes managed by the master)
`CREATE TABLE IF NOT EXISTS edge_routes (
hostname TEXT NOT NULL PRIMARY KEY,
backend_hostname TEXT NOT NULL,
backend_port INTEGER NOT NULL,
tls_cert TEXT NOT NULL DEFAULT '',
tls_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);`,
}

View File

@@ -0,0 +1,93 @@
package registry
import (
"database/sql"
"fmt"
"time"
)
// EdgeRoute represents a public edge route managed by the master.
type EdgeRoute struct {
Hostname string
BackendHostname string
BackendPort int
TLSCert string
TLSKey string
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateEdgeRoute inserts or replaces an edge route.
func CreateEdgeRoute(db *sql.DB, hostname, backendHostname string, backendPort int, tlsCert, tlsKey string) error {
_, err := db.Exec(`
INSERT INTO edge_routes (hostname, backend_hostname, backend_port, tls_cert, tls_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
backend_hostname = excluded.backend_hostname,
backend_port = excluded.backend_port,
tls_cert = excluded.tls_cert,
tls_key = excluded.tls_key,
updated_at = datetime('now')
`, hostname, backendHostname, backendPort, tlsCert, tlsKey)
if err != nil {
return fmt.Errorf("create edge route %s: %w", hostname, err)
}
return nil
}
// GetEdgeRoute returns a single edge route by hostname.
func GetEdgeRoute(db *sql.DB, hostname string) (*EdgeRoute, error) {
var r EdgeRoute
var createdAt, updatedAt string
err := db.QueryRow(`
SELECT hostname, backend_hostname, backend_port, tls_cert, tls_key, created_at, updated_at
FROM edge_routes WHERE hostname = ?
`, hostname).Scan(&r.Hostname, &r.BackendHostname, &r.BackendPort, &r.TLSCert, &r.TLSKey, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get edge route %s: %w", hostname, err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
r.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return &r, nil
}
// ListEdgeRoutes returns all edge routes.
func ListEdgeRoutes(db *sql.DB) ([]*EdgeRoute, error) {
rows, err := db.Query(`
SELECT hostname, backend_hostname, backend_port, tls_cert, tls_key, created_at, updated_at
FROM edge_routes ORDER BY hostname
`)
if err != nil {
return nil, fmt.Errorf("list edge routes: %w", err)
}
defer func() { _ = rows.Close() }()
var routes []*EdgeRoute
for rows.Next() {
var r EdgeRoute
var createdAt, updatedAt string
if err := rows.Scan(&r.Hostname, &r.BackendHostname, &r.BackendPort, &r.TLSCert, &r.TLSKey, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan edge route: %w", err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
r.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
routes = append(routes, &r)
}
return routes, rows.Err()
}
// DeleteEdgeRoute removes an edge route by hostname.
func DeleteEdgeRoute(db *sql.DB, hostname string) error {
result, err := db.Exec(`DELETE FROM edge_routes WHERE hostname = ?`, hostname)
if err != nil {
return fmt.Errorf("delete edge route %s: %w", hostname, err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("edge route %s not found", hostname)
}
return nil
}

View File

@@ -16,8 +16,10 @@ import (
// ServiceDef is the top-level TOML structure for a service definition file.
type ServiceDef struct {
Name string `toml:"name"`
Node string `toml:"node"`
Node string `toml:"node,omitempty"`
Tier string `toml:"tier,omitempty"`
Active *bool `toml:"active,omitempty"`
Comment string `toml:"comment,omitempty"`
Path string `toml:"path,omitempty"`
Build *BuildDef `toml:"build,omitempty"`
Components []ComponentDef `toml:"components"`
@@ -36,6 +38,7 @@ type RouteDef struct {
Port int `toml:"port"`
Mode string `toml:"mode,omitempty"`
Hostname string `toml:"hostname,omitempty"`
Public bool `toml:"public,omitempty"`
}
// ComponentDef describes a single container component within a service.
@@ -129,8 +132,9 @@ func validate(def *ServiceDef) error {
if def.Name == "" {
return fmt.Errorf("service name is required")
}
if def.Node == "" {
return fmt.Errorf("service node is required")
// v2: either node or tier must be set. Tier defaults to "worker" if both empty.
if def.Node == "" && def.Tier == "" {
def.Tier = "worker"
}
if len(def.Components) == 0 {
return fmt.Errorf("service %q must have at least one component", def.Name)
@@ -191,8 +195,11 @@ func validateRoutes(compName, svcName string, routes []RouteDef) error {
// ToProto converts a ServiceDef to a proto ServiceSpec.
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
Name: def.Name,
Active: def.Active != nil && *def.Active,
Name: def.Name,
Active: def.Active != nil && *def.Active,
Comment: def.Comment,
Tier: def.Tier,
Node: def.Node,
}
for _, c := range def.Components {
@@ -213,6 +220,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
Port: int32(r.Port), //nolint:gosec // port range validated
Mode: r.Mode,
Hostname: r.Hostname,
Public: r.Public,
})
}
spec.Components = append(spec.Components, cs)
@@ -227,9 +235,11 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
active := spec.GetActive()
def := &ServiceDef{
Name: spec.GetName(),
Node: node,
Active: &active,
Name: spec.GetName(),
Node: node,
Tier: spec.GetTier(),
Active: &active,
Comment: spec.GetComment(),
}
for _, c := range spec.GetComponents() {
@@ -250,6 +260,7 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
Port: int(r.GetPort()),
Mode: r.GetMode(),
Hostname: r.GetHostname(),
Public: r.GetPublic(),
})
}
def.Components = append(def.Components, cd)

View File

@@ -119,14 +119,8 @@ func TestValidation(t *testing.T) {
},
wantErr: "service name is required",
},
{
name: "missing node",
def: &ServiceDef{
Name: "svc",
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
},
wantErr: "service node is required",
},
// v2: missing node no longer errors — defaults to tier=worker.
// Tested separately in TestValidationNodeTierDefault.
{
name: "empty components",
def: &ServiceDef{

95
proto/mcp/v1/master.proto Normal file
View File

@@ -0,0 +1,95 @@
// McpMasterService: Multi-node orchestration for the Metacircular platform.
syntax = "proto3";
package mcp.v1;
option go_package = "git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1";
import "proto/mcp/v1/mcp.proto";
// McpMasterService coordinates multi-node deployments. The CLI sends
// deploy/undeploy/status requests to the master, which places services on
// nodes, forwards to agents, and coordinates edge routing.
service McpMasterService {
// CLI operations.
rpc Deploy(MasterDeployRequest) returns (MasterDeployResponse);
rpc Undeploy(MasterUndeployRequest) returns (MasterUndeployResponse);
rpc Status(MasterStatusRequest) returns (MasterStatusResponse);
rpc ListNodes(ListNodesRequest) returns (ListNodesResponse);
}
// --- Deploy ---
message MasterDeployRequest {
ServiceSpec service = 1;
}
message MasterDeployResponse {
string node = 1; // node the service was placed on
bool success = 2; // true only if ALL steps succeeded
string error = 3;
// Per-step results for operator visibility.
StepResult deploy_result = 4;
StepResult edge_route_result = 5;
StepResult dns_result = 6;
}
message StepResult {
string step = 1;
bool success = 2;
string error = 3;
}
// --- Undeploy ---
message MasterUndeployRequest {
string service_name = 1;
}
message MasterUndeployResponse {
bool success = 1;
string error = 2;
}
// --- Status ---
message MasterStatusRequest {
string service_name = 1; // empty = all services
}
message MasterStatusResponse {
repeated ServiceStatus services = 1;
}
message ServiceStatus {
string name = 1;
string node = 2;
string tier = 3;
string status = 4; // "running", "stopped", "unhealthy", "unknown"
repeated EdgeRouteStatus edge_routes = 5;
}
message EdgeRouteStatus {
string hostname = 1;
string edge_node = 2;
string cert_expires = 3; // RFC3339
}
// --- Nodes ---
message ListNodesRequest {}
message ListNodesResponse {
repeated NodeInfo nodes = 1;
}
message NodeInfo {
string name = 1;
string role = 2;
string address = 3;
string arch = 4;
string status = 5; // "healthy", "unhealthy", "unknown"
int32 containers = 6;
string last_heartbeat = 7; // RFC3339
int32 services = 8; // placement count
}

View File

@@ -42,6 +42,14 @@ service McpAgentService {
rpc AddProxyRoute(AddProxyRouteRequest) returns (AddProxyRouteResponse);
rpc RemoveProxyRoute(RemoveProxyRouteRequest) returns (RemoveProxyRouteResponse);
// Edge routing (called by master on edge nodes)
rpc SetupEdgeRoute(SetupEdgeRouteRequest) returns (SetupEdgeRouteResponse);
rpc RemoveEdgeRoute(RemoveEdgeRouteRequest) returns (RemoveEdgeRouteResponse);
rpc ListEdgeRoutes(ListEdgeRoutesRequest) returns (ListEdgeRoutesResponse);
// Health (called by master on missed heartbeats)
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
// Logs
rpc Logs(LogsRequest) returns (stream LogsResponse);
}
@@ -53,6 +61,7 @@ message RouteSpec {
int32 port = 2; // mc-proxy listener port (e.g. 443, 8443, 9443); NOT the container internal port
string mode = 3; // "l4" or "l7"
string hostname = 4; // optional public hostname override
bool public = 5; // triggers edge routing when true
}
message ComponentSpec {
@@ -68,10 +77,19 @@ message ComponentSpec {
repeated string env = 10;
}
message SnapshotConfig {
string method = 1; // "grpc", "cli", "exec: <cmd>", "full", or "" (default)
repeated string excludes = 2; // paths relative to /srv/<service>/ to skip
}
message ServiceSpec {
string name = 1;
bool active = 2;
repeated ComponentSpec components = 3;
string comment = 4;
string tier = 5; // "core" or "worker" (default: "worker")
string node = 6; // explicit node pin (overrides tier)
SnapshotConfig snapshot = 7; // snapshot method and excludes
}
message DeployRequest {
@@ -151,6 +169,7 @@ message ServiceInfo {
string name = 1;
bool active = 2;
repeated ComponentInfo components = 3;
string comment = 4;
}
message ComponentInfo {
@@ -377,3 +396,43 @@ message RemoveProxyRouteRequest {
}
message RemoveProxyRouteResponse {}
// --- Edge routes (v2) ---
message SetupEdgeRouteRequest {
string hostname = 1; // public hostname (e.g. "mcq.metacircular.net")
string backend_hostname = 2; // internal .svc.mcp hostname
int32 backend_port = 3; // port on worker's mc-proxy
bool backend_tls = 4; // MUST be true; agent rejects false
}
message SetupEdgeRouteResponse {}
message RemoveEdgeRouteRequest {
string hostname = 1;
}
message RemoveEdgeRouteResponse {}
message ListEdgeRoutesRequest {}
message ListEdgeRoutesResponse {
repeated EdgeRoute routes = 1;
}
message EdgeRoute {
string hostname = 1;
string backend_hostname = 2;
int32 backend_port = 3;
string cert_serial = 4;
string cert_expires = 5; // RFC3339
}
// --- Health check (v2) ---
message HealthCheckRequest {}
message HealthCheckResponse {
string status = 1; // "healthy" or "degraded"
int32 containers = 2;
}