Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5da307cab5 | |||
| 22a836812f | |||
| 9918859705 | |||
| da59d60c2d | |||
| 598ea44e0b | |||
| 6fd81cacf2 | |||
| 20735e4b41 | |||
| 3c0b55f9f8 | |||
| 78890ed76a | |||
| c5ff5bb63c | |||
| ddd6f123ab | |||
| 90445507a3 | |||
| 68d670b3ed | |||
| 714320c018 | |||
| fa8ba6fac1 | |||
| f66758b92b | |||
| 09d0d197c3 | |||
| 52914d50b0 | |||
| bb4bee51ba | |||
| 4ac8a6d60b | |||
| d8f45ca520 | |||
| 95f86157b4 | |||
| 93e26d3789 | |||
| 3d2edb7c26 | |||
| bf02935716 | |||
| c4f0d7be8e | |||
| 4d900eafd1 | |||
| 38f9070c24 | |||
| 67d0ab1d9d | |||
| 7383b370f0 | |||
| 4c847e6de9 | |||
| 14b978861f | |||
| 18365cc0a8 | |||
| 86d516acf6 | |||
| dd167b8e0b |
151
ARCHITECTURE.md
151
ARCHITECTURE.md
@@ -121,9 +121,26 @@ option for future security hardening.
|
|||||||
## Authentication and Authorization
|
## Authentication and Authorization
|
||||||
|
|
||||||
MCP follows the platform authentication model: all auth is delegated to
|
MCP follows the platform authentication model: all auth is delegated to
|
||||||
MCIAS.
|
MCIAS. The auth model separates three concerns: operator intent (CLI to
|
||||||
|
agent), infrastructure automation (agent to platform services), and
|
||||||
|
access control (who can do what).
|
||||||
|
|
||||||
### Agent Authentication
|
### Identity Model
|
||||||
|
|
||||||
|
| Identity | Type | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| Human operator (e.g., `kyle`) | human | CLI operations: deploy, stop, start, build |
|
||||||
|
| `mcp-agent` | system | Agent-to-service automation: certs, DNS, routes, image pull |
|
||||||
|
| Per-service accounts (e.g., `mcq`) | system | Scoped self-management (own DNS records only) |
|
||||||
|
| `admin` role | role | MCIAS account management, policy changes, zone creation |
|
||||||
|
| `guest` role | role | Explicitly rejected by the agent |
|
||||||
|
|
||||||
|
The `admin` role is reserved for MCIAS-level administrative operations
|
||||||
|
(account creation, policy management, zone mutations). Routine MCP
|
||||||
|
operations (deploy, stop, start, build) do not require admin — any
|
||||||
|
authenticated non-guest user or system account is accepted.
|
||||||
|
|
||||||
|
### Agent Authentication (CLI → Agent)
|
||||||
|
|
||||||
The agent is a gRPC server with a unary interceptor that enforces
|
The agent is a gRPC server with a unary interceptor that enforces
|
||||||
authentication on every RPC:
|
authentication on every RPC:
|
||||||
@@ -132,10 +149,34 @@ authentication on every RPC:
|
|||||||
(`authorization: Bearer <token>`).
|
(`authorization: Bearer <token>`).
|
||||||
2. Agent extracts the token and validates it against MCIAS (cached 30s by
|
2. Agent extracts the token and validates it against MCIAS (cached 30s by
|
||||||
SHA-256 of the token, per platform convention).
|
SHA-256 of the token, per platform convention).
|
||||||
3. Agent checks that the caller has the `admin` role. All MCP operations
|
3. Agent rejects guests (`guest` role → `PERMISSION_DENIED`). All other
|
||||||
require admin -- there is no unprivileged MCP access.
|
authenticated users and system accounts are accepted.
|
||||||
4. If validation fails, the RPC returns `UNAUTHENTICATED` (invalid/expired
|
4. If validation fails, the RPC returns `UNAUTHENTICATED` (invalid/expired
|
||||||
token) or `PERMISSION_DENIED` (valid token, not admin).
|
token) or `PERMISSION_DENIED` (guest).
|
||||||
|
|
||||||
|
### Agent Service Authentication (Agent → Platform Services)
|
||||||
|
|
||||||
|
The agent authenticates to platform services using a long-lived system
|
||||||
|
account token (`mcp-agent`). Each service has its own token file:
|
||||||
|
|
||||||
|
| Service | Token Path | Operations |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| Metacrypt | `/srv/mcp/metacrypt-token` | TLS cert provisioning (PKI issue) |
|
||||||
|
| MCNS | `/srv/mcp/mcns-token` | DNS record create/delete (any name) |
|
||||||
|
| mc-proxy | Unix socket (no auth) | Route registration/removal |
|
||||||
|
| MCR | podman auth store | Image pull (JWT-as-password) |
|
||||||
|
|
||||||
|
These tokens are issued by MCIAS for the `mcp-agent` system account.
|
||||||
|
They carry no roles — authorization is handled by each service's policy
|
||||||
|
engine:
|
||||||
|
|
||||||
|
- **Metacrypt:** Policy rule grants `mcp-agent` write access to
|
||||||
|
`engine/pki/issue`.
|
||||||
|
- **MCNS:** Code-level authorization: system account `mcp-agent` can
|
||||||
|
manage any record; other system accounts can only manage records
|
||||||
|
matching their username.
|
||||||
|
- **MCR:** Default policy allows all authenticated users to push/pull.
|
||||||
|
MCR accepts MCIAS JWTs as passwords at the `/v2/token` endpoint.
|
||||||
|
|
||||||
### CLI Authentication
|
### CLI Authentication
|
||||||
|
|
||||||
@@ -148,6 +189,15 @@ obtained by:
|
|||||||
|
|
||||||
The stored token is used for all subsequent agent RPCs until it expires.
|
The stored token is used for all subsequent agent RPCs until it expires.
|
||||||
|
|
||||||
|
### MCR Registry Authentication
|
||||||
|
|
||||||
|
`mcp build` auto-authenticates to MCR before pushing images. It reads
|
||||||
|
the CLI's stored MCIAS token and uses it as the password for `podman
|
||||||
|
login`. MCR's token endpoint accepts MCIAS JWTs as passwords (the
|
||||||
|
personal-access-token pattern), so both human and system account tokens
|
||||||
|
work. This eliminates the need for a separate interactive `podman login`
|
||||||
|
step.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Services and Components
|
## Services and Components
|
||||||
@@ -224,6 +274,9 @@ mcp pull <service> <path> [local-file] Copy a file from /srv/<service>/<path> to
|
|||||||
mcp node list List registered nodes
|
mcp node list List registered nodes
|
||||||
mcp node add <name> <address> Register a node
|
mcp node add <name> <address> Register a node
|
||||||
mcp node remove <name> Deregister a node
|
mcp node remove <name> Deregister a node
|
||||||
|
|
||||||
|
mcp agent upgrade [node] Build, push, and restart agent on all (or one) node(s)
|
||||||
|
mcp agent status Show agent version on each node
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Definition Files
|
### Service Definition Files
|
||||||
@@ -1144,20 +1197,84 @@ The agent's data directory follows the platform convention:
|
|||||||
|
|
||||||
### Agent Deployment (on nodes)
|
### Agent Deployment (on nodes)
|
||||||
|
|
||||||
The agent is deployed like any other Metacircular service:
|
#### Provisioning (one-time per node)
|
||||||
|
|
||||||
1. Provision the `mcp` system user via NixOS config (with podman access
|
Each node needs a one-time setup before the agent can run. The steps are
|
||||||
and subuid/subgid ranges for rootless containers).
|
the same regardless of OS, but the mechanism differs:
|
||||||
|
|
||||||
|
1. Create `mcp` system user with podman access and subuid/subgid ranges.
|
||||||
2. Set `/srv/` ownership to the `mcp` user (the agent creates and manages
|
2. Set `/srv/` ownership to the `mcp` user (the agent creates and manages
|
||||||
`/srv/<service>/` directories for all services).
|
`/srv/<service>/` directories for all services).
|
||||||
3. Create `/srv/mcp/` directory and config file.
|
3. Create `/srv/mcp/` directory and config file.
|
||||||
4. Provision TLS certificate from Metacrypt.
|
4. Provision TLS certificate from Metacrypt.
|
||||||
5. Create an MCIAS system account for the agent (`mcp-agent`).
|
5. Create an MCIAS system account for the agent (`mcp-agent`).
|
||||||
6. Install the `mcp-agent` binary.
|
6. Install the initial `mcp-agent` binary to `/srv/mcp/mcp-agent`.
|
||||||
7. Start via systemd unit.
|
7. Install and start the systemd unit.
|
||||||
|
|
||||||
The agent runs as a systemd service. Container-first deployment is a v2
|
On **NixOS** (rift), provisioning is declarative via the NixOS config.
|
||||||
concern -- MCP needs to be running before it can manage its own agent.
|
The NixOS config owns the infrastructure (user, systemd unit, podman,
|
||||||
|
directories, permissions) but **not** the binary. `ExecStart` points to
|
||||||
|
`/srv/mcp/mcp-agent`, a mutable path that MCP manages. NixOS may
|
||||||
|
bootstrap the initial binary there, but subsequent updates come from MCP.
|
||||||
|
|
||||||
|
On **Debian** (hyperborea, svc), provisioning is done via a setup script
|
||||||
|
or ansible playbook that creates the same layout.
|
||||||
|
|
||||||
|
#### Binary Location
|
||||||
|
|
||||||
|
The agent binary lives at `/srv/mcp/mcp-agent` on **all** nodes,
|
||||||
|
regardless of OS. This unifies the update mechanism across the fleet.
|
||||||
|
|
||||||
|
#### Agent Upgrades
|
||||||
|
|
||||||
|
After initial provisioning, the agent binary is updated via
|
||||||
|
`mcp agent upgrade`. The CLI:
|
||||||
|
|
||||||
|
1. Cross-compiles the agent for each target architecture
|
||||||
|
(`GOARCH=amd64` for rift/svc, `GOARCH=arm64` for hyperborea).
|
||||||
|
2. SSHs to each node, pushes the binary to `/srv/mcp/mcp-agent.new`.
|
||||||
|
3. Atomically swaps the binary (`mv mcp-agent.new mcp-agent`).
|
||||||
|
4. Restarts the systemd service (`systemctl restart mcp-agent`).
|
||||||
|
|
||||||
|
SSH is used instead of gRPC because:
|
||||||
|
- It works even when the agent is broken or has an incompatible version.
|
||||||
|
- The binary is ~17MB, which exceeds gRPC default message limits.
|
||||||
|
- No self-restart coordination needed.
|
||||||
|
|
||||||
|
The CLI uses `golang.org/x/crypto/ssh` for native SSH, keeping the
|
||||||
|
entire workflow in a single binary with no external tool dependencies.
|
||||||
|
|
||||||
|
#### Node Configuration
|
||||||
|
|
||||||
|
Node config includes SSH and architecture info for agent management:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[nodes]]
|
||||||
|
name = "rift"
|
||||||
|
address = "100.95.252.120:9444"
|
||||||
|
ssh = "rift" # SSH host (from ~/.ssh/config or hostname)
|
||||||
|
arch = "amd64" # GOARCH for cross-compilation
|
||||||
|
|
||||||
|
[[nodes]]
|
||||||
|
name = "hyperborea"
|
||||||
|
address = "100.x.x.x:9444"
|
||||||
|
ssh = "hyperborea"
|
||||||
|
arch = "arm64"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Coordinated Upgrades
|
||||||
|
|
||||||
|
New MCP releases often add new RPCs. A CLI at v0.6.0 calling an agent
|
||||||
|
at v0.5.0 fails with `Unimplemented`. Therefore agent upgrades must be
|
||||||
|
coordinated: `mcp agent upgrade` (with no node argument) upgrades all
|
||||||
|
nodes before the CLI is used for other operations.
|
||||||
|
|
||||||
|
If a node fails to upgrade, it is reported but the others still proceed.
|
||||||
|
The operator can retry or investigate via SSH.
|
||||||
|
|
||||||
|
#### Systemd Unit
|
||||||
|
|
||||||
|
The systemd unit is the same on all nodes:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -1167,7 +1284,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/mcp-agent server --config /srv/mcp/mcp-agent.toml
|
ExecStart=/srv/mcp/mcp-agent server --config /srv/mcp/mcp-agent.toml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -1175,17 +1292,14 @@ User=mcp
|
|||||||
Group=mcp
|
Group=mcp
|
||||||
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=full
|
||||||
ProtectHome=true
|
ProtectHome=false
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
PrivateDevices=true
|
PrivateDevices=true
|
||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
ProtectKernelModules=true
|
ProtectKernelModules=true
|
||||||
ProtectControlGroups=true
|
|
||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
RestrictNamespaces=true
|
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictRealtime=true
|
RestrictRealtime=true
|
||||||
ReadWritePaths=/srv
|
ReadWritePaths=/srv
|
||||||
|
|
||||||
@@ -1195,6 +1309,7 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
Note: `ReadWritePaths=/srv` (not `/srv/mcp`) because the agent writes
|
Note: `ReadWritePaths=/srv` (not `/srv/mcp`) because the agent writes
|
||||||
files to any service's `/srv/<service>/` directory on behalf of the CLI.
|
files to any service's `/srv/<service>/` directory on behalf of the CLI.
|
||||||
|
`ProtectHome=false` because the `mcp` user's home is `/srv/mcp`.
|
||||||
|
|
||||||
### CLI Installation (on operator workstation)
|
### CLI Installation (on operator workstation)
|
||||||
|
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -12,6 +12,21 @@ MCP has two components:
|
|||||||
|
|
||||||
Services have one or more components (containers). Container naming: `<service>-<component>`.
|
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
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,7 +48,7 @@ Run a single test: `go test ./internal/registry/ -run TestComponentCRUD`
|
|||||||
|
|
||||||
- `cmd/mcp/` — CLI entry point
|
- `cmd/mcp/` — CLI entry point
|
||||||
- `cmd/mcp-agent/` — Agent 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/runtime/` — Container runtime abstraction (podman)
|
||||||
- `internal/registry/` — SQLite registry (services, components, events)
|
- `internal/registry/` — SQLite registry (services, components, events)
|
||||||
- `internal/monitor/` — Monitoring subsystem (watch loop, alerting)
|
- `internal/monitor/` — Monitoring subsystem (watch loop, alerting)
|
||||||
|
|||||||
22
Dockerfile.master
Normal file
22
Dockerfile.master
Normal 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"]
|
||||||
14
Makefile
14
Makefile
@@ -8,6 +8,9 @@ mcp:
|
|||||||
mcp-agent:
|
mcp-agent:
|
||||||
CGO_ENABLED=0 go build $(LDFLAGS) -o mcp-agent ./cmd/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:
|
build:
|
||||||
go build ./...
|
go build ./...
|
||||||
|
|
||||||
@@ -29,7 +32,12 @@ proto-lint:
|
|||||||
buf lint
|
buf lint
|
||||||
buf breaking --against '.git#branch=master,subdir=proto'
|
buf breaking --against '.git#branch=master,subdir=proto'
|
||||||
|
|
||||||
clean:
|
docker-master:
|
||||||
rm -f mcp mcp-agent
|
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
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
return agent.Run(cfg)
|
return agent.Run(cfg, version)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
49
cmd/mcp-master/main.go
Normal file
49
cmd/mcp-master/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/auth"
|
||||||
"git.wntrmute.dev/mc/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
||||||
@@ -52,6 +53,17 @@ func buildServiceImages(ctx context.Context, cfg *config.CLIConfig, def *service
|
|||||||
|
|
||||||
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
||||||
|
|
||||||
|
// Auto-login to the registry using the CLI's stored MCIAS token.
|
||||||
|
// MCR accepts JWTs as passwords, so this works for both human and
|
||||||
|
// service account tokens. Failures are non-fatal — existing podman
|
||||||
|
// auth may suffice.
|
||||||
|
if token, err := auth.LoadToken(cfg.Auth.TokenPath); err == nil && token != "" {
|
||||||
|
registry := extractRegistry(def)
|
||||||
|
if registry != "" {
|
||||||
|
_ = rt.Login(ctx, registry, "mcp", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for imageName, dockerfile := range def.Build.Images {
|
for imageName, dockerfile := range def.Build.Images {
|
||||||
if imageFilter != "" && imageName != imageFilter {
|
if imageFilter != "" && imageName != imageFilter {
|
||||||
continue
|
continue
|
||||||
@@ -96,6 +108,19 @@ func findImageRef(def *servicedef.ServiceDef, imageName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractRegistry returns the registry host from the first component's
|
||||||
|
// image reference (e.g., "mcr.svc.mcp.metacircular.net:8443" from
|
||||||
|
// "mcr.svc.mcp.metacircular.net:8443/mcq:v0.1.1"). Returns empty
|
||||||
|
// string if no slash is found.
|
||||||
|
func extractRegistry(def *servicedef.ServiceDef) string {
|
||||||
|
for _, c := range def.Components {
|
||||||
|
if i := strings.LastIndex(c.Image, "/"); i > 0 {
|
||||||
|
return c.Image[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractRepoName returns the repository name from an image reference.
|
// extractRepoName returns the repository name from an image reference.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
@@ -124,6 +149,8 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
|
|||||||
return nil // no build config, skip auto-build
|
return nil // no build config, skip auto-build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registryLoginDone := false
|
||||||
|
|
||||||
for _, c := range def.Components {
|
for _, c := range def.Components {
|
||||||
if component != "" && c.Name != component {
|
if component != "" && c.Name != component {
|
||||||
continue
|
continue
|
||||||
@@ -153,6 +180,17 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
|
|||||||
|
|
||||||
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
|
||||||
|
|
||||||
|
// Auto-login to registry before first push.
|
||||||
|
if !registryLoginDone {
|
||||||
|
if token, err := auth.LoadToken(cfg.Auth.TokenPath); err == nil && token != "" {
|
||||||
|
registry := extractRegistry(def)
|
||||||
|
if registry != "" {
|
||||||
|
_ = rt.Login(ctx, registry, "mcp", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registryLoginDone = true
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile)
|
fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile)
|
||||||
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
|
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
|
||||||
return fmt.Errorf("auto-build %s: %w", c.Image, err)
|
return fmt.Errorf("auto-build %s: %w", c.Image, err)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func deployCmd() *cobra.Command {
|
func deployCmd() *cobra.Command {
|
||||||
|
var direct bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "deploy <service>[/<component>]",
|
Use: "deploy <service>[/<component>]",
|
||||||
Short: "Deploy service from service definition",
|
Short: "Deploy service from service definition",
|
||||||
@@ -40,6 +42,12 @@ func deployCmd() *cobra.Command {
|
|||||||
|
|
||||||
spec := servicedef.ToProto(def)
|
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)
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -64,9 +72,48 @@ func deployCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringP("file", "f", "", "service definition file")
|
cmd.Flags().StringP("file", "f", "", "service definition file")
|
||||||
|
cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, deploy directly to agent (v1 mode)")
|
||||||
return cmd
|
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.
|
// parseServiceArg splits a "service/component" argument into its parts.
|
||||||
func parseServiceArg(arg string) (service, component string) {
|
func parseServiceArg(arg string) (service, component string) {
|
||||||
parts := strings.SplitN(arg, "/", 2)
|
parts := strings.SplitN(arg, "/", 2)
|
||||||
@@ -125,8 +172,9 @@ func loadServiceDef(cmd *cobra.Command, cfg *config.CLIConfig, serviceName strin
|
|||||||
// ServiceInfo, not ServiceSpec.
|
// ServiceInfo, not ServiceSpec.
|
||||||
func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
|
func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
|
||||||
spec := &mcpv1.ServiceSpec{
|
spec := &mcpv1.ServiceSpec{
|
||||||
Name: info.GetName(),
|
Name: info.GetName(),
|
||||||
Active: info.GetActive(),
|
Active: info.GetActive(),
|
||||||
|
Comment: info.GetComment(),
|
||||||
}
|
}
|
||||||
for _, c := range info.GetComponents() {
|
for _, c := range info.GetComponents() {
|
||||||
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
|
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClie
|
|||||||
address,
|
address,
|
||||||
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
|
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
|
||||||
grpc.WithUnaryInterceptor(tokenInterceptor(token)),
|
grpc.WithUnaryInterceptor(tokenInterceptor(token)),
|
||||||
|
grpc.WithStreamInterceptor(streamTokenInterceptor(token)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("dial %q: %w", address, err)
|
return nil, nil, fmt.Errorf("dial %q: %w", address, err)
|
||||||
@@ -51,6 +52,43 @@ func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClie
|
|||||||
return mcpv1.NewMcpAgentServiceClient(conn), conn, nil
|
return mcpv1.NewMcpAgentServiceClient(conn), conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// tokenInterceptor returns a gRPC client interceptor that attaches the
|
||||||
// bearer token to outgoing RPC metadata.
|
// bearer token to outgoing RPC metadata.
|
||||||
func tokenInterceptor(token string) grpc.UnaryClientInterceptor {
|
func tokenInterceptor(token string) grpc.UnaryClientInterceptor {
|
||||||
@@ -60,6 +98,15 @@ func tokenInterceptor(token string) grpc.UnaryClientInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// streamTokenInterceptor returns a gRPC client stream interceptor that
|
||||||
|
// attaches the bearer token to outgoing stream metadata.
|
||||||
|
func streamTokenInterceptor(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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// loadBearerToken reads the token from file or env var.
|
// loadBearerToken reads the token from file or env var.
|
||||||
func loadBearerToken(cfg *config.CLIConfig) (string, error) {
|
func loadBearerToken(cfg *config.CLIConfig) (string, error) {
|
||||||
if token := os.Getenv("MCP_TOKEN"); token != "" {
|
if token := os.Getenv("MCP_TOKEN"); token != "" {
|
||||||
|
|||||||
87
cmd/mcp/dns.go
Normal file
87
cmd/mcp/dns.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dnsCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "dns",
|
||||||
|
Short: "List all DNS zones and records from MCNS",
|
||||||
|
RunE: runDNS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDNS(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS is centralized — query the first reachable agent.
|
||||||
|
resp, nodeName, err := queryDNS(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.GetZones()) == 0 {
|
||||||
|
fmt.Println("no DNS zones configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = nodeName
|
||||||
|
for i, zone := range resp.GetZones() {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
fmt.Printf("ZONE: %s\n", zone.GetName())
|
||||||
|
|
||||||
|
if len(zone.GetRecords()) == 0 {
|
||||||
|
fmt.Println(" (no records)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
w := newTable()
|
||||||
|
_, _ = fmt.Fprintln(w, " NAME\tTYPE\tVALUE\tTTL")
|
||||||
|
for _, r := range zone.GetRecords() {
|
||||||
|
_, _ = fmt.Fprintf(w, " %s\t%s\t%s\t%d\n",
|
||||||
|
r.GetName(), r.GetType(), r.GetValue(), r.GetTtl())
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryDNS tries each configured agent and returns the first successful
|
||||||
|
// DNS listing. DNS is centralized so any agent with MCNS configured works.
|
||||||
|
func queryDNS(cfg *config.CLIConfig) (*mcpv1.ListDNSRecordsResponse, string, error) {
|
||||||
|
for _, node := range cfg.Nodes {
|
||||||
|
client, conn, err := dialAgent(node.Address, cfg)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: %v\n", node.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
resp, err := client.ListDNSRecords(ctx, &mcpv1.ListDNSRecordsRequest{})
|
||||||
|
cancel()
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: list DNS: %v\n", node.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, node.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("no reachable agent with DNS configured")
|
||||||
|
}
|
||||||
180
cmd/mcp/edge.go
Normal file
180
cmd/mcp/edge.go
Normal 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
|
||||||
|
}
|
||||||
12
cmd/mcp/edit.go
Normal file
12
cmd/mcp/edit.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func editCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "edit <service>",
|
||||||
|
Short: "Open service definition in $EDITOR",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runServiceEdit,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
|
|
||||||
func stopCmd() *cobra.Command {
|
func stopCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "stop <service>",
|
Use: "stop <service>[/<component>]",
|
||||||
Short: "Stop all components, set active=false",
|
Short: "Stop components (or all), set active=false",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.LoadCLIConfig(cfgPath)
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
@@ -23,7 +23,7 @@ func stopCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := args[0]
|
serviceName, component := parseServiceArg(args[0])
|
||||||
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
||||||
|
|
||||||
def, err := servicedef.Load(defPath)
|
def, err := servicedef.Load(defPath)
|
||||||
@@ -31,10 +31,13 @@ func stopCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("load service def: %w", err)
|
return fmt.Errorf("load service def: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
active := false
|
// Only flip active=false when stopping the whole service.
|
||||||
def.Active = &active
|
if component == "" {
|
||||||
if err := servicedef.Write(defPath, def); err != nil {
|
active := false
|
||||||
return fmt.Errorf("write service def: %w", err)
|
def.Active = &active
|
||||||
|
if err := servicedef.Write(defPath, def); err != nil {
|
||||||
|
return fmt.Errorf("write service def: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
address, err := findNodeAddress(cfg, def.Node)
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
@@ -49,7 +52,8 @@ func stopCmd() *cobra.Command {
|
|||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
resp, err := client.StopService(context.Background(), &mcpv1.StopServiceRequest{
|
resp, err := client.StopService(context.Background(), &mcpv1.StopServiceRequest{
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
|
Component: component,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stop service: %w", err)
|
return fmt.Errorf("stop service: %w", err)
|
||||||
@@ -63,8 +67,8 @@ func stopCmd() *cobra.Command {
|
|||||||
|
|
||||||
func startCmd() *cobra.Command {
|
func startCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "start <service>",
|
Use: "start <service>[/<component>]",
|
||||||
Short: "Start all components, set active=true",
|
Short: "Start components (or all), set active=true",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.LoadCLIConfig(cfgPath)
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
@@ -72,7 +76,7 @@ func startCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := args[0]
|
serviceName, component := parseServiceArg(args[0])
|
||||||
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
||||||
|
|
||||||
def, err := servicedef.Load(defPath)
|
def, err := servicedef.Load(defPath)
|
||||||
@@ -80,10 +84,13 @@ func startCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("load service def: %w", err)
|
return fmt.Errorf("load service def: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
active := true
|
// Only flip active=true when starting the whole service.
|
||||||
def.Active = &active
|
if component == "" {
|
||||||
if err := servicedef.Write(defPath, def); err != nil {
|
active := true
|
||||||
return fmt.Errorf("write service def: %w", err)
|
def.Active = &active
|
||||||
|
if err := servicedef.Write(defPath, def); err != nil {
|
||||||
|
return fmt.Errorf("write service def: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
address, err := findNodeAddress(cfg, def.Node)
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
@@ -98,7 +105,8 @@ func startCmd() *cobra.Command {
|
|||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
resp, err := client.StartService(context.Background(), &mcpv1.StartServiceRequest{
|
resp, err := client.StartService(context.Background(), &mcpv1.StartServiceRequest{
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
|
Component: component,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("start service: %w", err)
|
return fmt.Errorf("start service: %w", err)
|
||||||
@@ -112,8 +120,8 @@ func startCmd() *cobra.Command {
|
|||||||
|
|
||||||
func restartCmd() *cobra.Command {
|
func restartCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "restart <service>",
|
Use: "restart <service>[/<component>]",
|
||||||
Short: "Restart all components",
|
Short: "Restart components (or all)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.LoadCLIConfig(cfgPath)
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
@@ -121,7 +129,7 @@ func restartCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := args[0]
|
serviceName, component := parseServiceArg(args[0])
|
||||||
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
|
||||||
|
|
||||||
def, err := servicedef.Load(defPath)
|
def, err := servicedef.Load(defPath)
|
||||||
@@ -141,7 +149,8 @@ func restartCmd() *cobra.Command {
|
|||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
resp, err := client.RestartService(context.Background(), &mcpv1.RestartServiceRequest{
|
resp, err := client.RestartService(context.Background(), &mcpv1.RestartServiceRequest{
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
|
Component: component,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restart service: %w", err)
|
return fmt.Errorf("restart service: %w", err)
|
||||||
|
|||||||
81
cmd/mcp/logs.go
Normal file
81
cmd/mcp/logs.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logsCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
tail int
|
||||||
|
follow bool
|
||||||
|
timestamps bool
|
||||||
|
since string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logs <service>[/<component>]",
|
||||||
|
Short: "Show container logs",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceName, component := parseServiceArg(args[0])
|
||||||
|
|
||||||
|
def, err := loadServiceDef(cmd, cfg, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
|
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() }()
|
||||||
|
|
||||||
|
stream, err := client.Logs(cmd.Context(), &mcpv1.LogsRequest{
|
||||||
|
Service: serviceName,
|
||||||
|
Component: component,
|
||||||
|
Tail: int32(tail),
|
||||||
|
Follow: follow,
|
||||||
|
Timestamps: timestamps,
|
||||||
|
Since: since,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
resp, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recv: %w", err)
|
||||||
|
}
|
||||||
|
_, _ = os.Stdout.Write(resp.Data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().IntVarP(&tail, "tail", "n", 0, "number of lines from end (0 = all)")
|
||||||
|
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow log output")
|
||||||
|
cmd.Flags().BoolVarP(×tamps, "timestamps", "t", false, "show timestamps")
|
||||||
|
cmd.Flags().StringVar(&since, "since", "", "show logs since (e.g., 2h, 2026-03-28T00:00:00Z)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -50,6 +50,11 @@ func main() {
|
|||||||
root.AddCommand(pullCmd())
|
root.AddCommand(pullCmd())
|
||||||
root.AddCommand(nodeCmd())
|
root.AddCommand(nodeCmd())
|
||||||
root.AddCommand(purgeCmd())
|
root.AddCommand(purgeCmd())
|
||||||
|
root.AddCommand(logsCmd())
|
||||||
|
root.AddCommand(editCmd())
|
||||||
|
root.AddCommand(dnsCmd())
|
||||||
|
root.AddCommand(routeCmd())
|
||||||
|
root.AddCommand(edgeCmd())
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
toml "github.com/pelletier/go-toml/v2"
|
toml "github.com/pelletier/go-toml/v2"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/mc/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -48,13 +51,35 @@ func runNodeList(_ *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
_, _ = fmt.Fprintln(w, "NAME\tADDRESS")
|
_, _ = fmt.Fprintln(w, "NAME\tADDRESS\tVERSION")
|
||||||
for _, n := range cfg.Nodes {
|
for _, n := range cfg.Nodes {
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", n.Name, n.Address)
|
ver := queryAgentVersion(cfg, n.Address)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", n.Name, n.Address, ver)
|
||||||
}
|
}
|
||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryAgentVersion dials the agent and returns its version, or an error indicator.
|
||||||
|
func queryAgentVersion(cfg *config.CLIConfig, address string) string {
|
||||||
|
client, conn, err := dialAgent(address, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := client.NodeStatus(ctx, &mcpv1.NodeStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
if resp.AgentVersion == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return resp.AgentVersion
|
||||||
|
}
|
||||||
|
|
||||||
func runNodeAdd(_ *cobra.Command, args []string) error {
|
func runNodeAdd(_ *cobra.Command, args []string) error {
|
||||||
cfg, err := config.LoadCLIConfig(cfgPath)
|
cfg, err := config.LoadCLIConfig(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
225
cmd/mcp/route.go
Normal file
225
cmd/mcp/route.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func routeCmd() *cobra.Command {
|
||||||
|
var nodeName string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "route",
|
||||||
|
Short: "Manage mc-proxy routes",
|
||||||
|
}
|
||||||
|
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List mc-proxy routes",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return runRouteList(nodeName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
routeMode string
|
||||||
|
backendTLS bool
|
||||||
|
tlsCert string
|
||||||
|
tlsKey string
|
||||||
|
)
|
||||||
|
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add <listener> <hostname> <backend>",
|
||||||
|
Short: "Add a route to mc-proxy",
|
||||||
|
Long: "Add a route. Example: mcp route add -n rift :443 mcq.svc.mcp.metacircular.net 127.0.0.1:48080 --mode l7 --tls-cert /srv/mc-proxy/certs/mcq.pem --tls-key /srv/mc-proxy/certs/mcq.key",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
return runRouteAdd(nodeName, args, routeMode, backendTLS, tlsCert, tlsKey)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&routeMode, "mode", "l4", "route mode (l4 or l7)")
|
||||||
|
add.Flags().BoolVar(&backendTLS, "backend-tls", false, "re-encrypt traffic to backend")
|
||||||
|
add.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS cert on the node (required for l7)")
|
||||||
|
add.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS key on the node (required for l7)")
|
||||||
|
|
||||||
|
remove := &cobra.Command{
|
||||||
|
Use: "remove <listener> <hostname>",
|
||||||
|
Short: "Remove a route from mc-proxy",
|
||||||
|
Long: "Remove a route. Example: mcp route remove -n rift :443 mcq.metacircular.net",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
return runRouteRemove(nodeName, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringVarP(&nodeName, "node", "n", "", "target node (required)")
|
||||||
|
|
||||||
|
cmd.AddCommand(list, add, remove)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRouteList(nodeName string) error {
|
||||||
|
if nodeName == "" {
|
||||||
|
return runRouteListAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ListProxyRoutes(ctx, &mcpv1.ListProxyRoutesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list routes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printRoutes(nodeName, resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRouteListAll() error {
|
||||||
|
first := true
|
||||||
|
return forEachNode(func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := client.ListProxyRoutes(ctx, &mcpv1.ListProxyRoutesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: list routes: %v\n", node.Name, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !first {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
printRoutes(node.Name, resp)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRoutes(nodeName string, resp *mcpv1.ListProxyRoutesResponse) {
|
||||||
|
fmt.Printf("NODE: %s\n", nodeName)
|
||||||
|
fmt.Printf("mc-proxy %s\n", resp.GetVersion())
|
||||||
|
if resp.GetStartedAt() != nil {
|
||||||
|
uptime := time.Since(resp.GetStartedAt().AsTime()).Truncate(time.Second)
|
||||||
|
fmt.Printf("uptime: %s\n", uptime)
|
||||||
|
}
|
||||||
|
fmt.Printf("connections: %d\n", resp.GetTotalConnections())
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for _, ls := range resp.GetListeners() {
|
||||||
|
fmt.Printf(" %s routes=%d active=%d\n",
|
||||||
|
ls.GetAddr(), ls.GetRouteCount(), ls.GetActiveConnections())
|
||||||
|
for _, r := range ls.GetRoutes() {
|
||||||
|
mode := r.GetMode()
|
||||||
|
if mode == "" {
|
||||||
|
mode = "l4"
|
||||||
|
}
|
||||||
|
extra := ""
|
||||||
|
if r.GetBackendTls() {
|
||||||
|
extra = " (re-encrypt)"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s → %s%s\n", mode, r.GetHostname(), r.GetBackend(), extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRouteAdd(nodeName string, args []string, mode string, backendTLS bool, tlsCert, tlsKey string) error {
|
||||||
|
if nodeName == "" {
|
||||||
|
return fmt.Errorf("--node is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
_, err = client.AddProxyRoute(ctx, &mcpv1.AddProxyRouteRequest{
|
||||||
|
ListenerAddr: args[0],
|
||||||
|
Hostname: args[1],
|
||||||
|
Backend: args[2],
|
||||||
|
Mode: mode,
|
||||||
|
BackendTls: backendTLS,
|
||||||
|
TlsCert: tlsCert,
|
||||||
|
TlsKey: tlsKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add route: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added route: %s %s → %s on %s (%s)\n", mode, args[1], args[2], args[0], nodeName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRouteRemove(nodeName string, args []string) error {
|
||||||
|
if nodeName == "" {
|
||||||
|
return fmt.Errorf("--node is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
_, err = client.RemoveProxyRoute(ctx, &mcpv1.RemoveProxyRouteRequest{
|
||||||
|
ListenerAddr: args[0],
|
||||||
|
Hostname: args[1],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove route: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Removed route: %s from %s (%s)\n", args[1], args[0], nodeName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func undeployCmd() *cobra.Command {
|
func undeployCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var direct bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "undeploy <service>",
|
Use: "undeploy <service>",
|
||||||
Short: "Fully undeploy a service: remove routes, DNS, certs, and containers",
|
Short: "Fully undeploy a service: remove routes, DNS, certs, and containers",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
@@ -38,6 +40,11 @@ func undeployCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("write service def: %w", err)
|
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)
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -60,4 +67,28 @@ func undeployCmd() *cobra.Command {
|
|||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
94
deploy/examples/mcp-master.toml
Normal file
94
deploy/examples/mcp-master.toml
Normal 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"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
let
|
let
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
version = "0.6.0";
|
version = "0.8.3";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages.${system} = {
|
packages.${system} = {
|
||||||
|
|||||||
849
gen/mcp/v1/master.pb.go
Normal file
849
gen/mcp/v1/master.pb.go
Normal 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
|
||||||
|
}
|
||||||
247
gen/mcp/v1/master_grpc.pb.go
Normal file
247
gen/mcp/v1/master_grpc.pb.go
Normal 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",
|
||||||
|
}
|
||||||
1943
gen/mcp/v1/mcp.pb.go
1943
gen/mcp/v1/mcp.pb.go
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,15 @@ const (
|
|||||||
McpAgentService_PushFile_FullMethodName = "/mcp.v1.McpAgentService/PushFile"
|
McpAgentService_PushFile_FullMethodName = "/mcp.v1.McpAgentService/PushFile"
|
||||||
McpAgentService_PullFile_FullMethodName = "/mcp.v1.McpAgentService/PullFile"
|
McpAgentService_PullFile_FullMethodName = "/mcp.v1.McpAgentService/PullFile"
|
||||||
McpAgentService_NodeStatus_FullMethodName = "/mcp.v1.McpAgentService/NodeStatus"
|
McpAgentService_NodeStatus_FullMethodName = "/mcp.v1.McpAgentService/NodeStatus"
|
||||||
|
McpAgentService_ListDNSRecords_FullMethodName = "/mcp.v1.McpAgentService/ListDNSRecords"
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// McpAgentServiceClient is the client API for McpAgentService service.
|
// McpAgentServiceClient is the client API for McpAgentService service.
|
||||||
@@ -60,6 +69,20 @@ type McpAgentServiceClient interface {
|
|||||||
PullFile(ctx context.Context, in *PullFileRequest, opts ...grpc.CallOption) (*PullFileResponse, error)
|
PullFile(ctx context.Context, in *PullFileRequest, opts ...grpc.CallOption) (*PullFileResponse, error)
|
||||||
// Node
|
// Node
|
||||||
NodeStatus(ctx context.Context, in *NodeStatusRequest, opts ...grpc.CallOption) (*NodeStatusResponse, error)
|
NodeStatus(ctx context.Context, in *NodeStatusRequest, opts ...grpc.CallOption) (*NodeStatusResponse, error)
|
||||||
|
// DNS (query MCNS)
|
||||||
|
ListDNSRecords(ctx context.Context, in *ListDNSRecordsRequest, opts ...grpc.CallOption) (*ListDNSRecordsResponse, error)
|
||||||
|
// Proxy routes (query mc-proxy)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mcpAgentServiceClient struct {
|
type mcpAgentServiceClient struct {
|
||||||
@@ -210,6 +233,105 @@ func (c *mcpAgentServiceClient) NodeStatus(ctx context.Context, in *NodeStatusRe
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mcpAgentServiceClient) ListDNSRecords(ctx context.Context, in *ListDNSRecordsRequest, opts ...grpc.CallOption) (*ListDNSRecordsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListDNSRecordsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, McpAgentService_ListDNSRecords_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpAgentServiceClient) ListProxyRoutes(ctx context.Context, in *ListProxyRoutesRequest, opts ...grpc.CallOption) (*ListProxyRoutesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListProxyRoutesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, McpAgentService_ListProxyRoutes_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpAgentServiceClient) AddProxyRoute(ctx context.Context, in *AddProxyRouteRequest, opts ...grpc.CallOption) (*AddProxyRouteResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(AddProxyRouteResponse)
|
||||||
|
err := c.cc.Invoke(ctx, McpAgentService_AddProxyRoute_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpAgentServiceClient) RemoveProxyRoute(ctx context.Context, in *RemoveProxyRouteRequest, opts ...grpc.CallOption) (*RemoveProxyRouteResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(RemoveProxyRouteResponse)
|
||||||
|
err := c.cc.Invoke(ctx, McpAgentService_RemoveProxyRoute_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[LogsRequest, LogsResponse]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type McpAgentService_LogsClient = grpc.ServerStreamingClient[LogsResponse]
|
||||||
|
|
||||||
// McpAgentServiceServer is the server API for McpAgentService service.
|
// McpAgentServiceServer is the server API for McpAgentService service.
|
||||||
// All implementations must embed UnimplementedMcpAgentServiceServer
|
// All implementations must embed UnimplementedMcpAgentServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -235,6 +357,20 @@ type McpAgentServiceServer interface {
|
|||||||
PullFile(context.Context, *PullFileRequest) (*PullFileResponse, error)
|
PullFile(context.Context, *PullFileRequest) (*PullFileResponse, error)
|
||||||
// Node
|
// Node
|
||||||
NodeStatus(context.Context, *NodeStatusRequest) (*NodeStatusResponse, error)
|
NodeStatus(context.Context, *NodeStatusRequest) (*NodeStatusResponse, error)
|
||||||
|
// DNS (query MCNS)
|
||||||
|
ListDNSRecords(context.Context, *ListDNSRecordsRequest) (*ListDNSRecordsResponse, error)
|
||||||
|
// Proxy routes (query mc-proxy)
|
||||||
|
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()
|
mustEmbedUnimplementedMcpAgentServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +423,33 @@ func (UnimplementedMcpAgentServiceServer) PullFile(context.Context, *PullFileReq
|
|||||||
func (UnimplementedMcpAgentServiceServer) NodeStatus(context.Context, *NodeStatusRequest) (*NodeStatusResponse, error) {
|
func (UnimplementedMcpAgentServiceServer) NodeStatus(context.Context, *NodeStatusRequest) (*NodeStatusResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method NodeStatus not implemented")
|
return nil, status.Error(codes.Unimplemented, "method NodeStatus not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedMcpAgentServiceServer) ListDNSRecords(context.Context, *ListDNSRecordsRequest) (*ListDNSRecordsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListDNSRecords not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMcpAgentServiceServer) ListProxyRoutes(context.Context, *ListProxyRoutesRequest) (*ListProxyRoutesResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method ListProxyRoutes not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMcpAgentServiceServer) AddProxyRoute(context.Context, *AddProxyRouteRequest) (*AddProxyRouteResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method AddProxyRoute not implemented")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
func (UnimplementedMcpAgentServiceServer) mustEmbedUnimplementedMcpAgentServiceServer() {}
|
func (UnimplementedMcpAgentServiceServer) mustEmbedUnimplementedMcpAgentServiceServer() {}
|
||||||
func (UnimplementedMcpAgentServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedMcpAgentServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -560,6 +723,161 @@ func _McpAgentService_NodeStatus_Handler(srv interface{}, ctx context.Context, d
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _McpAgentService_ListDNSRecords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListDNSRecordsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(McpAgentServiceServer).ListDNSRecords(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: McpAgentService_ListDNSRecords_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(McpAgentServiceServer).ListDNSRecords(ctx, req.(*ListDNSRecordsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _McpAgentService_ListProxyRoutes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListProxyRoutesRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(McpAgentServiceServer).ListProxyRoutes(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: McpAgentService_ListProxyRoutes_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(McpAgentServiceServer).ListProxyRoutes(ctx, req.(*ListProxyRoutesRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _McpAgentService_AddProxyRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AddProxyRouteRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(McpAgentServiceServer).AddProxyRoute(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: McpAgentService_AddProxyRoute_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(McpAgentServiceServer).AddProxyRoute(ctx, req.(*AddProxyRouteRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _McpAgentService_RemoveProxyRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveProxyRouteRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(McpAgentServiceServer).RemoveProxyRoute(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: McpAgentService_RemoveProxyRoute_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(McpAgentServiceServer).RemoveProxyRoute(ctx, req.(*RemoveProxyRouteRequest))
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(McpAgentServiceServer).Logs(m, &grpc.GenericServerStream[LogsRequest, LogsResponse]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type McpAgentService_LogsServer = grpc.ServerStreamingServer[LogsResponse]
|
||||||
|
|
||||||
// McpAgentService_ServiceDesc is the grpc.ServiceDesc for McpAgentService service.
|
// McpAgentService_ServiceDesc is the grpc.ServiceDesc for McpAgentService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -623,7 +941,45 @@ var McpAgentService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "NodeStatus",
|
MethodName: "NodeStatus",
|
||||||
Handler: _McpAgentService_NodeStatus_Handler,
|
Handler: _McpAgentService_NodeStatus_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListDNSRecords",
|
||||||
|
Handler: _McpAgentService_ListDNSRecords_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListProxyRoutes",
|
||||||
|
Handler: _McpAgentService_ListProxyRoutes_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AddProxyRoute",
|
||||||
|
Handler: _McpAgentService_AddProxyRoute_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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{
|
||||||
|
{
|
||||||
|
StreamName: "Logs",
|
||||||
|
Handler: _McpAgentService_Logs_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
|
||||||
Metadata: "proto/mcp/v1/mcp.proto",
|
Metadata: "proto/mcp/v1/mcp.proto",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ type Agent struct {
|
|||||||
Proxy *ProxyRouter
|
Proxy *ProxyRouter
|
||||||
Certs *CertProvisioner
|
Certs *CertProvisioner
|
||||||
DNS *DNSRegistrar
|
DNS *DNSRegistrar
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the agent: opens the database, sets up the gRPC server with
|
// Run starts the agent: opens the database, sets up the gRPC server with
|
||||||
// TLS and auth, and blocks until SIGINT/SIGTERM.
|
// TLS and auth, and blocks until SIGINT/SIGTERM.
|
||||||
func Run(cfg *config.AgentConfig) error {
|
func Run(cfg *config.AgentConfig, version string) error {
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
Level: parseLogLevel(cfg.Log.Level),
|
Level: parseLogLevel(cfg.Log.Level),
|
||||||
}))
|
}))
|
||||||
@@ -79,6 +80,7 @@ func Run(cfg *config.AgentConfig) error {
|
|||||||
Proxy: proxy,
|
Proxy: proxy,
|
||||||
Certs: certs,
|
Certs: certs,
|
||||||
DNS: dns,
|
DNS: dns,
|
||||||
|
Version: version,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
||||||
@@ -100,6 +102,9 @@ func Run(cfg *config.AgentConfig) error {
|
|||||||
grpc.ChainUnaryInterceptor(
|
grpc.ChainUnaryInterceptor(
|
||||||
auth.AuthInterceptor(validator),
|
auth.AuthInterceptor(validator),
|
||||||
),
|
),
|
||||||
|
grpc.ChainStreamInterceptor(
|
||||||
|
auth.StreamAuthInterceptor(validator),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
mcpv1.RegisterMcpAgentServiceServer(server, a)
|
mcpv1.RegisterMcpAgentServiceServer(server, a)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func (a *Agent) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.De
|
|||||||
filtered = append(filtered, cs)
|
filtered = append(filtered, cs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil, fmt.Errorf("component %q not found in service %q", target, serviceName)
|
||||||
|
}
|
||||||
components = filtered
|
components = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +134,8 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
|
|||||||
Error: fmt.Sprintf("allocate route ports: %v", err),
|
Error: fmt.Sprintf("allocate route ports: %v", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runSpec.Ports = ports
|
// Merge explicit ports from the spec with route-allocated ports.
|
||||||
|
runSpec.Ports = append(cs.GetPorts(), ports...)
|
||||||
runSpec.Env = append(runSpec.Env, env...)
|
runSpec.Env = append(runSpec.Env, env...)
|
||||||
} else {
|
} else {
|
||||||
// Legacy: use ports directly from the spec.
|
// Legacy: use ports directly from the spec.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ type DNSRegistrar struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// dnsRecord is the JSON representation of an MCNS record.
|
// DNSRecord is the JSON representation of an MCNS record.
|
||||||
type dnsRecord struct {
|
type DNSRecord struct {
|
||||||
ID int `json:"ID"`
|
ID int `json:"ID"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Type string `json:"Type"`
|
Type string `json:"Type"`
|
||||||
@@ -136,8 +136,87 @@ func (d *DNSRegistrar) RemoveRecord(ctx context.Context, serviceName string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNSZone is the JSON representation of an MCNS zone.
|
||||||
|
type DNSZone struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListZones returns all zones from MCNS.
|
||||||
|
func (d *DNSRegistrar) ListZones(ctx context.Context) ([]DNSZone, error) {
|
||||||
|
if d == nil {
|
||||||
|
return nil, fmt.Errorf("DNS registrar not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/v1/zones", d.serverURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create list zones request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+d.token)
|
||||||
|
|
||||||
|
resp, err := d.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list zones: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read list zones response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("list zones: mcns returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Zones []DNSZone `json:"zones"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse list zones response: %w", err)
|
||||||
|
}
|
||||||
|
return envelope.Zones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListZoneRecords returns all records in the given zone (no filters).
|
||||||
|
func (d *DNSRegistrar) ListZoneRecords(ctx context.Context, zone string) ([]DNSRecord, error) {
|
||||||
|
if d == nil {
|
||||||
|
return nil, fmt.Errorf("DNS registrar not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, zone)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create list zone records request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+d.token)
|
||||||
|
|
||||||
|
resp, err := d.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list zone records: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read list zone records response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("list zone 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 zone records response: %w", err)
|
||||||
|
}
|
||||||
|
return envelope.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
// listRecords returns A records matching the service name in the zone.
|
// listRecords returns A records matching the service name in the zone.
|
||||||
func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]dnsRecord, error) {
|
func (d *DNSRegistrar) 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)
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,7 +240,7 @@ func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]d
|
|||||||
}
|
}
|
||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Records []dnsRecord `json:"records"`
|
Records []DNSRecord `json:"records"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
return nil, fmt.Errorf("parse list response: %w", err)
|
return nil, fmt.Errorf("parse list response: %w", err)
|
||||||
|
|||||||
40
internal/agent/dns_rpc.go
Normal file
40
internal/agent/dns_rpc.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListDNSRecords queries MCNS for all zones and their records.
|
||||||
|
func (a *Agent) ListDNSRecords(ctx context.Context, _ *mcpv1.ListDNSRecordsRequest) (*mcpv1.ListDNSRecordsResponse, error) {
|
||||||
|
a.Logger.Debug("ListDNSRecords called")
|
||||||
|
|
||||||
|
zones, err := a.DNS.ListZones(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list zones: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &mcpv1.ListDNSRecordsResponse{}
|
||||||
|
for _, z := range zones {
|
||||||
|
records, err := a.DNS.ListZoneRecords(ctx, z.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list records for zone %q: %w", z.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zone := &mcpv1.DNSZone{Name: z.Name}
|
||||||
|
for _, r := range records {
|
||||||
|
zone.Records = append(zone.Records, &mcpv1.DNSRecord{
|
||||||
|
Id: int64(r.ID),
|
||||||
|
Name: r.Name,
|
||||||
|
Type: r.Type,
|
||||||
|
Value: r.Value,
|
||||||
|
Ttl: int32(r.TTL), //nolint:gosec // TTL is bounded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp.Zones = append(resp.Zones, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func TestEnsureRecordSkipsWhenExists(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
// Return an existing record with the correct value.
|
// Return an existing record with the correct value.
|
||||||
resp := map[string][]dnsRecord{"records": {{ID: 1, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
resp := map[string][]DNSRecord{"records": {{ID: 1, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
return
|
return
|
||||||
@@ -124,7 +124,7 @@ func TestEnsureRecordUpdatesWrongValue(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
// Return a record with a stale value.
|
// Return a record with a stale value.
|
||||||
resp := map[string][]dnsRecord{"records": {{ID: 42, Name: "myservice", Type: "A", Value: "10.0.0.1", TTL: 300}}}
|
resp := map[string][]DNSRecord{"records": {{ID: 42, Name: "myservice", Type: "A", Value: "10.0.0.1", TTL: 300}}}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
return
|
return
|
||||||
@@ -160,7 +160,7 @@ func TestRemoveRecordDeletes(t *testing.T) {
|
|||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
resp := map[string][]dnsRecord{"records": {{ID: 7, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
resp := map[string][]DNSRecord{"records": {{ID: 7, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
return
|
return
|
||||||
|
|||||||
196
internal/agent/edge_rpc.go
Normal file
196
internal/agent/edge_rpc.go
Normal 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
|
||||||
|
}
|
||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StopService stops all components of a service.
|
// StopService stops all components of a service, or a single component if specified.
|
||||||
func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest) (*mcpv1.StopServiceResponse, error) {
|
func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest) (*mcpv1.StopServiceResponse, error) {
|
||||||
a.Logger.Info("StopService", "service", req.GetName())
|
a.Logger.Info("StopService", "service", req.GetName(), "component", req.GetComponent())
|
||||||
|
|
||||||
if req.GetName() == "" {
|
if req.GetName() == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
||||||
@@ -25,6 +25,13 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
|
|||||||
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if target := req.GetComponent(); target != "" {
|
||||||
|
components, err = filterComponents(components, req.GetName(), target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var results []*mcpv1.ComponentResult
|
var results []*mcpv1.ComponentResult
|
||||||
for _, c := range components {
|
for _, c := range components {
|
||||||
containerName := ContainerNameFor(req.GetName(), c.Name)
|
containerName := ContainerNameFor(req.GetName(), c.Name)
|
||||||
@@ -59,10 +66,10 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
|
|||||||
return &mcpv1.StopServiceResponse{Results: results}, nil
|
return &mcpv1.StopServiceResponse{Results: results}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartService starts all components of a service. If a container already
|
// StartService starts all components of a service, or a single component if specified.
|
||||||
// exists but is stopped, it is removed first so a fresh one can be created.
|
// If a container already exists but is stopped, it is removed first so a fresh one can be created.
|
||||||
func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest) (*mcpv1.StartServiceResponse, error) {
|
func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest) (*mcpv1.StartServiceResponse, error) {
|
||||||
a.Logger.Info("StartService", "service", req.GetName())
|
a.Logger.Info("StartService", "service", req.GetName(), "component", req.GetComponent())
|
||||||
|
|
||||||
if req.GetName() == "" {
|
if req.GetName() == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
||||||
@@ -73,6 +80,13 @@ func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest
|
|||||||
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if target := req.GetComponent(); target != "" {
|
||||||
|
components, err = filterComponents(components, req.GetName(), target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var results []*mcpv1.ComponentResult
|
var results []*mcpv1.ComponentResult
|
||||||
for _, c := range components {
|
for _, c := range components {
|
||||||
r := startComponent(ctx, a, req.GetName(), &c)
|
r := startComponent(ctx, a, req.GetName(), &c)
|
||||||
@@ -82,10 +96,10 @@ func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest
|
|||||||
return &mcpv1.StartServiceResponse{Results: results}, nil
|
return &mcpv1.StartServiceResponse{Results: results}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartService restarts all components of a service by stopping, removing,
|
// RestartService restarts all components of a service, or a single component if specified,
|
||||||
// and re-creating each container. The desired_state is not changed.
|
// by stopping, removing, and re-creating each container. The desired_state is not changed.
|
||||||
func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceRequest) (*mcpv1.RestartServiceResponse, error) {
|
func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceRequest) (*mcpv1.RestartServiceResponse, error) {
|
||||||
a.Logger.Info("RestartService", "service", req.GetName())
|
a.Logger.Info("RestartService", "service", req.GetName(), "component", req.GetComponent())
|
||||||
|
|
||||||
if req.GetName() == "" {
|
if req.GetName() == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
return nil, status.Error(codes.InvalidArgument, "service name is required")
|
||||||
@@ -96,6 +110,13 @@ func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceReq
|
|||||||
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
return nil, status.Errorf(codes.Internal, "list components: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if target := req.GetComponent(); target != "" {
|
||||||
|
components, err = filterComponents(components, req.GetName(), target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var results []*mcpv1.ComponentResult
|
var results []*mcpv1.ComponentResult
|
||||||
for _, c := range components {
|
for _, c := range components {
|
||||||
r := restartComponent(ctx, a, req.GetName(), &c)
|
r := restartComponent(ctx, a, req.GetName(), &c)
|
||||||
@@ -167,6 +188,16 @@ func componentToSpec(service string, c *registry.Component) runtime.ContainerSpe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterComponents returns only the component matching target, or an error if not found.
|
||||||
|
func filterComponents(components []registry.Component, service, target string) ([]registry.Component, error) {
|
||||||
|
for _, c := range components {
|
||||||
|
if c.Name == target {
|
||||||
|
return []registry.Component{c}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.NotFound, "component %q not found in service %q", target, service)
|
||||||
|
}
|
||||||
|
|
||||||
// componentExists checks whether a component already exists in the registry.
|
// componentExists checks whether a component already exists in the registry.
|
||||||
func componentExists(db *sql.DB, service, name string) bool {
|
func componentExists(db *sql.DB, service, name string) bool {
|
||||||
_, err := registry.GetComponent(db, service, name)
|
_, err := registry.GetComponent(db, service, name)
|
||||||
|
|||||||
79
internal/agent/logs.go
Normal file
79
internal/agent/logs.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logs streams container logs for a service component.
|
||||||
|
func (a *Agent) Logs(req *mcpv1.LogsRequest, stream mcpv1.McpAgentService_LogsServer) error {
|
||||||
|
if req.GetService() == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "service name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve component name.
|
||||||
|
component := req.GetComponent()
|
||||||
|
if component == "" {
|
||||||
|
components, err := registry.ListComponents(a.DB, req.GetService())
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "list components: %v", err)
|
||||||
|
}
|
||||||
|
if len(components) == 0 {
|
||||||
|
return status.Error(codes.NotFound, "no components found for service")
|
||||||
|
}
|
||||||
|
component = components[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
containerName := ContainerNameFor(req.GetService(), component)
|
||||||
|
|
||||||
|
podman, ok := a.Runtime.(*runtime.Podman)
|
||||||
|
if !ok {
|
||||||
|
return status.Error(codes.Internal, "logs requires podman runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := podman.Logs(stream.Context(), containerName, int(req.GetTail()), req.GetFollow(), req.GetTimestamps(), req.GetSince())
|
||||||
|
|
||||||
|
a.Logger.Info("running podman logs", "container", containerName, "args", cmd.Args)
|
||||||
|
|
||||||
|
// Podman writes container stdout to its stdout and container stderr
|
||||||
|
// to its stderr. Merge both into a single pipe.
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
cmd.Stdout = pw
|
||||||
|
cmd.Stderr = pw
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
pw.Close()
|
||||||
|
return status.Errorf(codes.Internal, "start podman logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the write end when the command exits so the scanner finishes.
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Warn("podman logs exited", "container", containerName, "error", err)
|
||||||
|
}
|
||||||
|
pw.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(pr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := stream.Send(&mcpv1.LogsResponse{
|
||||||
|
Data: append(line, '\n'),
|
||||||
|
}); err != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ func (a *Agent) NodeStatus(ctx context.Context, _ *mcpv1.NodeStatusRequest) (*mc
|
|||||||
Runtime: a.Config.Agent.ContainerRuntime,
|
Runtime: a.Config.Agent.ContainerRuntime,
|
||||||
ServiceCount: uint32(len(services)), //nolint:gosec // bounded
|
ServiceCount: uint32(len(services)), //nolint:gosec // bounded
|
||||||
ComponentCount: componentCount,
|
ComponentCount: componentCount,
|
||||||
|
AgentVersion: a.Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime version.
|
// Runtime version.
|
||||||
|
|||||||
@@ -48,6 +48,40 @@ func (p *ProxyRouter) Close() error {
|
|||||||
return p.client.Close()
|
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 {
|
||||||
|
return nil, fmt.Errorf("mc-proxy not configured")
|
||||||
|
}
|
||||||
|
return p.client.GetStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRoute adds a single route to mc-proxy.
|
||||||
|
func (p *ProxyRouter) AddRoute(ctx context.Context, listenerAddr string, route mcproxy.Route) error {
|
||||||
|
if p == nil {
|
||||||
|
return fmt.Errorf("mc-proxy not configured")
|
||||||
|
}
|
||||||
|
return p.client.AddRoute(ctx, listenerAddr, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRoute removes a single route from mc-proxy.
|
||||||
|
func (p *ProxyRouter) RemoveRoute(ctx context.Context, listenerAddr, hostname string) error {
|
||||||
|
if p == nil {
|
||||||
|
return fmt.Errorf("mc-proxy not configured")
|
||||||
|
}
|
||||||
|
return p.client.RemoveRoute(ctx, listenerAddr, hostname)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers all routes for a service component with mc-proxy.
|
// RegisterRoutes registers all routes for a service component with mc-proxy.
|
||||||
// It uses the assigned host ports from the registry.
|
// It uses the assigned host ports from the registry.
|
||||||
func (p *ProxyRouter) RegisterRoutes(ctx context.Context, serviceName string, routes []registry.Route, hostPorts map[string]int) error {
|
func (p *ProxyRouter) RegisterRoutes(ctx context.Context, serviceName string, routes []registry.Route, hostPorts map[string]int) error {
|
||||||
|
|||||||
113
internal/agent/proxy_rpc.go
Normal file
113
internal/agent/proxy_rpc.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/mc/mc-proxy/client/mcproxy"
|
||||||
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListProxyRoutes queries mc-proxy for its current status and routes.
|
||||||
|
func (a *Agent) ListProxyRoutes(ctx context.Context, _ *mcpv1.ListProxyRoutesRequest) (*mcpv1.ListProxyRoutesResponse, error) {
|
||||||
|
a.Logger.Debug("ListProxyRoutes called")
|
||||||
|
|
||||||
|
status, err := a.Proxy.GetStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get mc-proxy status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &mcpv1.ListProxyRoutesResponse{
|
||||||
|
Version: status.Version,
|
||||||
|
TotalConnections: status.TotalConnections,
|
||||||
|
}
|
||||||
|
if !status.StartedAt.IsZero() {
|
||||||
|
resp.StartedAt = timestamppb.New(status.StartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ls := range status.Listeners {
|
||||||
|
listener := &mcpv1.ProxyListenerInfo{
|
||||||
|
Addr: ls.Addr,
|
||||||
|
RouteCount: int32(ls.RouteCount), //nolint:gosec // bounded
|
||||||
|
ActiveConnections: ls.ActiveConnections,
|
||||||
|
}
|
||||||
|
for _, r := range ls.Routes {
|
||||||
|
listener.Routes = append(listener.Routes, &mcpv1.ProxyRouteInfo{
|
||||||
|
Hostname: r.Hostname,
|
||||||
|
Backend: r.Backend,
|
||||||
|
Mode: r.Mode,
|
||||||
|
BackendTls: r.BackendTLS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp.Listeners = append(resp.Listeners, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProxyRoute adds a route to mc-proxy.
|
||||||
|
func (a *Agent) AddProxyRoute(ctx context.Context, req *mcpv1.AddProxyRouteRequest) (*mcpv1.AddProxyRouteResponse, error) {
|
||||||
|
if req.GetListenerAddr() == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "listener_addr is required")
|
||||||
|
}
|
||||||
|
if req.GetHostname() == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "hostname is required")
|
||||||
|
}
|
||||||
|
if req.GetBackend() == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "backend is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Proxy == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "mc-proxy not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
route := mcproxy.Route{
|
||||||
|
Hostname: req.GetHostname(),
|
||||||
|
Backend: req.GetBackend(),
|
||||||
|
Mode: req.GetMode(),
|
||||||
|
BackendTLS: req.GetBackendTls(),
|
||||||
|
TLSCert: req.GetTlsCert(),
|
||||||
|
TLSKey: req.GetTlsKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Proxy.AddRoute(ctx, req.GetListenerAddr(), route); err != nil {
|
||||||
|
return nil, fmt.Errorf("add route: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Logger.Info("route added",
|
||||||
|
"listener", req.GetListenerAddr(),
|
||||||
|
"hostname", req.GetHostname(),
|
||||||
|
"backend", req.GetBackend(),
|
||||||
|
"mode", req.GetMode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &mcpv1.AddProxyRouteResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProxyRoute removes a route from mc-proxy.
|
||||||
|
func (a *Agent) RemoveProxyRoute(ctx context.Context, req *mcpv1.RemoveProxyRouteRequest) (*mcpv1.RemoveProxyRouteResponse, error) {
|
||||||
|
if req.GetListenerAddr() == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "listener_addr is required")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Proxy.RemoveRoute(ctx, req.GetListenerAddr(), req.GetHostname()); err != nil {
|
||||||
|
return nil, fmt.Errorf("remove route: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Logger.Info("route removed",
|
||||||
|
"listener", req.GetListenerAddr(),
|
||||||
|
"hostname", req.GetHostname(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &mcpv1.RemoveProxyRouteResponse{}, nil
|
||||||
|
}
|
||||||
@@ -99,6 +99,12 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
|
|||||||
|
|
||||||
if rc, ok := runtimeByName[containerName]; ok {
|
if rc, ok := runtimeByName[containerName]; ok {
|
||||||
ci.ObservedState = rc.State
|
ci.ObservedState = rc.State
|
||||||
|
if rc.Version != "" {
|
||||||
|
ci.Version = rc.Version
|
||||||
|
}
|
||||||
|
if rc.Image != "" {
|
||||||
|
ci.Image = rc.Image
|
||||||
|
}
|
||||||
if !rc.Started.IsZero() {
|
if !rc.Started.IsZero() {
|
||||||
ci.Started = timestamppb.New(rc.Started)
|
ci.Started = timestamppb.New(rc.Started)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AuthInterceptor returns a gRPC unary server interceptor that validates
|
// AuthInterceptor returns a gRPC unary server interceptor that validates
|
||||||
// bearer tokens and requires the "admin" role.
|
// bearer tokens. Any authenticated user or system account is accepted,
|
||||||
|
// except guests which are explicitly rejected. Admin role is not required
|
||||||
|
// for agent operations — it is reserved for MCIAS account management and
|
||||||
|
// policy changes.
|
||||||
func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
||||||
return func(
|
return func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -240,9 +243,9 @@ func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
|||||||
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !tokenInfo.HasRole("admin") {
|
if tokenInfo.HasRole("guest") {
|
||||||
slog.Warn("permission denied", "method", info.FullMethod, "user", tokenInfo.Username)
|
slog.Warn("guest access denied", "method", info.FullMethod, "user", tokenInfo.Username)
|
||||||
return nil, status.Error(codes.PermissionDenied, "admin role required")
|
return nil, status.Error(codes.PermissionDenied, "guest access not permitted")
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType)
|
slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType)
|
||||||
@@ -252,6 +255,52 @@ func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamAuthInterceptor returns a gRPC stream server interceptor with
|
||||||
|
// the same authentication rules as AuthInterceptor.
|
||||||
|
func StreamAuthInterceptor(validator TokenValidator) grpc.StreamServerInterceptor {
|
||||||
|
return func(
|
||||||
|
srv any,
|
||||||
|
ss grpc.ServerStream,
|
||||||
|
info *grpc.StreamServerInfo,
|
||||||
|
handler grpc.StreamHandler,
|
||||||
|
) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(ss.Context())
|
||||||
|
if !ok {
|
||||||
|
return status.Error(codes.Unauthenticated, "missing metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
authValues := md.Get("authorization")
|
||||||
|
if len(authValues) == 0 {
|
||||||
|
return status.Error(codes.Unauthenticated, "missing authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := authValues[0]
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return status.Error(codes.Unauthenticated, "malformed authorization header")
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
tokenInfo, err := validator.ValidateToken(ss.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("token validation failed", "method", info.FullMethod, "error", err)
|
||||||
|
return status.Error(codes.Unauthenticated, "token validation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tokenInfo.Valid {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenInfo.HasRole("guest") {
|
||||||
|
slog.Warn("guest access denied", "method", info.FullMethod, "user", tokenInfo.Username)
|
||||||
|
return status.Error(codes.PermissionDenied, "guest access not permitted")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType)
|
||||||
|
|
||||||
|
return handler(srv, ss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Login authenticates with MCIAS and returns a bearer token.
|
// Login authenticates with MCIAS and returns a bearer token.
|
||||||
func Login(serverURL, caCertPath, username, password string) (string, error) {
|
func Login(serverURL, caCertPath, username, password string) (string, error) {
|
||||||
client, err := newHTTPClient(caCertPath)
|
client, err := newHTTPClient(caCertPath)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func TestInterceptorRejectsInvalidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInterceptorRejectsNonAdmin(t *testing.T) {
|
func TestInterceptorAcceptsRegularUser(t *testing.T) {
|
||||||
server := mockMCIAS(t, func(authHeader string) (any, int) {
|
server := mockMCIAS(t, func(authHeader string) (any, int) {
|
||||||
return &TokenInfo{
|
return &TokenInfo{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
@@ -142,6 +142,28 @@ func TestInterceptorRejectsNonAdmin(t *testing.T) {
|
|||||||
md := metadata.Pairs("authorization", "Bearer user-token")
|
md := metadata.Pairs("authorization", "Bearer user-token")
|
||||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||||
|
|
||||||
|
_, err := callInterceptor(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected regular user to be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptorRejectsGuest(t *testing.T) {
|
||||||
|
server := mockMCIAS(t, func(authHeader string) (any, int) {
|
||||||
|
return &TokenInfo{
|
||||||
|
Valid: true,
|
||||||
|
Username: "visitor",
|
||||||
|
Roles: []string{"guest"},
|
||||||
|
AccountType: "human",
|
||||||
|
}, http.StatusOK
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
v := validatorFromServer(t, server)
|
||||||
|
|
||||||
|
md := metadata.Pairs("authorization", "Bearer guest-token")
|
||||||
|
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||||
|
|
||||||
_, err := callInterceptor(ctx, v)
|
_, err := callInterceptor(ctx, v)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
|
|||||||
@@ -10,11 +10,19 @@ import (
|
|||||||
|
|
||||||
// CLIConfig is the configuration for the mcp CLI binary.
|
// CLIConfig is the configuration for the mcp CLI binary.
|
||||||
type CLIConfig struct {
|
type CLIConfig struct {
|
||||||
Services ServicesConfig `toml:"services"`
|
Services ServicesConfig `toml:"services"`
|
||||||
Build BuildConfig `toml:"build"`
|
Build BuildConfig `toml:"build"`
|
||||||
MCIAS MCIASConfig `toml:"mcias"`
|
MCIAS MCIASConfig `toml:"mcias"`
|
||||||
Auth AuthConfig `toml:"auth"`
|
Auth AuthConfig `toml:"auth"`
|
||||||
Nodes []NodeConfig `toml:"nodes"`
|
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.
|
// BuildConfig holds settings for building container images.
|
||||||
|
|||||||
168
internal/config/master.go
Normal file
168
internal/config/master.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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"`
|
||||||
|
Role string `toml:"role"` // "worker", "edge", or "master"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
190
internal/master/agentclient.go
Normal file
190
internal/master/agentclient.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// 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 {
|
||||||
|
client, err := DialAgent(address, p.caCert, p.token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add node %s: %w", name, err)
|
||||||
|
}
|
||||||
|
client.Node = name
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Close existing connection if re-adding.
|
||||||
|
if old, ok := p.clients[name]; ok {
|
||||||
|
_ = old.Close()
|
||||||
|
}
|
||||||
|
p.clients[name] = client
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
222
internal/master/deploy.go
Normal 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
252
internal/master/dns.go
Normal 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
159
internal/master/master.go
Normal 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.AddNode(n.Name, n.Address); 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
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/master/placement.go
Normal file
64
internal/master/placement.go
Normal 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
130
internal/master/status.go
Normal 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
|
||||||
|
}
|
||||||
94
internal/master/undeploy.go
Normal file
94
internal/master/undeploy.go
Normal 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
106
internal/masterdb/db.go
Normal 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);
|
||||||
|
`,
|
||||||
|
}
|
||||||
185
internal/masterdb/db_test.go
Normal file
185
internal/masterdb/db_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/masterdb/edge_routes.go
Normal file
95
internal/masterdb/edge_routes.go
Normal 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
103
internal/masterdb/nodes.go
Normal 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
|
||||||
|
}
|
||||||
99
internal/masterdb/placements.go
Normal file
99
internal/masterdb/placements.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -142,4 +142,18 @@ var migrations = []string{
|
|||||||
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
|
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'))
|
||||||
|
);`,
|
||||||
}
|
}
|
||||||
|
|||||||
93
internal/registry/edge_routes.go
Normal file
93
internal/registry/edge_routes.go
Normal 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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -178,6 +179,83 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logs returns an exec.Cmd that streams container logs. For containers
|
||||||
|
// using the journald log driver, it tries journalctl first (podman logs
|
||||||
|
// can't read journald outside the originating user session). If journalctl
|
||||||
|
// can't access the journal, it falls back to podman logs.
|
||||||
|
func (p *Podman) Logs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd {
|
||||||
|
// Check if this container uses the journald log driver.
|
||||||
|
inspectCmd := exec.CommandContext(ctx, p.command(), "inspect", "--format", "{{.HostConfig.LogConfig.Type}}", containerName) //nolint:gosec
|
||||||
|
if out, err := inspectCmd.Output(); err == nil && strings.TrimSpace(string(out)) == "journald" {
|
||||||
|
if p.journalAccessible(ctx, containerName) {
|
||||||
|
return p.journalLogs(ctx, containerName, tail, follow, since)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.podmanLogs(ctx, containerName, tail, follow, timestamps, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalAccessible probes whether journalctl can read logs for the container.
|
||||||
|
func (p *Podman) journalAccessible(ctx context.Context, containerName string) bool {
|
||||||
|
args := []string{"--no-pager", "-n", "0"}
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
args = append(args, "--user")
|
||||||
|
}
|
||||||
|
args = append(args, "CONTAINER_NAME="+containerName)
|
||||||
|
cmd := exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalLogs returns a journalctl command filtered by container name.
|
||||||
|
func (p *Podman) journalLogs(ctx context.Context, containerName string, tail int, follow bool, since string) *exec.Cmd {
|
||||||
|
args := []string{"--no-pager", "--output", "cat"}
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
args = append(args, "--user")
|
||||||
|
}
|
||||||
|
args = append(args, "CONTAINER_NAME="+containerName)
|
||||||
|
if tail > 0 {
|
||||||
|
args = append(args, "--lines", fmt.Sprintf("%d", tail))
|
||||||
|
}
|
||||||
|
if follow {
|
||||||
|
args = append(args, "--follow")
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
args = append(args, "--since", since)
|
||||||
|
}
|
||||||
|
return exec.CommandContext(ctx, "journalctl", args...) //nolint:gosec // args built programmatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// podmanLogs returns a podman logs command.
|
||||||
|
func (p *Podman) podmanLogs(ctx context.Context, containerName string, tail int, follow, timestamps bool, since string) *exec.Cmd {
|
||||||
|
args := []string{"logs"}
|
||||||
|
if tail > 0 {
|
||||||
|
args = append(args, "--tail", fmt.Sprintf("%d", tail))
|
||||||
|
}
|
||||||
|
if follow {
|
||||||
|
args = append(args, "--follow")
|
||||||
|
}
|
||||||
|
if timestamps {
|
||||||
|
args = append(args, "--timestamps")
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
args = append(args, "--since", since)
|
||||||
|
}
|
||||||
|
args = append(args, containerName)
|
||||||
|
return exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates to a container registry using the given token as
|
||||||
|
// the password. This enables non-interactive push with service account
|
||||||
|
// tokens (MCR accepts MCIAS JWTs as passwords).
|
||||||
|
func (p *Podman) Login(ctx context.Context, registry, username, token string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, p.command(), "login", "--username", username, "--password-stdin", registry) //nolint:gosec // args built programmatically
|
||||||
|
cmd.Stdin = strings.NewReader(token)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("podman login %q: %w: %s", registry, err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build builds a container image from a Dockerfile.
|
// Build builds a container image from a Dockerfile.
|
||||||
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
|
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
|
||||||
args := []string{"build", "-t", image, "-f", dockerfile, contextDir}
|
args := []string{"build", "-t", image, "-f", dockerfile, contextDir}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import (
|
|||||||
// ServiceDef is the top-level TOML structure for a service definition file.
|
// ServiceDef is the top-level TOML structure for a service definition file.
|
||||||
type ServiceDef struct {
|
type ServiceDef struct {
|
||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Node string `toml:"node"`
|
Node string `toml:"node,omitempty"`
|
||||||
|
Tier string `toml:"tier,omitempty"`
|
||||||
Active *bool `toml:"active,omitempty"`
|
Active *bool `toml:"active,omitempty"`
|
||||||
|
Comment string `toml:"comment,omitempty"`
|
||||||
Path string `toml:"path,omitempty"`
|
Path string `toml:"path,omitempty"`
|
||||||
Build *BuildDef `toml:"build,omitempty"`
|
Build *BuildDef `toml:"build,omitempty"`
|
||||||
Components []ComponentDef `toml:"components"`
|
Components []ComponentDef `toml:"components"`
|
||||||
@@ -36,6 +38,7 @@ type RouteDef struct {
|
|||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
Mode string `toml:"mode,omitempty"`
|
Mode string `toml:"mode,omitempty"`
|
||||||
Hostname string `toml:"hostname,omitempty"`
|
Hostname string `toml:"hostname,omitempty"`
|
||||||
|
Public bool `toml:"public,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComponentDef describes a single container component within a service.
|
// ComponentDef describes a single container component within a service.
|
||||||
@@ -129,8 +132,9 @@ func validate(def *ServiceDef) error {
|
|||||||
if def.Name == "" {
|
if def.Name == "" {
|
||||||
return fmt.Errorf("service name is required")
|
return fmt.Errorf("service name is required")
|
||||||
}
|
}
|
||||||
if def.Node == "" {
|
// v2: either node or tier must be set. Tier defaults to "worker" if both empty.
|
||||||
return fmt.Errorf("service node is required")
|
if def.Node == "" && def.Tier == "" {
|
||||||
|
def.Tier = "worker"
|
||||||
}
|
}
|
||||||
if len(def.Components) == 0 {
|
if len(def.Components) == 0 {
|
||||||
return fmt.Errorf("service %q must have at least one component", def.Name)
|
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.
|
// ToProto converts a ServiceDef to a proto ServiceSpec.
|
||||||
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
||||||
spec := &mcpv1.ServiceSpec{
|
spec := &mcpv1.ServiceSpec{
|
||||||
Name: def.Name,
|
Name: def.Name,
|
||||||
Active: def.Active != nil && *def.Active,
|
Active: def.Active != nil && *def.Active,
|
||||||
|
Comment: def.Comment,
|
||||||
|
Tier: def.Tier,
|
||||||
|
Node: def.Node,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range def.Components {
|
for _, c := range def.Components {
|
||||||
@@ -213,6 +220,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
|||||||
Port: int32(r.Port), //nolint:gosec // port range validated
|
Port: int32(r.Port), //nolint:gosec // port range validated
|
||||||
Mode: r.Mode,
|
Mode: r.Mode,
|
||||||
Hostname: r.Hostname,
|
Hostname: r.Hostname,
|
||||||
|
Public: r.Public,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
spec.Components = append(spec.Components, cs)
|
spec.Components = append(spec.Components, cs)
|
||||||
@@ -227,9 +235,11 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
|||||||
func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
|
func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
|
||||||
active := spec.GetActive()
|
active := spec.GetActive()
|
||||||
def := &ServiceDef{
|
def := &ServiceDef{
|
||||||
Name: spec.GetName(),
|
Name: spec.GetName(),
|
||||||
Node: node,
|
Node: node,
|
||||||
Active: &active,
|
Tier: spec.GetTier(),
|
||||||
|
Active: &active,
|
||||||
|
Comment: spec.GetComment(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range spec.GetComponents() {
|
for _, c := range spec.GetComponents() {
|
||||||
@@ -250,6 +260,7 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
|
|||||||
Port: int(r.GetPort()),
|
Port: int(r.GetPort()),
|
||||||
Mode: r.GetMode(),
|
Mode: r.GetMode(),
|
||||||
Hostname: r.GetHostname(),
|
Hostname: r.GetHostname(),
|
||||||
|
Public: r.GetPublic(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
def.Components = append(def.Components, cd)
|
def.Components = append(def.Components, cd)
|
||||||
|
|||||||
@@ -119,14 +119,8 @@ func TestValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "service name is required",
|
wantErr: "service name is required",
|
||||||
},
|
},
|
||||||
{
|
// v2: missing node no longer errors — defaults to tier=worker.
|
||||||
name: "missing node",
|
// Tested separately in TestValidationNodeTierDefault.
|
||||||
def: &ServiceDef{
|
|
||||||
Name: "svc",
|
|
||||||
Components: []ComponentDef{{Name: "api", Image: "img:v1"}},
|
|
||||||
},
|
|
||||||
wantErr: "service node is required",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "empty components",
|
name: "empty components",
|
||||||
def: &ServiceDef{
|
def: &ServiceDef{
|
||||||
|
|||||||
95
proto/mcp/v1/master.proto
Normal file
95
proto/mcp/v1/master.proto
Normal 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
|
||||||
|
}
|
||||||
@@ -33,6 +33,25 @@ service McpAgentService {
|
|||||||
|
|
||||||
// Node
|
// Node
|
||||||
rpc NodeStatus(NodeStatusRequest) returns (NodeStatusResponse);
|
rpc NodeStatus(NodeStatusRequest) returns (NodeStatusResponse);
|
||||||
|
|
||||||
|
// DNS (query MCNS)
|
||||||
|
rpc ListDNSRecords(ListDNSRecordsRequest) returns (ListDNSRecordsResponse);
|
||||||
|
|
||||||
|
// Proxy routes (query mc-proxy)
|
||||||
|
rpc ListProxyRoutes(ListProxyRoutesRequest) returns (ListProxyRoutesResponse);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Service lifecycle ---
|
// --- Service lifecycle ---
|
||||||
@@ -42,6 +61,7 @@ message RouteSpec {
|
|||||||
int32 port = 2; // mc-proxy listener port (e.g. 443, 8443, 9443); NOT the container internal port
|
int32 port = 2; // mc-proxy listener port (e.g. 443, 8443, 9443); NOT the container internal port
|
||||||
string mode = 3; // "l4" or "l7"
|
string mode = 3; // "l4" or "l7"
|
||||||
string hostname = 4; // optional public hostname override
|
string hostname = 4; // optional public hostname override
|
||||||
|
bool public = 5; // triggers edge routing when true
|
||||||
}
|
}
|
||||||
|
|
||||||
message ComponentSpec {
|
message ComponentSpec {
|
||||||
@@ -57,10 +77,19 @@ message ComponentSpec {
|
|||||||
repeated string env = 10;
|
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 {
|
message ServiceSpec {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
bool active = 2;
|
bool active = 2;
|
||||||
repeated ComponentSpec components = 3;
|
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 {
|
message DeployRequest {
|
||||||
@@ -81,6 +110,7 @@ message ComponentResult {
|
|||||||
|
|
||||||
message StopServiceRequest {
|
message StopServiceRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
|
string component = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StopServiceResponse {
|
message StopServiceResponse {
|
||||||
@@ -89,6 +119,7 @@ message StopServiceResponse {
|
|||||||
|
|
||||||
message StartServiceRequest {
|
message StartServiceRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
|
string component = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartServiceResponse {
|
message StartServiceResponse {
|
||||||
@@ -97,6 +128,7 @@ message StartServiceResponse {
|
|||||||
|
|
||||||
message RestartServiceRequest {
|
message RestartServiceRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
|
string component = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RestartServiceResponse {
|
message RestartServiceResponse {
|
||||||
@@ -137,6 +169,7 @@ message ServiceInfo {
|
|||||||
string name = 1;
|
string name = 1;
|
||||||
bool active = 2;
|
bool active = 2;
|
||||||
repeated ComponentInfo components = 3;
|
repeated ComponentInfo components = 3;
|
||||||
|
string comment = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ComponentInfo {
|
message ComponentInfo {
|
||||||
@@ -254,6 +287,7 @@ message NodeStatusResponse {
|
|||||||
uint64 memory_free_bytes = 9;
|
uint64 memory_free_bytes = 9;
|
||||||
double cpu_usage_percent = 10;
|
double cpu_usage_percent = 10;
|
||||||
google.protobuf.Timestamp uptime_since = 11;
|
google.protobuf.Timestamp uptime_since = 11;
|
||||||
|
string agent_version = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Purge ---
|
// --- Purge ---
|
||||||
@@ -282,3 +316,123 @@ message PurgeResult {
|
|||||||
// Why eligible, or why refused.
|
// Why eligible, or why refused.
|
||||||
string reason = 4;
|
string reason = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Logs ---
|
||||||
|
|
||||||
|
message LogsRequest {
|
||||||
|
string service = 1;
|
||||||
|
string component = 2; // optional; defaults to first/only component
|
||||||
|
int32 tail = 3; // number of lines from the end (0 = all)
|
||||||
|
bool follow = 4; // stream new output
|
||||||
|
bool timestamps = 5; // prepend timestamps
|
||||||
|
string since = 6; // show logs since (e.g., "2h", "2026-03-28T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogsResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DNS ---
|
||||||
|
|
||||||
|
message ListDNSRecordsRequest {}
|
||||||
|
|
||||||
|
message DNSZone {
|
||||||
|
string name = 1;
|
||||||
|
repeated DNSRecord records = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DNSRecord {
|
||||||
|
int64 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string type = 3;
|
||||||
|
string value = 4;
|
||||||
|
int32 ttl = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListDNSRecordsResponse {
|
||||||
|
repeated DNSZone zones = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Proxy routes ---
|
||||||
|
|
||||||
|
message ListProxyRoutesRequest {}
|
||||||
|
|
||||||
|
message ProxyRouteInfo {
|
||||||
|
string hostname = 1;
|
||||||
|
string backend = 2;
|
||||||
|
string mode = 3;
|
||||||
|
bool backend_tls = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProxyListenerInfo {
|
||||||
|
string addr = 1;
|
||||||
|
int32 route_count = 2;
|
||||||
|
int64 active_connections = 3;
|
||||||
|
repeated ProxyRouteInfo routes = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListProxyRoutesResponse {
|
||||||
|
string version = 1;
|
||||||
|
int64 total_connections = 2;
|
||||||
|
google.protobuf.Timestamp started_at = 3;
|
||||||
|
repeated ProxyListenerInfo listeners = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddProxyRouteRequest {
|
||||||
|
string listener_addr = 1; // e.g. ":443"
|
||||||
|
string hostname = 2;
|
||||||
|
string backend = 3;
|
||||||
|
string mode = 4; // "l4" or "l7"
|
||||||
|
bool backend_tls = 5;
|
||||||
|
string tls_cert = 6; // path to TLS cert (required for l7)
|
||||||
|
string tls_key = 7; // path to TLS key (required for l7)
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddProxyRouteResponse {}
|
||||||
|
|
||||||
|
message RemoveProxyRouteRequest {
|
||||||
|
string listener_addr = 1; // e.g. ":443"
|
||||||
|
string hostname = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user