28 Commits

Author SHA1 Message Date
169b3a0d4a Fix EnsureRecord to check all existing records before updating
When multiple A records exist for a service (e.g., LAN and Tailscale
IPs), check all of them for the correct value before attempting an
update. Previously only checked the first record, which could trigger
a 409 conflict if another record already had the target value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:17:19 -07:00
2bda7fc138 Fix DNS record JSON parsing for MCNS response format
MCNS returns records wrapped in {"records": [...]} envelope with
uppercase field names (ID, Name, Type, Value), not bare arrays
with lowercase fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:12:43 -07:00
76247978c2 Fix protoToComponent to include routes in synced components
Routes from the proto ComponentSpec were dropped during sync, causing
the deploy flow to see empty regRoutes and skip cert provisioning,
route registration, and DNS registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:39:26 -07:00
ca3bc736f6 Bump version to v0.5.0 for Phase D release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:33:54 -07:00
9d9ad6588e Phase D: Automated DNS registration via MCNS
Add DNSRegistrar that creates/updates/deletes A records in MCNS
during deploy and stop. When a service has routes, the agent ensures
an A record exists in the configured zone pointing to the node's
address. On stop, the record is removed.

- Add MCNSConfig to agent config (server_url, ca_cert, token_path,
  zone, node_addr) with defaults and env overrides
- Add DNSRegistrar (internal/agent/dns.go): REST client for MCNS
  record CRUD, nil-receiver safe
- Wire into deploy flow (EnsureRecord after route registration)
- Wire into stop flow (RemoveRecord before container stop)
- 7 new tests, make all passes with 0 issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:33:41 -07:00
e4d131021e Bump version to v0.4.0 for Phase C release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:47:02 -07:00
8d6c060483 Update mc-proxy dependency to v1.2.0, drop replace directive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:41 -07:00
c7e1232f98 Phase C: Automated TLS cert provisioning for L7 routes
Add CertProvisioner that requests TLS certificates from Metacrypt's CA
API during deploy. When a service has L7 routes, the agent checks for
an existing cert, re-issues if missing or within 30 days of expiry,
and writes chain+key to mc-proxy's cert directory before registering
routes.

- Add MetacryptConfig to agent config (server_url, ca_cert, mount,
  issuer, token_path) with defaults and env overrides
- Add CertProvisioner (internal/agent/certs.go): REST client for
  Metacrypt IssueCert, atomic file writes, cert expiry checking
- Wire into Agent struct and deploy flow (before route registration)
- Add hasL7Routes/l7Hostnames helpers in deploy.go
- Fix pre-existing lint issues: unreachable code in portalloc.go,
  gofmt in servicedef.go, gosec suppressions, golangci v2 config
- Update vendored mc-proxy to fix protobuf init panic
- 10 new tests, make all passes with 0 issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:31:11 -07:00
572d2fb196 Regenerate proto files for mc/ module path
Raw descriptor bytes in .pb.go files were corrupted by the sed-based
module path rename (string length changed, breaking protobuf binary
encoding). Regenerated with protoc to fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:54:40 -07:00
c6a84a1b80 Bump flake.nix version to match latest tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:16:45 -07:00
08b3e2a472 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0,
mc-proxy to v1.1.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:42 -07:00
6e30cf12f2 Mark Phase B complete in PROGRESS_V1.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:36:50 -07:00
c28562dbcf Merge pull request 'Phase B: Agent registers routes with mc-proxy on deploy' (#2) from phase-b-route-registration into master 2026-03-27 08:36:25 +00:00
84c487e7f8 Phase B: Agent registers routes with mc-proxy on deploy
The agent connects to mc-proxy via Unix socket and automatically
registers/removes routes during deploy and stop. This eliminates
manual mcproxyctl usage or TOML editing.

- New ProxyRouter abstraction wraps mc-proxy client library
- Deploy: after container starts, registers routes with mc-proxy
  using host ports from the registry
- Stop: removes routes from mc-proxy before stopping container
- Config: [mcproxy] section with socket path and cert_dir
- Nil-safe: if mc-proxy socket not configured, route registration
  is silently skipped (backward compatible)
- L7 routes use certs from convention path (<cert_dir>/<service>.pem)
- L4 routes use TLS passthrough (backend_tls=true)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:35:06 -07:00
8b1c89fdc9 Add mcp build command and deploy auto-build
Extends MCP to own the full build-push-deploy lifecycle. When deploying,
the CLI checks whether each component's image tag exists in the registry
and builds/pushes automatically if missing and build config is present.

- Add Build, Push, ImageExists to runtime.Runtime interface (podman impl)
- Add mcp build <service>[/<image>] command
- Add [build] section to CLI config (workspace path)
- Add path and [build.images] to service definitions
- Wire auto-build into mcp deploy before agent RPC
- Update ARCHITECTURE.md with runtime interface and deploy auto-build docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:34:25 -07:00
d7f18a5d90 Add Platform Evolution tracking to PROGRESS_V1.md
Phase A complete: route declarations, port allocation, $PORT env vars.
Phase B in progress: agent mc-proxy route registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:25:26 -07:00
5a802bceb6 Merge pull request 'Add route declarations and automatic port allocation' (#1) from mcp-routes-port-allocation into master 2026-03-27 08:16:20 +00:00
777ba8a0e1 Add route declarations and automatic port allocation to MCP agent
Service definitions can now declare routes per component instead of
manual port mappings:

  [[components.routes]]
  name = "rest"
  port = 8443
  mode = "l4"

The agent allocates free host ports at deploy time and injects
$PORT/$PORT_<NAME> env vars into containers. Backward compatible:
components with old-style ports= work unchanged.

Changes:
- Proto: RouteSpec message, routes + env fields on ComponentSpec
- Servicedef: RouteDef parsing and validation from TOML
- Registry: component_routes table with host_port tracking
- Runtime: Env field on ContainerSpec, -e flag in BuildRunArgs
- Agent: PortAllocator (random 10000-60000, availability check),
  deploy wiring for route→port mapping and env injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:04:47 -07:00
503c52dc26 Update service definition example for convention-driven format
Drop uses_mcdsl, full image URLs, ports, network, user, restart.
Add route declarations and service-level version. Image names and
most config are now derived from conventions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:19:12 -07:00
6465da3547 Add build and release lifecycle to ARCHITECTURE.md
Service definitions now include [build] config (path, uses_mcdsl,
images) so MCP owns the full build-push-deploy lifecycle, replacing
mcdeploy.toml. Documents mcp build, mcp sync auto-build, image
versioning policy (explicit tags, never :latest), and workspace
convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:31:05 -07:00
e18a3647bf Add Nix flake for mcp and mcp-agent
Exposes two packages:
- default (mcp CLI) for operator workstations
- mcp-agent for managed nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:46:36 -07:00
1e58dcce27 Implement mcp purge command for registry cleanup
Add PurgeComponent RPC to the agent service that removes stale registry
entries for components that are both gone (observed state is removed,
unknown, or exited) and unwanted (not in any current service definition).
Refuses to purge components with running or stopped containers. When all
components of a service are purged, the service row is deleted too.
Supports --dry-run to preview without modifying the database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:30:45 -07:00
1afbf5e1f6 Add purge design to architecture doc
Purge removes stale registry entries — components that are no longer
in service definitions and have no running container. Designed as an
explicit, safe operation separate from sync: sync is additive (push
desired state), purge is subtractive (remove forgotten entries).

Includes safety rules (refuses to purge running containers), dry-run
mode, agent RPC definition, and rationale for why sync should not be
made destructive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:22:27 -07:00
ea8a42a696 P5.2 + P5.3: Bootstrap docs, README, and RUNBOOK
- docs/bootstrap.md: step-by-step bootstrap procedure with lessons
  learned from the first deployment (NixOS sandbox issues, podman
  rootless setup, container naming, MCR auth workaround)
- README.md: quick-start guide, command reference, doc links
- RUNBOOK.md: operational procedures for operators (health checks,
  common operations, unsealing metacrypt, cert renewal, incident
  response, disaster recovery, file locations)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:32:22 -07:00
ff9bfc5087 Update PROGRESS_V1.md with deployment status and remaining work
Documents Phase 6 (deployment), bugs fixed during rollout,
remaining work organized by priority (operational, quality,
design, infrastructure), and current platform state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:27:30 -07:00
17ac0f3014 Trim whitespace from token file in CLI
Token files with trailing newlines caused gRPC "non-printable ASCII
characters" errors in the authorization header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:19:27 -07:00
7133871be2 Default CLI config path to ~/.config/mcp/mcp.toml
Eliminates the need to pass --config on every command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:16:34 -07:00
efa32a7712 Fix container name handling for hyphenated service names
Extract ContainerNameFor and SplitContainerName into names.go.
ContainerNameFor handles single-component services where service
name equals component name (e.g., mc-proxy → "mc-proxy" not
"mc-proxy-mc-proxy"). SplitContainerName checks known services
from the registry before falling back to naive split on "-", fixing
mc-proxy being misidentified as service "mc" component "proxy".

Also fixes podman ps JSON parsing (Command field is []string not
string) found during deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:13:20 -07:00
2300 changed files with 6791217 additions and 339 deletions

View File

@@ -5,6 +5,24 @@ run:
tests: true
linters:
exclusions:
paths:
- vendor
rules:
# In test files, suppress gosec rules that are false positives:
# G101: hardcoded test credentials
# G304: file paths from variables (t.TempDir paths)
# G306: WriteFile with 0644 (cert files need to be readable)
# G404: weak RNG (not security-relevant in tests)
- path: "_test\\.go"
linters:
- gosec
text: "G101|G304|G306|G404"
# Nil context is acceptable in tests for nil-receiver safety checks.
- path: "_test\\.go"
linters:
- staticcheck
text: "SA1012"
default: none
enable:
- errcheck
@@ -69,12 +87,3 @@ formatters:
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclusions:
paths:
- vendor
rules:
- path: "_test\\.go"
linters:
- gosec
text: "G101"

View File

@@ -192,6 +192,9 @@ for a service by prefix and derive component names automatically
```
mcp login Authenticate to MCIAS, store token
mcp build <service> Build and push images for a service
mcp build <service>/<image> Build and push a single image
mcp deploy <service> Deploy all components from service definition
mcp deploy <service>/<component> Deploy a single component
mcp deploy <service> -f <file> Deploy from explicit file
@@ -203,10 +206,11 @@ mcp list List services from all agents (registry,
mcp ps Live check: query runtime on all agents, show running
containers with uptime and version
mcp status [service] Full picture: live query + drift + recent events
mcp sync Push service definitions to agent (update desired
state without deploying)
mcp sync Push service definitions to agent; build missing
images if source tree is available
mcp adopt <service> Adopt all <service>-* containers into a service
mcp purge [service[/component]] Remove stale registry entries (--dry-run to preview)
mcp service show <service> Print current spec from agent registry
mcp service edit <service> Open service definition in $EDITOR
@@ -234,25 +238,34 @@ Example: `~/.config/mcp/services/metacrypt.toml`
name = "metacrypt"
node = "rift"
active = true
version = "v1.0.0"
[build.images]
metacrypt = "Dockerfile.api"
metacrypt-web = "Dockerfile.web"
[[components]]
name = "api"
image = "mcr.svc.mcp.metacircular.net:8443/metacrypt:latest"
network = "docker_default"
user = "0:0"
restart = "unless-stopped"
ports = ["127.0.0.1:18443:8443", "127.0.0.1:19443:9443"]
volumes = ["/srv/metacrypt:/srv/metacrypt"]
[[components.routes]]
name = "rest"
port = 8443
mode = "l4"
[[components.routes]]
name = "grpc"
port = 9443
mode = "l4"
[[components]]
name = "web"
image = "mcr.svc.mcp.metacircular.net:8443/metacrypt-web:latest"
network = "docker_default"
user = "0:0"
restart = "unless-stopped"
ports = ["127.0.0.1:18080:8080"]
volumes = ["/srv/metacrypt:/srv/metacrypt"]
cmd = ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
[[components.routes]]
port = 443
mode = "l7"
```
### Active State
@@ -286,6 +299,12 @@ chain:
If neither exists (first deploy, no file), the deploy fails with an error
telling the operator to create a service definition.
Before pushing to the agent, the CLI checks that each component's image
tag exists in the registry. If a tag is missing and a `[build]` section
is configured, the CLI builds and pushes the image automatically (same
logic as `mcp sync` auto-build, described below). This makes `mcp deploy`
a single command for the bump-build-push-deploy workflow.
The CLI pushes the resolved spec to the agent. The agent records it in its
registry and executes the deploy. The service definition file on disk is
**not** modified -- it represents the operator's declared intent, not the
@@ -333,6 +352,83 @@ Service definition files can be:
- **Generated by converting from mcdeploy.toml** during initial MCP
migration (one-time).
### Build Configuration
Service definitions include a `[build]` section that tells MCP how to
build container images from source. This replaces the standalone
`mcdeploy.toml` -- MCP owns the full build-push-deploy lifecycle.
Top-level build fields:
| Field | Purpose |
|-------|---------|
| `path` | Source directory relative to the workspace root |
| `build.uses_mcdsl` | Whether the mcdsl module is needed at build time |
| `build.images.<name>` | Maps each image name to its Dockerfile path |
The workspace root is configured in `~/.config/mcp/mcp.toml`:
```toml
[build]
workspace = "~/src/metacircular"
```
A service with `path = "mcr"` resolves to `~/src/metacircular/mcr`. The
convention assumes `~/src/metacircular/<path>` on operator workstations
(vade, orion). The workspace path can be overridden but the convention
should hold for all standard machines.
### Build and Release Workflow
The standard release workflow for a service:
1. **Tag** the release in git (`git tag -a v1.1.0`).
2. **Build** the images: `mcp build <service>` reads the service
definition, locates the source tree via `path`, and runs `docker
build` using each Dockerfile in `[build.images]`. Images are tagged
with the version from the component `image` field and pushed to MCR.
3. **Update** the service definition: bump the version tag in each
component's `image` field.
4. **Deploy**: `mcp sync` or `mcp deploy <service>`.
#### `mcp build` Resolution
`mcp build <service>` does the following:
1. Read the service definition to find `[build.images]` and `path`.
2. Resolve the source tree: `<workspace>/<path>`.
3. For each image in `[build.images]`:
a. Build with the Dockerfile at `<source>/<dockerfile>`.
b. If `uses_mcdsl = true`, include the mcdsl directory in the build
context (or use a multi-module build strategy).
c. Tag as `<registry>/<image>:<version>` (version extracted from the
matching component's `image` field).
d. Push to MCR.
#### `mcp sync` Auto-Build
`mcp sync` pushes service definitions to agents. Before deploying, it
checks that each component's image tag exists in the registry:
- **Tag exists** → proceed with deploy.
- **Tag missing, source tree available** → build and push automatically,
then deploy.
- **Tag missing, no source tree** → fail with error:
`"mcr:v1.1.0 not found in registry and no source tree at ~/src/metacircular/mcr"`.
This ensures `mcp sync` is a single command for the common case (tag,
update version, sync) while failing clearly when the build environment
is not available.
#### Image Versioning
Service definitions MUST pin explicit version tags (e.g., `v1.1.0`),
never `:latest`. This ensures:
- `mcp status` shows the actual running version.
- Deployments are reproducible.
- Rollbacks are explicit (change the tag back to the previous version).
---
## Agent
@@ -566,6 +662,29 @@ The agent runs as a dedicated `mcp` system user. Podman runs rootless under
this user. All containers are owned by `mcp`. The NixOS configuration
provisions the `mcp` user with podman access.
#### Runtime Interface
The `runtime.Runtime` interface abstracts the container runtime. The agent
(and the CLI, for build operations) use it for all container operations.
| Method | Used by | Purpose |
|--------|---------|---------|
| `Pull(image)` | Agent | `podman pull <image>` |
| `Run(spec)` | Agent | `podman run -d ...` |
| `Stop(name)` | Agent | `podman stop <name>` |
| `Remove(name)` | Agent | `podman rm <name>` |
| `Inspect(name)` | Agent | `podman inspect <name>` |
| `List()` | Agent | `podman ps -a` |
| `Build(image, contextDir, dockerfile)` | CLI | `podman build -t <image> -f <dockerfile> <contextDir>` |
| `Push(image)` | CLI | `podman push <image>` |
| `ImageExists(image)` | CLI | `podman manifest inspect docker://<image>` (checks remote registry) |
The first six methods are used by the agent during deploy and monitoring.
The last three are used by the CLI during `mcp build` and `mcp deploy`
auto-build. They are on the same interface because the CLI uses the local
podman installation directly -- no gRPC RPC needed, since builds happen
on the operator's workstation, not on the deployment node.
#### Deploy Flow
When the agent receives a `Deploy` RPC:
@@ -1133,6 +1252,7 @@ mcp/
│ ├── mcp/ CLI
│ │ ├── main.go
│ │ ├── login.go
│ │ ├── build.go build and push images
│ │ ├── deploy.go
│ │ ├── lifecycle.go stop, start, restart
│ │ ├── status.go list, ps, status
@@ -1195,6 +1315,147 @@ mcp/
---
## Registry Cleanup: Purge
### Problem
The agent's registry accumulates stale entries over time. A component
that was replaced (e.g., `mcns/coredns``mcns/mcns`) or a service
that was decommissioned remains in the registry indefinitely with
`observed=removed` or `observed=unknown`. There is no mechanism to tell
the agent "this component no longer exists and should not be tracked."
This causes:
- Perpetual drift alerts for components that will never return.
- Noise in `mcp status` and `mcp list` output.
- Confusion about what the agent is actually responsible for.
The existing `mcp sync` compares local service definitions against the
agent's registry and updates desired state for components that are
defined. But it does not remove components or services that are *absent*
from the local definitions — sync is additive, not declarative.
### Design: `mcp purge`
Purge removes registry entries that are both **unwanted** (not in any
current service definition) and **gone** (no corresponding container in
the runtime). It is the garbage collector for the registry.
```
mcp purge [--dry-run] Purge all stale entries
mcp purge <service> [--dry-run] Purge stale entries for one service
mcp purge <service>/<component> [--dry-run] Purge a specific component
```
#### Semantics
Purge operates on the agent's registry, not on containers. It never
stops or removes running containers. The rules:
1. **Component purge**: a component is eligible for purge when:
- Its observed state is `removed`, `unknown`, or `exited`, AND
- It is not present in any current service definition file
(i.e., `mcp sync` would not recreate it).
Purging a component deletes its registry entry (from `components`,
`component_ports`, `component_volumes`, `component_cmd`) and its
event history.
2. **Service purge**: a service is eligible for purge when all of its
components have been purged (or it has no components). Purging a
service deletes its `services` row.
3. **Safety**: purge refuses to remove a component whose observed state
is `running` or `stopped` (i.e., a container still exists in the
runtime). This prevents accidentally losing track of live containers.
The operator must `mcp stop` and wait for the container to be removed
before purging, or manually remove it via podman.
4. **Dry run**: `--dry-run` lists what would be purged without modifying
the registry. This is the default-safe way to preview the operation.
#### Interaction with Sync
`mcp sync` pushes desired state from service definitions. `mcp purge`
removes entries that sync would never touch. They are complementary:
- `sync` answers: "what should exist?" (additive)
- `purge` answers: "what should be forgotten?" (subtractive)
A full cleanup is: `mcp sync && mcp purge`.
An alternative design would make `mcp sync` itself remove entries not
present in service definitions (fully declarative sync). This was
rejected because:
- Sync currently only operates on services that have local definition
files. A service without a local file is left untouched — this is
desirable when multiple operators or workstations manage different
services.
- Making sync destructive increases the blast radius of a missing file
(accidentally deleting the local `mcr.toml` would cause sync to
purge MCR from the registry).
- Purge as a separate, explicit command with `--dry-run` gives the
operator clear control over what gets cleaned up.
#### Agent RPC
```protobuf
rpc PurgeComponent(PurgeRequest) returns (PurgeResponse);
message PurgeRequest {
string service = 1; // service name (empty = all services)
string component = 2; // component name (empty = all eligible in service)
bool dry_run = 3; // preview only, do not modify registry
}
message PurgeResponse {
repeated PurgeResult results = 1;
}
message PurgeResult {
string service = 1;
string component = 2;
bool purged = 3; // true if removed (or would be, in dry-run)
string reason = 4; // why eligible, or why refused
}
```
The CLI sends the set of currently-defined service/component names
alongside the purge request so the agent can determine what is "not in
any current service definition" without needing access to the CLI's
filesystem.
#### Example
After replacing `mcns/coredns` with `mcns/mcns`:
```
$ mcp purge --dry-run
would purge mcns/coredns (observed=removed, not in service definitions)
$ mcp purge
purged mcns/coredns
$ mcp status
SERVICE COMPONENT DESIRED OBSERVED VERSION
mc-proxy mc-proxy running running latest
mcns mcns running running v1.0.0
mcr api running running latest
mcr web running running latest
metacrypt api running running latest
metacrypt web running running latest
```
#### Registry Auth
Purge also cleans up after the `mcp adopt` workflow. When containers are
adopted and later removed (replaced by a proper deploy), the adopted
entries linger. Purge removes them once the containers are gone and the
service definition no longer references them.
---
## Future Work (v2+)
These are explicitly out of scope for v1 but inform the design:

View File

@@ -55,4 +55,4 @@ Run a single test: `go test ./internal/registry/ -run TestComponentCRUD`
## Module Path
`git.wntrmute.dev/kyle/mcp`
`git.wntrmute.dev/mc/mcp`

View File

@@ -21,8 +21,8 @@ lint:
golangci-lint run ./...
proto:
protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/mcp \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/mcp \
protoc --go_out=. --go_opt=module=git.wntrmute.dev/mc/mcp \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcp \
proto/mcp/v1/*.proto
proto-lint:

View File

@@ -47,5 +47,109 @@
## Phase 5: Integration and Polish
- [ ] **P5.1** Integration test suite
- [ ] **P5.2** Bootstrap procedure test
- [ ] **P5.3** Documentation (CLAUDE.md, README.md, RUNBOOK.md)
- [x] **P5.2** Bootstrap procedure — documented in `docs/bootstrap.md`
- [x] **P5.3** Documentation CLAUDE.md, README.md, RUNBOOK.md
## Phase 6: Deployment (completed 2026-03-26)
- [x] **P6.1** NixOS config for mcp user (rootless podman, subuid/subgid, systemd service)
- [x] **P6.2** TLS cert provisioned from Metacrypt (DNS + IP SANs)
- [x] **P6.3** MCIAS system account (mcp-agent with admin role)
- [x] **P6.4** Container migration (metacrypt, mc-proxy, mcr, mcns → mcp user)
- [x] **P6.5** MCP bootstrap (adopt, sync, export service definitions)
- [x] **P6.6** Service definitions completed with full container specs
## Deployment Bugs Fixed During Rollout
- podman ps JSON: `Command` field is `[]string` not `string`
- Container name handling: `splitContainerName` naive split broke `mc-proxy`
→ extracted `ContainerNameFor`/`SplitContainerName` with registry-aware lookup
- CLI default config path: `~/.config/mcp/mcp.toml`
- Token file whitespace: trim newlines before sending in gRPC metadata
- NixOS systemd sandbox: `ProtectHome` blocks `/run/user`, `ProtectSystem=strict`
blocks podman runtime dir → relaxed to `ProtectSystem=full`, `ProtectHome=false`
- Agent needs `PATH`, `HOME`, `XDG_RUNTIME_DIR` in systemd environment
## Platform Evolution (see PLATFORM_EVOLUTION.md)
### Phase A — COMPLETE (2026-03-27)
- [x] Route declarations in service definitions (`[[components.routes]]`)
- [x] Automatic port allocation by agent (10000-60000, mutex-serialized)
- [x] `$PORT` / `$PORT_<NAME>` env var injection into containers
- [x] Proto: `RouteSpec` message, `routes` + `env` on `ComponentSpec`
- [x] Registry: `component_routes` table with `host_port` tracking
- [x] Backward compatible: old-style `ports` strings still work
### Phase B — COMPLETE (2026-03-27)
- [x] Agent connects to mc-proxy via Unix socket on deploy
- [x] Agent calls `AddRoute` to register routes with mc-proxy
- [x] Agent calls `RemoveRoute` on service stop/teardown
- [x] Agent config: `[mcproxy] socket` and `cert_dir` fields
- [x] TLS certs: pre-provisioned at convention path (Phase C automates)
- [x] Nil-safe: if socket not configured, route registration silently skipped
## Remaining Work
### Operational — Next Priority
- [ ] **MCR auth for mcp user** — podman pull from MCR requires OCI token
auth. Currently using image save/load workaround. Need either: OCI token
flow support in the agent, or podman login with service account credentials.
- [ ] **Vade DNS routing** — Tailscale MagicDNS intercepts `*.svc.mcp.metacircular.net`
queries on vade, preventing hostname-based TLS connections. CLI currently
uses IP address directly. Fix: Tailscale DNS configuration or split-horizon
setup on vade.
- [ ] **Service export completeness**`mcp service export` only captures
name + image from the registry. Should include full spec (network, ports,
volumes, user, restart, cmd). Requires the agent's `ListServices` response
to include full `ComponentSpec` data, not just `ComponentInfo`.
### Quality
- [ ] **P5.1** Integration test suite — end-to-end CLI → agent → podman tests
- [ ] **P5.2** Bootstrap procedure test — documented and verified
- [ ] **README.md** — quick-start guide
- [ ] **RUNBOOK.md** — operational procedures (unseal metacrypt, restart
services, disaster recovery)
### Design
- [ ] **Self-management** — how MCP updates mc-proxy and its own agent without
circular dependency. Likely answer: NixOS manages the agent and mc-proxy
binaries; MCP manages their containers. Or: staged restart with health
checks.
- [ ] **ARCHITECTURE.md proto naming** — update spec to match buf-lint-compliant
message names (StopServiceRequest vs ServiceRequest, AdoptContainers vs
AdoptContainer).
- [ ] **mcdsl DefaultPath helper**`DefaultPath(name) string` for consistent
config file discovery across all services. Root: /srv, /etc. User: XDG, /srv.
- [ ] **Engineering standards update** — document REST+gRPC parity exception
for infrastructure services (MCP agent).
### Infrastructure
- [ ] **Certificate renewal** — MCP-managed cert renewal before expiry.
Agent cert expires 2026-06-24. Need automated renewal via Metacrypt ACME
or REST API.
- [ ] **Monitor alerting** — configure alert_command on rift (ntfy, webhook,
or custom script) for drift/flap notifications.
- [ ] **Backup timer** — install mcp-agent-backup timer via NixOS config.
## Current State (2026-03-26)
MCP is deployed and operational on rift. The agent runs as a systemd service
under the `mcp` user with rootless podman. All platform services (metacrypt,
mc-proxy, mcr, mcns) are managed by MCP with complete service definitions.
```
$ mcp status
SERVICE COMPONENT DESIRED OBSERVED VERSION
mc-proxy mc-proxy running running latest
mcns coredns running running 1.12.1
mcr api running running latest
mcr web running running latest
metacrypt api running running latest
metacrypt web running running latest
```

View File

@@ -32,7 +32,7 @@ else builds on.
structure, and configure tooling.
**Deliverables:**
- `go.mod` with module path `git.wntrmute.dev/kyle/mcp`
- `go.mod` with module path `git.wntrmute.dev/mc/mcp`
- `Makefile` with standard targets (build, test, vet, lint, proto,
proto-lint, clean, all)
- `.golangci.yaml` with platform-standard linter config

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# MCP — Metacircular Control Plane
MCP is the orchestrator for the [Metacircular](https://metacircular.net)
platform. It manages container lifecycle, tracks what services run where,
and transfers files between the operator's workstation and managed nodes.
## Architecture
**CLI** (`mcp`) — thin client on the operator's workstation. Reads local
service definition files, pushes intent to agents, queries status.
**Agent** (`mcp-agent`) — per-node daemon. Manages containers via rootless
podman, stores a SQLite registry of desired/observed state, monitors for
drift, and alerts the operator.
## Quick Start
### Build
```bash
make all # vet, lint, test, build
make mcp # CLI only
make mcp-agent # agent only
```
### Install the CLI
```bash
cp mcp ~/.local/bin/
mkdir -p ~/.config/mcp/services
```
Create `~/.config/mcp/mcp.toml`:
```toml
[services]
dir = "/home/<user>/.config/mcp/services"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp"
[auth]
token_path = "/home/<user>/.config/mcp/token"
[[nodes]]
name = "rift"
address = "100.95.252.120:9444"
```
### Authenticate
```bash
mcp login
```
### Check status
```bash
mcp status # full picture: services, drift, events
mcp ps # live container check with uptime
mcp list # quick registry query
```
### Deploy a service
Write a service definition in `~/.config/mcp/services/<name>.toml`:
```toml
name = "myservice"
node = "rift"
active = true
[[components]]
name = "api"
image = "mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0"
network = "mcpnet"
user = "0:0"
restart = "unless-stopped"
ports = ["127.0.0.1:8443:8443"]
volumes = ["/srv/myservice:/srv/myservice"]
cmd = ["server", "--config", "/srv/myservice/myservice.toml"]
```
Then deploy:
```bash
mcp deploy myservice
```
## Commands
| Command | Description |
|---------|-------------|
| `mcp login` | Authenticate to MCIAS |
| `mcp deploy <service>[/<component>]` | Deploy from service definition |
| `mcp stop <service>` | Stop all components |
| `mcp start <service>` | Start all components |
| `mcp restart <service>` | Restart all components |
| `mcp list` | List services (registry) |
| `mcp ps` | Live container check |
| `mcp status [service]` | Full status with drift and events |
| `mcp sync` | Push all service definitions |
| `mcp adopt <service>` | Adopt running containers |
| `mcp service show <service>` | Print spec from agent |
| `mcp service edit <service>` | Edit definition in $EDITOR |
| `mcp service export <service>` | Export agent spec to file |
| `mcp push <file> <service> [path]` | Push file to node |
| `mcp pull <service> <path> [file]` | Pull file from node |
| `mcp node list` | List nodes |
| `mcp node add <name> <addr>` | Add a node |
| `mcp node remove <name>` | Remove a node |
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) — design specification
- [RUNBOOK.md](RUNBOOK.md) — operational procedures
- [PROJECT_PLAN_V1.md](PROJECT_PLAN_V1.md) — implementation plan
- [PROGRESS_V1.md](PROGRESS_V1.md) — progress and remaining work

305
RUNBOOK.md Normal file
View File

@@ -0,0 +1,305 @@
# MCP Runbook
Operational procedures for the Metacircular Control Plane. Written for
operators at 3 AM.
## Service Overview
MCP manages container lifecycle on Metacircular nodes. Two components:
- **mcp-agent** — systemd service on each node (rift). Manages containers
via rootless podman, stores registry in SQLite, monitors for drift.
- **mcp** — CLI on the operator's workstation (vade). Pushes desired state,
queries status.
## Health Checks
### Quick status
```bash
mcp status
```
Shows all services, desired vs observed state, drift, and recent events.
No drift = healthy.
### Agent process
```bash
ssh rift "doas systemctl status mcp-agent"
ssh rift "doas journalctl -u mcp-agent --since '10 min ago' --no-pager"
```
### Individual service
```bash
mcp status metacrypt
```
## Common Operations
### Check what's running
```bash
mcp ps # live check with uptime
mcp list # from registry (no runtime query)
mcp status # full picture with drift and events
```
### Restart a service
```bash
mcp restart metacrypt
```
Restarts all components. Does not change the `active` flag. Metacrypt
will need to be unsealed after restart.
### Stop a service
```bash
mcp stop metacrypt
```
Sets `active = false` in the service definition file and stops all
containers. The agent will not restart them.
### Start a stopped service
```bash
mcp start metacrypt
```
Sets `active = true` and starts all containers.
### Deploy an update
Edit the service definition to update the image tag, then deploy:
```bash
mcp service edit metacrypt # opens in $EDITOR
mcp deploy metacrypt # deploys all components
mcp deploy metacrypt/web # deploy just the web component
```
### Push a config file to a node
```bash
mcp push metacrypt.toml metacrypt # → /srv/metacrypt/metacrypt.toml
mcp push cert.pem metacrypt certs/cert.pem # → /srv/metacrypt/certs/cert.pem
```
### Pull a file from a node
```bash
mcp pull metacrypt metacrypt.toml ./local-copy.toml
```
### Sync desired state
Push all service definitions to the agent without deploying:
```bash
mcp sync
```
### View service definition
```bash
mcp service show metacrypt # from agent registry
cat ~/.config/mcp/services/metacrypt.toml # local file
```
### Export service definition from agent
```bash
mcp service export metacrypt
```
Writes the agent's current spec to the local service definition file.
## Unsealing Metacrypt
Metacrypt starts sealed after any restart. Unseal via the API:
```bash
curl -sk -X POST https://metacrypt.svc.mcp.metacircular.net:8443/v1/unseal \
-H "Content-Type: application/json" \
-d '{"password":"<unseal-password>"}'
```
Or via the web UI at `https://metacrypt.svc.mcp.metacircular.net`.
**Important:** Restarting metacrypt-api requires unsealing. To avoid this
when updating just the UI, deploy only the web component:
```bash
mcp deploy metacrypt/web
```
## Agent Management
### Restart the agent
```bash
ssh rift "doas systemctl restart mcp-agent"
```
Containers keep running — the agent is stateless w.r.t. container
lifecycle. Podman's restart policy keeps containers up.
### View agent logs
```bash
ssh rift "doas journalctl -u mcp-agent -f" # follow
ssh rift "doas journalctl -u mcp-agent --since today" # today's logs
```
### Agent database backup
```bash
ssh rift "doas -u mcp /usr/local/bin/mcp-agent snapshot --config /srv/mcp/mcp-agent.toml"
```
Backups go to `/srv/mcp/backups/`.
### Update the agent binary
```bash
# On vade, in the mcp repo:
make clean && make mcp-agent
scp mcp-agent rift:/tmp/
ssh rift "doas systemctl stop mcp-agent && \
doas cp /tmp/mcp-agent /usr/local/bin/mcp-agent && \
doas systemctl start mcp-agent"
```
### Update the CLI binary
```bash
make clean && make mcp
cp mcp ~/.local/bin/
```
## Node Management
### List nodes
```bash
mcp node list
```
### Add a node
```bash
mcp node add <name> <address:port>
```
### Remove a node
```bash
mcp node remove <name>
```
## TLS Certificate Renewal
The agent's TLS cert is at `/srv/mcp/certs/cert.pem`. Check expiry:
```bash
ssh rift "openssl x509 -in /srv/mcp/certs/cert.pem -noout -enddate"
```
To renew (requires a Metacrypt token):
```bash
export METACRYPT_TOKEN="<token>"
ssh rift "curl -sk -X POST https://127.0.0.1:18443/v1/engine/request \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer $METACRYPT_TOKEN' \
-d '{
\"mount\": \"pki\",
\"operation\": \"issue\",
\"path\": \"web\",
\"data\": {
\"issuer\": \"web\",
\"common_name\": \"mcp-agent.svc.mcp.metacircular.net\",
\"profile\": \"server\",
\"dns_names\": [\"mcp-agent.svc.mcp.metacircular.net\"],
\"ip_addresses\": [\"100.95.252.120\", \"192.168.88.181\"],
\"ttl\": \"2160h\"
}
}'" > /tmp/cert-response.json
# Extract and install cert+key from the JSON response, then:
ssh rift "doas systemctl restart mcp-agent"
```
## Incident Procedures
### Service not running (drift detected)
1. `mcp status` — identify which service/component drifted.
2. Check agent logs: `ssh rift "doas journalctl -u mcp-agent --since '10 min ago'"`
3. Check container logs: `ssh rift "doas -u mcp podman logs <container-name>"`
4. Restart: `mcp restart <service>`
5. If metacrypt: unseal after restart.
### Agent unreachable
1. Check if the agent process is running: `ssh rift "doas systemctl status mcp-agent"`
2. If stopped: `ssh rift "doas systemctl start mcp-agent"`
3. Check logs for crash reason: `ssh rift "doas journalctl -u mcp-agent -n 50"`
4. Containers keep running independently — podman's restart policy handles them.
### Token expired
MCP CLI shows `UNAUTHENTICATED` or `PERMISSION_DENIED`:
1. Check token: the mcp-agent service account token is at `~/.config/mcp/token`
2. Validate: `curl -sk -X POST -H "Authorization: Bearer $(cat ~/.config/mcp/token)" https://mcias.metacircular.net:8443/v1/token/validate`
3. If expired: generate a new service account token from MCIAS admin dashboard.
### Database corruption
The agent's SQLite database is at `/srv/mcp/mcp.db`:
1. Stop the agent: `ssh rift "doas systemctl stop mcp-agent"`
2. Restore from backup: `ssh rift "doas -u mcp cp /srv/mcp/backups/<latest>.db /srv/mcp/mcp.db"`
3. Start the agent: `ssh rift "doas systemctl start mcp-agent"`
4. Run `mcp sync` to re-push desired state.
If no backup exists, delete the database and re-bootstrap:
1. `ssh rift "doas -u mcp rm /srv/mcp/mcp.db"`
2. `ssh rift "doas systemctl start mcp-agent"` (creates fresh database)
3. `mcp sync` (pushes all service definitions)
### Disaster recovery (rift lost)
1. Provision new machine, connect to overlay network.
2. Apply NixOS config (creates mcp user, installs agent).
3. Install mcp-agent binary.
4. Restore `/srv/` from backups (each service's backup timer creates daily snapshots).
5. Provision TLS cert from Metacrypt.
6. Start agent: `doas systemctl start mcp-agent`
7. `mcp sync` from vade to push service definitions.
8. Unseal Metacrypt.
## File Locations
### On rift (agent)
| Path | Purpose |
|------|---------|
| `/srv/mcp/mcp-agent.toml` | Agent config |
| `/srv/mcp/mcp.db` | Registry database |
| `/srv/mcp/certs/` | Agent TLS cert and key |
| `/srv/mcp/backups/` | Database snapshots |
| `/srv/<service>/` | Service data directories |
### On vade (CLI)
| Path | Purpose |
|------|---------|
| `~/.config/mcp/mcp.toml` | CLI config |
| `~/.config/mcp/token` | MCIAS bearer token |
| `~/.config/mcp/services/` | Service definition files |

View File

@@ -5,8 +5,8 @@ import (
"log"
"os"
"git.wntrmute.dev/kyle/mcp/internal/agent"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/agent"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
)

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
"time"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
_ "modernc.org/sqlite"
)

View File

@@ -4,8 +4,8 @@ import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
)

168
cmd/mcp/build.go Normal file
View File

@@ -0,0 +1,168 @@
package main
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
)
func buildCmd() *cobra.Command {
return &cobra.Command{
Use: "build <service>[/<image>]",
Short: "Build and push images for a service",
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, imageFilter := parseServiceArg(args[0])
def, err := loadServiceDef(cmd, cfg, serviceName)
if err != nil {
return err
}
rt := &runtime.Podman{}
return buildServiceImages(cmd.Context(), cfg, def, rt, imageFilter)
},
}
}
// buildServiceImages builds and pushes images for a service definition.
// If imageFilter is non-empty, only the matching image is built.
func buildServiceImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.ServiceDef, rt *runtime.Podman, imageFilter string) error {
if def.Build == nil || len(def.Build.Images) == 0 {
return fmt.Errorf("service %q has no [build.images] configuration", def.Name)
}
if def.Path == "" {
return fmt.Errorf("service %q has no path configured", def.Name)
}
if cfg.Build.Workspace == "" {
return fmt.Errorf("build.workspace is not configured in %s", cfgPath)
}
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
for imageName, dockerfile := range def.Build.Images {
if imageFilter != "" && imageName != imageFilter {
continue
}
imageRef := findImageRef(def, imageName)
if imageRef == "" {
return fmt.Errorf("no component references image %q in service %q", imageName, def.Name)
}
fmt.Printf("building %s from %s\n", imageRef, dockerfile)
if err := rt.Build(ctx, imageRef, sourceDir, dockerfile); err != nil {
return fmt.Errorf("build %s: %w", imageRef, err)
}
fmt.Printf("pushing %s\n", imageRef)
if err := rt.Push(ctx, imageRef); err != nil {
return fmt.Errorf("push %s: %w", imageRef, err)
}
}
if imageFilter != "" {
if _, ok := def.Build.Images[imageFilter]; !ok {
return fmt.Errorf("image %q not found in [build.images] for service %q", imageFilter, def.Name)
}
}
return nil
}
// findImageRef finds the full image reference for a build image name by
// matching it against component image fields. The image name from
// [build.images] matches the repository name in the component's image
// reference (the path segment after the last slash, before the tag).
func findImageRef(def *servicedef.ServiceDef, imageName string) string {
for _, c := range def.Components {
repoName := extractRepoName(c.Image)
if repoName == imageName {
return c.Image
}
}
return ""
}
// extractRepoName returns the repository name from an image reference.
// Examples:
//
// "mcr.svc.mcp.metacircular.net:8443/mcr:v1.1.0" -> "mcr"
// "mcr.svc.mcp.metacircular.net:8443/mcr-web:v1.2.0" -> "mcr-web"
// "mcr-web:v1.2.0" -> "mcr-web"
// "mcr-web" -> "mcr-web"
func extractRepoName(image string) string {
// Strip registry prefix (everything up to and including the last slash).
name := image
if i := strings.LastIndex(image, "/"); i >= 0 {
name = image[i+1:]
}
// Strip tag.
if i := strings.LastIndex(name, ":"); i >= 0 {
name = name[:i]
}
return name
}
// ensureImages checks that all component images exist in the registry.
// If an image is missing and the service has build configuration, it
// builds and pushes the image. Returns nil if all images are available.
func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.ServiceDef, rt *runtime.Podman, component string) error {
if def.Build == nil || len(def.Build.Images) == 0 {
return nil // no build config, skip auto-build
}
for _, c := range def.Components {
if component != "" && c.Name != component {
continue
}
repoName := extractRepoName(c.Image)
dockerfile, ok := def.Build.Images[repoName]
if !ok {
continue // no Dockerfile for this image, skip
}
exists, err := rt.ImageExists(ctx, c.Image)
if err != nil {
return fmt.Errorf("check image %s: %w", c.Image, err)
}
if exists {
continue
}
// Image missing — build and push.
if def.Path == "" {
return fmt.Errorf("image %s not found in registry and service %q has no path configured", c.Image, def.Name)
}
if cfg.Build.Workspace == "" {
return fmt.Errorf("image %s not found in registry and build.workspace is not configured", c.Image)
}
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile)
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
return fmt.Errorf("auto-build %s: %w", c.Image, err)
}
fmt.Printf("pushing %s\n", c.Image)
if err := rt.Push(ctx, c.Image); err != nil {
return fmt.Errorf("auto-push %s: %w", c.Image, err)
}
}
return nil
}

View File

@@ -8,9 +8,10 @@ import (
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
)
func deployCmd() *cobra.Command {
@@ -31,6 +32,12 @@ func deployCmd() *cobra.Command {
return err
}
// Auto-build missing images if the service has build config.
rt := &runtime.Podman{}
if err := ensureImages(cmd.Context(), cfg, def, rt, component); err != nil {
return err
}
spec := servicedef.ToProto(def)
address, err := findNodeAddress(cfg, def.Node)

View File

@@ -6,9 +6,10 @@ import (
"crypto/x509"
"fmt"
"os"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
@@ -68,5 +69,5 @@ func loadBearerToken(cfg *config.CLIConfig) (string, error) {
if err != nil {
return "", fmt.Errorf("read token from %q: %w (run 'mcp login' first)", cfg.Auth.TokenPath, err)
}
return string(token), nil
return strings.TrimSpace(string(token)), nil
}

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"os"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// findNodeAddress looks up a node by name in the CLI config and returns

View File

@@ -7,9 +7,9 @@ import (
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
)
func stopCmd() *cobra.Command {

View File

@@ -8,8 +8,8 @@ import (
"github.com/spf13/cobra"
"git.wntrmute.dev/kyle/mcp/internal/auth"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
func loginCmd() *cobra.Command {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
@@ -18,7 +19,11 @@ func main() {
Use: "mcp",
Short: "Metacircular Control Plane CLI",
}
root.PersistentFlags().StringVarP(&cfgPath, "config", "c", "", "config file path")
defaultCfg := ""
if home, err := os.UserHomeDir(); err == nil {
defaultCfg = filepath.Join(home, ".config", "mcp", "mcp.toml")
}
root.PersistentFlags().StringVarP(&cfgPath, "config", "c", defaultCfg, "config file path")
root.AddCommand(&cobra.Command{
Use: "version",
@@ -29,6 +34,7 @@ func main() {
})
root.AddCommand(loginCmd())
root.AddCommand(buildCmd())
root.AddCommand(deployCmd())
root.AddCommand(stopCmd())
root.AddCommand(startCmd())
@@ -42,6 +48,7 @@ func main() {
root.AddCommand(pushCmd())
root.AddCommand(pullCmd())
root.AddCommand(nodeCmd())
root.AddCommand(purgeCmd())
if err := root.Execute(); err != nil {
log.Fatal(err)

View File

@@ -7,7 +7,7 @@ import (
toml "github.com/pelletier/go-toml/v2"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
)

119
cmd/mcp/purge.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
"github.com/spf13/cobra"
)
func purgeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "purge [service[/component]]",
Short: "Remove stale registry entries for gone, undefined components",
Long: `Purge removes registry entries that are both unwanted (not in any
current service definition) and gone (no corresponding container in the
runtime). It never stops or removes running containers.
Use --dry-run to preview what would be purged.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
dryRun, _ := cmd.Flags().GetBool("dry-run")
var service, component string
if len(args) == 1 {
service, component = parseServiceArg(args[0])
}
// Load all local service definitions to build the set of
// currently-defined service/component pairs.
definedComponents := buildDefinedComponents(cfg)
// Build node address lookup.
nodeAddr := make(map[string]string, len(cfg.Nodes))
for _, n := range cfg.Nodes {
nodeAddr[n.Name] = n.Address
}
// If a specific service was given and we can find its node,
// only talk to that node. Otherwise, talk to all nodes.
targetNodes := cfg.Nodes
if service != "" {
if nodeName, nodeAddr, err := findServiceNode(cfg, service); err == nil {
targetNodes = []config.NodeConfig{{Name: nodeName, Address: nodeAddr}}
}
}
anyResults := false
for _, node := range targetNodes {
client, conn, err := dialAgent(node.Address, cfg)
if err != nil {
return fmt.Errorf("dial %s: %w", node.Name, err)
}
defer func() { _ = conn.Close() }()
resp, err := client.PurgeComponent(context.Background(), &mcpv1.PurgeRequest{
Service: service,
Component: component,
DryRun: dryRun,
DefinedComponents: definedComponents,
})
if err != nil {
return fmt.Errorf("purge on %s: %w", node.Name, err)
}
for _, r := range resp.GetResults() {
anyResults = true
if r.GetPurged() {
if dryRun {
fmt.Printf("would purge %s/%s (%s)\n", r.GetService(), r.GetComponent(), r.GetReason())
} else {
fmt.Printf("purged %s/%s (%s)\n", r.GetService(), r.GetComponent(), r.GetReason())
}
} else {
fmt.Printf("skipped %s/%s (%s)\n", r.GetService(), r.GetComponent(), r.GetReason())
}
}
}
if !anyResults {
fmt.Println("nothing to purge")
}
return nil
},
}
cmd.Flags().Bool("dry-run", false, "preview what would be purged without modifying the registry")
return cmd
}
// buildDefinedComponents reads all local service definition files and returns
// a list of "service/component" strings for every defined component.
func buildDefinedComponents(cfg *config.CLIConfig) []string {
defs, err := servicedef.LoadAll(cfg.Services.Dir)
if err != nil {
// If we can't read service definitions, return an empty list.
// The agent will treat every component as undefined, which is the
// most conservative behavior (everything eligible gets purged).
return nil
}
var defined []string
for _, def := range defs {
for _, comp := range def.Components {
defined = append(defined, def.Name+"/"+comp.Name)
}
}
return defined
}

View File

@@ -7,9 +7,9 @@ import (
"os/exec"
"path/filepath"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"google.golang.org/grpc"

View File

@@ -7,8 +7,8 @@ import (
"text/tabwriter"
"time"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
)

View File

@@ -4,9 +4,9 @@ import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/servicedef"
"github.com/spf13/cobra"
)

View File

@@ -7,8 +7,8 @@ import (
"os"
"path/filepath"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/config"
"github.com/spf13/cobra"
)

View File

@@ -11,6 +11,8 @@ RestartSec=5
User=mcp
Group=mcp
Environment=HOME=/srv/mcp
Environment=XDG_RUNTIME_DIR=/run/user/%U
NoNewPrivileges=true
ProtectSystem=strict

198
docs/bootstrap.md Normal file
View File

@@ -0,0 +1,198 @@
# MCP Bootstrap Procedure
How to bring MCP up on a node for the first time, including migrating
existing containers from another user's podman instance.
## Prerequisites
- NixOS configuration applied with `configs/mcp.nix` (creates `mcp` user
with rootless podman, subuid/subgid, systemd service)
- MCIAS system account with `admin` role (for token validation and cert
provisioning)
- Metacrypt running (for TLS certificate issuance)
## Step 1: Provision TLS Certificate
Issue a cert from Metacrypt with DNS and IP SANs:
```bash
export METACRYPT_TOKEN="<admin-token>"
# From a machine that can reach Metacrypt (e.g., via loopback on rift):
curl -sk -X POST https://127.0.0.1:18443/v1/engine/request \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $METACRYPT_TOKEN" \
-d '{
"mount": "pki",
"operation": "issue",
"path": "web",
"data": {
"issuer": "web",
"common_name": "mcp-agent.svc.mcp.metacircular.net",
"profile": "server",
"dns_names": ["mcp-agent.svc.mcp.metacircular.net"],
"ip_addresses": ["<tailscale-ip>", "<lan-ip>"],
"ttl": "2160h"
}
}' > cert-response.json
# Extract cert and key from the JSON response and install:
doas cp cert.pem /srv/mcp/certs/cert.pem
doas cp key.pem /srv/mcp/certs/key.pem
doas chown mcp:mcp /srv/mcp/certs/cert.pem /srv/mcp/certs/key.pem
doas chmod 600 /srv/mcp/certs/cert.pem /srv/mcp/certs/key.pem
```
## Step 2: Add DNS Record
Add an A record for `mcp-agent.svc.mcp.metacircular.net` pointing to the
node's IP in the MCNS zone file, bump the serial, restart CoreDNS.
## Step 3: Write Agent Config
Create `/srv/mcp/mcp-agent.toml`:
```toml
[server]
grpc_addr = "<tailscale-ip>:9444"
tls_cert = "/srv/mcp/certs/cert.pem"
tls_key = "/srv/mcp/certs/key.pem"
[database]
path = "/srv/mcp/mcp.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-agent"
[agent]
node_name = "<node-name>"
container_runtime = "podman"
[monitor]
interval = "60s"
alert_command = []
cooldown = "15m"
flap_threshold = 3
flap_window = "10m"
retention = "30d"
[log]
level = "info"
```
## Step 4: Install Agent Binary
```bash
scp mcp-agent <node>:/tmp/
ssh <node> "doas cp /tmp/mcp-agent /usr/local/bin/mcp-agent"
```
## Step 5: Start the Agent
```bash
ssh <node> "doas systemctl start mcp-agent"
ssh <node> "doas systemctl status mcp-agent"
```
## Step 6: Configure CLI
On the operator's workstation, create `~/.config/mcp/mcp.toml` and save
the MCIAS admin service account token to `~/.config/mcp/token`.
## Step 7: Migrate Containers (if existing)
If containers are running under another user (e.g., `kyle`), migrate them
to the `mcp` user's podman. Process each service in dependency order:
**Dependency order:** Metacrypt → MC-Proxy → MCR → MCNS
For each service:
```bash
# 1. Stop containers under the old user
ssh <node> "podman stop <container> && podman rm <container>"
# 2. Transfer ownership of data directory
ssh <node> "doas chown -R mcp:mcp /srv/<service>"
# 3. Transfer images to mcp's podman
ssh <node> "podman save <image> -o /tmp/<service>.tar"
ssh <node> "doas su -l -s /bin/sh mcp -c 'XDG_RUNTIME_DIR=/run/user/<uid> podman load -i /tmp/<service>.tar'"
# 4. Start containers under mcp (with new naming convention)
ssh <node> "doas su -l -s /bin/sh mcp -c 'XDG_RUNTIME_DIR=/run/user/<uid> podman run -d \
--name <service>-<component> \
--network mcpnet \
--restart unless-stopped \
--user 0:0 \
-p <ports> \
-v /srv/<service>:/srv/<service> \
<image> <cmd>'"
```
**Container naming convention:** `<service>-<component>` (e.g.,
`metacrypt-api`, `metacrypt-web`, `mc-proxy`).
**Network:** Services whose components need to communicate (metacrypt
api↔web, mcr api↔web) must be on the same podman network with DNS
enabled. Create with `podman network create mcpnet`.
**Config updates:** If service configs reference container names for
inter-component communication (e.g., `vault_grpc = "metacrypt:9443"`),
update them to use the new names (e.g., `vault_grpc = "metacrypt-api:9443"`).
**Unseal Metacrypt** after migration — it starts sealed.
## Step 8: Adopt Containers
```bash
mcp adopt metacrypt
mcp adopt mc-proxy
mcp adopt mcr
mcp adopt mcns
```
## Step 9: Export and Complete Service Definitions
```bash
mcp service export metacrypt
mcp service export mc-proxy
mcp service export mcr
mcp service export mcns
```
The exported files will have name + image only. Edit each file to add the
full container spec: network, ports, volumes, user, restart, cmd.
Then sync to push the complete specs:
```bash
mcp sync
```
## Step 10: Verify
```bash
mcp status
```
All services should show `desired: running`, `observed: running`, no drift.
## Lessons Learned (from first deployment, 2026-03-26)
- **NixOS systemd sandbox**: `ProtectHome=true` blocks `/run/user` which
rootless podman needs. Use `ProtectHome=false`. `ProtectSystem=strict`
also blocks it; use `full` instead.
- **PATH**: the agent's systemd unit needs `PATH=/run/current-system/sw/bin`
to find podman.
- **XDG_RUNTIME_DIR**: must be set to `/run/user/<uid>` for rootless podman.
Pin the UID in NixOS config to avoid drift.
- **Podman ps JSON**: the `Command` field is `[]string`, not `string`.
- **Container naming**: `mc-proxy` (service with hyphen) breaks naive split
on `-`. The agent uses registry-aware splitting.
- **Token whitespace**: token files with trailing newlines cause gRPC header
errors. The CLI trims whitespace.
- **MCR auth**: rootless podman under a new user can't pull from MCR without
OCI token auth. Workaround: `podman save` + `podman load` to transfer
images.

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1774388614,
"narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

48
flake.nix Normal file
View File

@@ -0,0 +1,48 @@
{
description = "mcp - Metacircular Control Plane";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
version = "0.5.0";
in
{
packages.${system} = {
default = pkgs.buildGoModule {
pname = "mcp";
inherit version;
src = ./.;
vendorHash = null;
subPackages = [
"cmd/mcp"
];
ldflags = [
"-s"
"-w"
"-X main.version=${version}"
];
};
mcp-agent = pkgs.buildGoModule {
pname = "mcp-agent";
inherit version;
src = ./.;
vendorHash = null;
subPackages = [
"cmd/mcp-agent"
];
ldflags = [
"-s"
"-w"
"-X main.version=${version}"
];
};
};
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ const (
McpAgentService_GetServiceStatus_FullMethodName = "/mcp.v1.McpAgentService/GetServiceStatus"
McpAgentService_LiveCheck_FullMethodName = "/mcp.v1.McpAgentService/LiveCheck"
McpAgentService_AdoptContainers_FullMethodName = "/mcp.v1.McpAgentService/AdoptContainers"
McpAgentService_PurgeComponent_FullMethodName = "/mcp.v1.McpAgentService/PurgeComponent"
McpAgentService_PushFile_FullMethodName = "/mcp.v1.McpAgentService/PushFile"
McpAgentService_PullFile_FullMethodName = "/mcp.v1.McpAgentService/PullFile"
McpAgentService_NodeStatus_FullMethodName = "/mcp.v1.McpAgentService/NodeStatus"
@@ -50,6 +51,8 @@ type McpAgentServiceClient interface {
LiveCheck(ctx context.Context, in *LiveCheckRequest, opts ...grpc.CallOption) (*LiveCheckResponse, error)
// Adopt
AdoptContainers(ctx context.Context, in *AdoptContainersRequest, opts ...grpc.CallOption) (*AdoptContainersResponse, error)
// Purge
PurgeComponent(ctx context.Context, in *PurgeRequest, opts ...grpc.CallOption) (*PurgeResponse, error)
// File transfer
PushFile(ctx context.Context, in *PushFileRequest, opts ...grpc.CallOption) (*PushFileResponse, error)
PullFile(ctx context.Context, in *PullFileRequest, opts ...grpc.CallOption) (*PullFileResponse, error)
@@ -155,6 +158,16 @@ func (c *mcpAgentServiceClient) AdoptContainers(ctx context.Context, in *AdoptCo
return out, nil
}
func (c *mcpAgentServiceClient) PurgeComponent(ctx context.Context, in *PurgeRequest, opts ...grpc.CallOption) (*PurgeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PurgeResponse)
err := c.cc.Invoke(ctx, McpAgentService_PurgeComponent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mcpAgentServiceClient) PushFile(ctx context.Context, in *PushFileRequest, opts ...grpc.CallOption) (*PushFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PushFileResponse)
@@ -202,6 +215,8 @@ type McpAgentServiceServer interface {
LiveCheck(context.Context, *LiveCheckRequest) (*LiveCheckResponse, error)
// Adopt
AdoptContainers(context.Context, *AdoptContainersRequest) (*AdoptContainersResponse, error)
// Purge
PurgeComponent(context.Context, *PurgeRequest) (*PurgeResponse, error)
// File transfer
PushFile(context.Context, *PushFileRequest) (*PushFileResponse, error)
PullFile(context.Context, *PullFileRequest) (*PullFileResponse, error)
@@ -244,6 +259,9 @@ func (UnimplementedMcpAgentServiceServer) LiveCheck(context.Context, *LiveCheckR
func (UnimplementedMcpAgentServiceServer) AdoptContainers(context.Context, *AdoptContainersRequest) (*AdoptContainersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdoptContainers not implemented")
}
func (UnimplementedMcpAgentServiceServer) PurgeComponent(context.Context, *PurgeRequest) (*PurgeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PurgeComponent not implemented")
}
func (UnimplementedMcpAgentServiceServer) PushFile(context.Context, *PushFileRequest) (*PushFileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PushFile not implemented")
}
@@ -436,6 +454,24 @@ func _McpAgentService_AdoptContainers_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_PurgeComponent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PurgeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(McpAgentServiceServer).PurgeComponent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: McpAgentService_PurgeComponent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(McpAgentServiceServer).PurgeComponent(ctx, req.(*PurgeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _McpAgentService_PushFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PushFileRequest)
if err := dec(in); err != nil {
@@ -533,6 +569,10 @@ var McpAgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdoptContainers",
Handler: _McpAgentService_AdoptContainers_Handler,
},
{
MethodName: "PurgeComponent",
Handler: _McpAgentService_PurgeComponent_Handler,
},
{
MethodName: "PushFile",
Handler: _McpAgentService_PushFile_Handler,

3
go.mod
View File

@@ -1,8 +1,9 @@
module git.wntrmute.dev/kyle/mcp
module git.wntrmute.dev/mc/mcp
go 1.25.7
require (
git.wntrmute.dev/mc/mc-proxy v1.2.0
github.com/pelletier/go-toml/v2 v2.3.0
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.42.0

20
go.sum
View File

@@ -1,3 +1,9 @@
git.wntrmute.dev/mc/mc-proxy v1.2.0 h1:TVfwdZzYqMs/ksZ0a6aSR7hKGDDMG8X0Od5RIxlbXKQ=
git.wntrmute.dev/mc/mc-proxy v1.2.0/go.mod h1:6w8smZ/DNJVBb4n5std/faye0ROLEXfk3iJY1XNc1JU=
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -21,10 +27,22 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -44,6 +62,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=

View File

@@ -5,9 +5,9 @@ import (
"fmt"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
// AdoptContainers discovers running containers that match the given service

View File

@@ -4,9 +4,9 @@ import (
"context"
"testing"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
func TestAdoptContainers(t *testing.T) {

View File

@@ -11,12 +11,12 @@ import (
"os/signal"
"syscall"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/auth"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/monitor"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
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/monitor"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
@@ -26,11 +26,15 @@ import (
type Agent struct {
mcpv1.UnimplementedMcpAgentServiceServer
Config *config.AgentConfig
DB *sql.DB
Runtime runtime.Runtime
Monitor *monitor.Monitor
Logger *slog.Logger
Config *config.AgentConfig
DB *sql.DB
Runtime runtime.Runtime
Monitor *monitor.Monitor
Logger *slog.Logger
PortAlloc *PortAllocator
Proxy *ProxyRouter
Certs *CertProvisioner
DNS *DNSRegistrar
}
// Run starts the agent: opens the database, sets up the gRPC server with
@@ -50,12 +54,31 @@ func Run(cfg *config.AgentConfig) error {
mon := monitor.New(db, rt, cfg.Monitor, cfg.Agent.NodeName, logger)
proxy, err := NewProxyRouter(cfg.MCProxy.Socket, cfg.MCProxy.CertDir, logger)
if err != nil {
return fmt.Errorf("connect to mc-proxy: %w", err)
}
certs, err := NewCertProvisioner(cfg.Metacrypt, cfg.MCProxy.CertDir, logger)
if err != nil {
return fmt.Errorf("create cert provisioner: %w", err)
}
dns, err := NewDNSRegistrar(cfg.MCNS, logger)
if err != nil {
return fmt.Errorf("create DNS registrar: %w", err)
}
a := &Agent{
Config: cfg,
DB: db,
Runtime: rt,
Monitor: mon,
Logger: logger,
Config: cfg,
DB: db,
Runtime: rt,
Monitor: mon,
Logger: logger,
PortAlloc: NewPortAllocator(),
Proxy: proxy,
Certs: certs,
DNS: dns,
}
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
@@ -106,6 +129,7 @@ func Run(cfg *config.AgentConfig) error {
logger.Info("shutting down")
mon.Stop()
server.GracefulStop()
_ = proxy.Close()
return nil
case err := <-errCh:
mon.Stop()

244
internal/agent/certs.go Normal file
View File

@@ -0,0 +1,244 @@
package agent
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// renewWindow is how far before expiry a cert is considered stale and
// should be re-issued.
const renewWindow = 30 * 24 * time.Hour // 30 days
// CertProvisioner requests TLS certificates from Metacrypt's CA API
// and writes them to the mc-proxy cert directory. It is nil-safe: all
// methods are no-ops when the receiver is nil.
type CertProvisioner struct {
serverURL string
token string
mount string
issuer string
certDir string
httpClient *http.Client
logger *slog.Logger
}
// NewCertProvisioner creates a CertProvisioner. Returns (nil, nil) if
// cfg.ServerURL is empty (cert provisioning disabled).
func NewCertProvisioner(cfg config.MetacryptConfig, certDir string, logger *slog.Logger) (*CertProvisioner, error) {
if cfg.ServerURL == "" {
logger.Info("metacrypt not configured, cert provisioning disabled")
return nil, nil
}
token, err := auth.LoadToken(cfg.TokenPath)
if err != nil {
return nil, fmt.Errorf("load metacrypt token: %w", err)
}
httpClient, err := newTLSClient(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("create metacrypt HTTP client: %w", err)
}
logger.Info("metacrypt cert provisioner enabled", "server", cfg.ServerURL, "mount", cfg.Mount, "issuer", cfg.Issuer)
return &CertProvisioner{
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
token: token,
mount: cfg.Mount,
issuer: cfg.Issuer,
certDir: certDir,
httpClient: httpClient,
logger: logger,
}, nil
}
// EnsureCert checks whether a valid TLS certificate exists for the
// service. If the cert is missing or near expiry, it requests a new
// one from Metacrypt.
func (p *CertProvisioner) EnsureCert(ctx context.Context, serviceName string, hostnames []string) error {
if p == nil || len(hostnames) == 0 {
return nil
}
certPath := filepath.Join(p.certDir, serviceName+".pem")
if remaining, ok := certTimeRemaining(certPath); ok {
if remaining > renewWindow {
p.logger.Debug("cert valid, skipping provisioning",
"service", serviceName,
"expires_in", remaining.Round(time.Hour),
)
return nil
}
p.logger.Info("cert near expiry, re-issuing",
"service", serviceName,
"expires_in", remaining.Round(time.Hour),
)
}
return p.issueCert(ctx, serviceName, hostnames[0], hostnames)
}
// issueCert calls Metacrypt's CA API to issue a certificate and writes
// the chain and key to the cert directory.
func (p *CertProvisioner) issueCert(ctx context.Context, serviceName, commonName string, dnsNames []string) error {
p.logger.Info("provisioning TLS cert",
"service", serviceName,
"cn", commonName,
"sans", dnsNames,
)
reqBody := map[string]interface{}{
"mount": p.mount,
"operation": "issue",
"data": map[string]interface{}{
"issuer": p.issuer,
"common_name": commonName,
"dns_names": dnsNames,
"profile": "server",
"ttl": "2160h",
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal issue request: %w", err)
}
url := p.serverURL + "/v1/engine/request"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create issue request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.token)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("issue cert: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read issue response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("issue cert: metacrypt returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
ChainPEM string `json:"chain_pem"`
KeyPEM string `json:"key_pem"`
Serial string `json:"serial"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parse issue response: %w", err)
}
if result.ChainPEM == "" || result.KeyPEM == "" {
return fmt.Errorf("issue cert: response missing chain_pem or key_pem")
}
// Write cert and key atomically (temp file + rename).
certPath := filepath.Join(p.certDir, serviceName+".pem")
keyPath := filepath.Join(p.certDir, serviceName+".key")
if err := atomicWrite(certPath, []byte(result.ChainPEM), 0644); err != nil {
return fmt.Errorf("write cert: %w", err)
}
if err := atomicWrite(keyPath, []byte(result.KeyPEM), 0600); err != nil {
return fmt.Errorf("write key: %w", err)
}
p.logger.Info("cert provisioned",
"service", serviceName,
"serial", result.Serial,
"expires_at", result.ExpiresAt,
)
return nil
}
// certTimeRemaining returns the time until the leaf certificate at
// path expires. Returns (0, false) if the cert cannot be read or parsed.
func certTimeRemaining(path string) (time.Duration, bool) {
data, err := os.ReadFile(path) //nolint:gosec // path from trusted config
if err != nil {
return 0, false
}
block, _ := pem.Decode(data)
if block == nil {
return 0, false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return 0, false
}
remaining := time.Until(cert.NotAfter)
if remaining <= 0 {
return 0, true // expired
}
return remaining, true
}
// atomicWrite writes data to a temporary file then renames it to path,
// ensuring readers never see a partial file.
func atomicWrite(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename %s -> %s: %w", tmp, path, err)
}
return nil
}
// newTLSClient creates an HTTP client with TLS 1.3 minimum. If
// caCertPath is non-empty, the CA certificate is loaded into the
// root CA pool.
func newTLSClient(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
}

View File

@@ -0,0 +1,392 @@
package agent
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
func TestNilCertProvisionerIsNoop(t *testing.T) {
var p *CertProvisioner
if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil {
t.Fatalf("EnsureCert on nil: %v", err)
}
}
func TestNewCertProvisionerDisabledWhenUnconfigured(t *testing.T) {
p, err := NewCertProvisioner(config.MetacryptConfig{}, "/tmp", slog.Default())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p != nil {
t.Fatal("expected nil provisioner for empty config")
}
}
func TestEnsureCertSkipsValidCert(t *testing.T) {
certDir := t.TempDir()
certPath := filepath.Join(certDir, "svc.pem")
keyPath := filepath.Join(certDir, "svc.key")
// Generate a cert that expires in 90 days.
writeSelfSignedCert(t, certPath, keyPath, "svc.example.com", 90*24*time.Hour)
// Create a provisioner that would fail if it tried to issue.
p := &CertProvisioner{
serverURL: "https://will-fail-if-called:9999",
certDir: certDir,
logger: slog.Default(),
}
if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil {
t.Fatalf("EnsureCert: %v", err)
}
}
func TestEnsureCertReissuesExpiring(t *testing.T) {
certDir := t.TempDir()
certPath := filepath.Join(certDir, "svc.pem")
keyPath := filepath.Join(certDir, "svc.key")
// Generate a cert that expires in 10 days (within 30-day renewal window).
writeSelfSignedCert(t, certPath, keyPath, "svc.example.com", 10*24*time.Hour)
// Mock Metacrypt API.
newCert, newKey := generateCertPEM(t, "svc.example.com", 90*24*time.Hour)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{
"chain_pem": newCert,
"key_pem": newKey,
"serial": "abc123",
"expires_at": time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := &CertProvisioner{
serverURL: srv.URL,
token: "test-token",
mount: "pki",
issuer: "infra",
certDir: certDir,
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil {
t.Fatalf("EnsureCert: %v", err)
}
// Verify new cert was written.
got, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read cert: %v", err)
}
if string(got) != newCert {
t.Fatal("cert file was not updated with new cert")
}
}
func TestIssueCertWritesFiles(t *testing.T) {
certDir := t.TempDir()
// Mock Metacrypt API.
certPEM, keyPEM := generateCertPEM(t, "svc.example.com", 90*24*time.Hour)
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
var req map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Verify request structure.
if req["mount"] != "pki" || req["operation"] != "issue" {
t.Errorf("unexpected request: %v", req)
}
resp := map[string]string{
"chain_pem": certPEM,
"key_pem": keyPEM,
"serial": "deadbeef",
"expires_at": time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := &CertProvisioner{
serverURL: srv.URL,
token: "my-service-token",
mount: "pki",
issuer: "infra",
certDir: certDir,
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil {
t.Fatalf("EnsureCert: %v", err)
}
// Verify auth header.
if gotAuth != "Bearer my-service-token" {
t.Fatalf("auth header: got %q, want %q", gotAuth, "Bearer my-service-token")
}
// Verify cert file.
certData, err := os.ReadFile(filepath.Join(certDir, "svc.pem"))
if err != nil {
t.Fatalf("read cert: %v", err)
}
if string(certData) != certPEM {
t.Fatal("cert content mismatch")
}
// Verify key file.
keyData, err := os.ReadFile(filepath.Join(certDir, "svc.key"))
if err != nil {
t.Fatalf("read key: %v", err)
}
if string(keyData) != keyPEM {
t.Fatal("key content mismatch")
}
// Verify key file permissions.
info, err := os.Stat(filepath.Join(certDir, "svc.key"))
if err != nil {
t.Fatalf("stat key: %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("key permissions: got %o, want 0600", perm)
}
}
func TestIssueCertAPIError(t *testing.T) {
certDir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
}))
defer srv.Close()
p := &CertProvisioner{
serverURL: srv.URL,
token: "test-token",
mount: "pki",
issuer: "infra",
certDir: certDir,
httpClient: srv.Client(),
logger: slog.Default(),
}
err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"})
if err == nil {
t.Fatal("expected error for sealed metacrypt")
}
}
func TestCertTimeRemaining(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
if _, ok := certTimeRemaining("/nonexistent/cert.pem"); ok {
t.Fatal("expected false for missing file")
}
})
t.Run("valid cert", func(t *testing.T) {
certDir := t.TempDir()
path := filepath.Join(certDir, "test.pem")
writeSelfSignedCert(t, path, filepath.Join(certDir, "test.key"), "test.example.com", 90*24*time.Hour)
remaining, ok := certTimeRemaining(path)
if !ok {
t.Fatal("expected true for valid cert")
}
// Should be close to 90 days.
if remaining < 89*24*time.Hour || remaining > 91*24*time.Hour {
t.Fatalf("remaining: got %v, want ~90 days", remaining)
}
})
t.Run("expired cert", func(t *testing.T) {
certDir := t.TempDir()
path := filepath.Join(certDir, "expired.pem")
// Write a cert that's already expired (valid from -2h to -1h).
writeExpiredCert(t, path, filepath.Join(certDir, "expired.key"), "expired.example.com")
remaining, ok := certTimeRemaining(path)
if !ok {
t.Fatal("expected true for expired cert")
}
if remaining > 0 {
t.Fatalf("remaining: got %v, want <= 0", remaining)
}
})
}
func TestHasL7Routes(t *testing.T) {
tests := []struct {
name string
routes []registry.Route
want bool
}{
{"nil", nil, false},
{"empty", []registry.Route{}, false},
{"l4 only", []registry.Route{{Mode: "l4"}}, false},
{"l7 only", []registry.Route{{Mode: "l7"}}, true},
{"mixed", []registry.Route{{Mode: "l4"}, {Mode: "l7"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasL7Routes(tt.routes); got != tt.want {
t.Fatalf("hasL7Routes = %v, want %v", got, tt.want)
}
})
}
}
func TestL7Hostnames(t *testing.T) {
routes := []registry.Route{
{Mode: "l7", Hostname: ""},
{Mode: "l4", Hostname: "ignored.example.com"},
{Mode: "l7", Hostname: "custom.example.com"},
{Mode: "l7", Hostname: ""}, // duplicate default
}
got := l7Hostnames("myservice", routes)
want := []string{"myservice.svc.mcp.metacircular.net", "custom.example.com"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestAtomicWrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
if err := atomicWrite(path, []byte("hello"), 0644); err != nil {
t.Fatalf("atomicWrite: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(data) != "hello" {
t.Fatalf("got %q, want %q", string(data), "hello")
}
// Verify no .tmp file left behind.
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatal("temp file should not exist after atomic write")
}
}
// --- test helpers ---
// writeSelfSignedCert generates a self-signed cert/key and writes them to disk.
func writeSelfSignedCert(t *testing.T, certPath, keyPath, hostname string, validity time.Duration) {
t.Helper()
certPEM, keyPEM := generateCertPEM(t, hostname, validity)
if err := os.WriteFile(certPath, []byte(certPEM), 0644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
t.Fatalf("write key: %v", err)
}
}
// writeExpiredCert generates a cert that is already expired.
func writeExpiredCert(t *testing.T, certPath, keyPath, hostname string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
NotBefore: time.Now().Add(-2 * time.Hour),
NotAfter: time.Now().Add(-1 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("write key: %v", err)
}
}
// generateCertPEM generates a self-signed cert and returns PEM strings.
func generateCertPEM(t *testing.T, hostname string, validity time.Duration) (certPEM, keyPEM string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(validity),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certBlock := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return string(certBlock), string(keyBlock)
}

View File

@@ -5,10 +5,11 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
// Deploy deploys a service (or a single component of it) to this node.
@@ -49,7 +50,7 @@ func (a *Agent) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.De
// deployComponent handles the full deploy lifecycle for a single component.
func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcpv1.ComponentSpec, active bool) *mcpv1.ComponentResult {
compName := cs.GetName()
containerName := serviceName + "-" + compName
containerName := ContainerNameFor(serviceName, compName)
desiredState := "running"
if !active {
@@ -58,6 +59,25 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
a.Logger.Info("deploying component", "service", serviceName, "component", compName, "desired", desiredState)
// Convert proto routes to registry routes.
var regRoutes []registry.Route
for _, r := range cs.GetRoutes() {
mode := r.GetMode()
if mode == "" {
mode = "l4"
}
name := r.GetName()
if name == "" {
name = "default"
}
regRoutes = append(regRoutes, registry.Route{
Name: name,
Port: int(r.GetPort()),
Mode: mode,
Hostname: r.GetHostname(),
})
}
regComp := &registry.Component{
Name: compName,
Service: serviceName,
@@ -70,6 +90,7 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
Routes: regRoutes,
}
if err := ensureComponent(a.DB, regComp); err != nil {
@@ -89,16 +110,34 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
_ = a.Runtime.Stop(ctx, containerName) // may not exist yet
_ = a.Runtime.Remove(ctx, containerName) // may not exist yet
// Build the container spec. If the component has routes, use route-based
// port allocation and env injection. Otherwise, fall back to legacy ports.
runSpec := runtime.ContainerSpec{
Name: containerName,
Image: cs.GetImage(),
Network: cs.GetNetwork(),
User: cs.GetUser(),
Restart: cs.GetRestart(),
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
Env: cs.GetEnv(),
}
if len(regRoutes) > 0 && a.PortAlloc != nil {
ports, env, err := a.allocateRoutePorts(serviceName, compName, regRoutes)
if err != nil {
return &mcpv1.ComponentResult{
Name: compName,
Error: fmt.Sprintf("allocate route ports: %v", err),
}
}
runSpec.Ports = ports
runSpec.Env = append(runSpec.Env, env...)
} else {
// Legacy: use ports directly from the spec.
runSpec.Ports = cs.GetPorts()
}
if err := a.Runtime.Run(ctx, runSpec); err != nil {
_ = registry.UpdateComponentState(a.DB, serviceName, compName, "", "removed")
return &mcpv1.ComponentResult{
@@ -107,6 +146,31 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
}
}
// Provision TLS certs for L7 routes before registering with mc-proxy.
if a.Certs != nil && hasL7Routes(regRoutes) {
hostnames := l7Hostnames(serviceName, regRoutes)
if err := a.Certs.EnsureCert(ctx, serviceName, hostnames); err != nil {
a.Logger.Warn("failed to provision TLS cert", "service", serviceName, "err", err)
}
}
// Register routes with mc-proxy after the container is running.
if len(regRoutes) > 0 && a.Proxy != nil {
hostPorts, err := registry.GetRouteHostPorts(a.DB, serviceName, compName)
if err != nil {
a.Logger.Warn("failed to get host ports for route registration", "service", serviceName, "component", compName, "err", err)
} else if err := a.Proxy.RegisterRoutes(ctx, serviceName, regRoutes, hostPorts); err != nil {
a.Logger.Warn("failed to register routes with mc-proxy", "service", serviceName, "component", compName, "err", err)
}
}
// Register DNS record for the service.
if a.DNS != nil && len(regRoutes) > 0 {
if err := a.DNS.EnsureRecord(ctx, serviceName); err != nil {
a.Logger.Warn("failed to register DNS record", "service", serviceName, "err", err)
}
}
if err := registry.UpdateComponentState(a.DB, serviceName, compName, "running", "running"); err != nil {
a.Logger.Warn("failed to update component state", "service", serviceName, "component", compName, "err", err)
}
@@ -117,6 +181,36 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
}
}
// allocateRoutePorts allocates host ports for each route, stores them in
// the registry, and returns the port mappings and env vars for the container.
func (a *Agent) allocateRoutePorts(service, component string, routes []registry.Route) ([]string, []string, error) {
var ports []string
var env []string
for _, r := range routes {
hostPort, err := a.PortAlloc.Allocate()
if err != nil {
return nil, nil, fmt.Errorf("allocate port for route %q: %w", r.Name, err)
}
if err := registry.UpdateRouteHostPort(a.DB, service, component, r.Name, hostPort); err != nil {
a.PortAlloc.Release(hostPort)
return nil, nil, fmt.Errorf("store host port for route %q: %w", r.Name, err)
}
ports = append(ports, fmt.Sprintf("127.0.0.1:%d:%d", hostPort, r.Port))
if len(routes) == 1 {
env = append(env, fmt.Sprintf("PORT=%d", hostPort))
} else {
envName := "PORT_" + strings.ToUpper(r.Name)
env = append(env, fmt.Sprintf("%s=%d", envName, hostPort))
}
}
return ports, env, nil
}
// ensureService creates the service if it does not exist, or updates its
// active flag if it does.
func ensureService(db *sql.DB, name string, active bool) error {
@@ -130,6 +224,37 @@ func ensureService(db *sql.DB, name string, active bool) error {
return registry.UpdateServiceActive(db, name, active)
}
// hasL7Routes reports whether any route uses L7 (TLS-terminating) mode.
func hasL7Routes(routes []registry.Route) bool {
for _, r := range routes {
if r.Mode == "l7" {
return true
}
}
return false
}
// l7Hostnames returns the unique hostnames from L7 routes, applying
// the default hostname convention when a route has no explicit hostname.
func l7Hostnames(serviceName string, routes []registry.Route) []string {
seen := make(map[string]bool)
var hostnames []string
for _, r := range routes {
if r.Mode != "l7" {
continue
}
h := r.Hostname
if h == "" {
h = serviceName + ".svc.mcp.metacircular.net"
}
if !seen[h] {
seen[h] = true
hostnames = append(hostnames, h)
}
}
return hostnames
}
// ensureComponent creates the component if it does not exist, or updates its
// spec if it does.
func ensureComponent(db *sql.DB, c *registry.Component) error {

265
internal/agent/dns.go Normal file
View File

@@ -0,0 +1,265 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// DNSRegistrar creates and removes A records in MCNS during deploy
// and stop. It is nil-safe: all methods are no-ops when the receiver
// is nil.
type DNSRegistrar struct {
serverURL string
token string
zone string
nodeAddr string
httpClient *http.Client
logger *slog.Logger
}
// dnsRecord is the JSON representation of an MCNS record.
type dnsRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
Type string `json:"Type"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
// NewDNSRegistrar creates a DNSRegistrar. Returns (nil, nil) if
// cfg.ServerURL is empty (DNS registration disabled).
func NewDNSRegistrar(cfg config.MCNSConfig, logger *slog.Logger) (*DNSRegistrar, 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 := newTLSClient(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("create mcns HTTP client: %w", err)
}
logger.Info("mcns DNS registrar enabled", "server", cfg.ServerURL, "zone", cfg.Zone, "node_addr", cfg.NodeAddr)
return &DNSRegistrar{
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
token: token,
zone: cfg.Zone,
nodeAddr: cfg.NodeAddr,
httpClient: httpClient,
logger: logger,
}, nil
}
// EnsureRecord ensures an A record exists for the service in the
// configured zone, pointing to the node's address.
func (d *DNSRegistrar) EnsureRecord(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)
}
// Check if any existing record already has the correct value.
for _, r := range existing {
if r.Value == d.nodeAddr {
d.logger.Debug("DNS record exists, skipping",
"service", serviceName,
"record", r.Name+"."+d.zone,
"value", r.Value,
)
return nil
}
}
// No record with the correct value — update the first one if it exists.
if len(existing) > 0 {
d.logger.Info("updating DNS record",
"service", serviceName,
"old_value", existing[0].Value,
"new_value", d.nodeAddr,
)
return d.updateRecord(ctx, existing[0].ID, serviceName)
}
// No existing record — create one.
d.logger.Info("creating DNS record",
"service", serviceName,
"record", serviceName+"."+d.zone,
"value", d.nodeAddr,
)
return d.createRecord(ctx, serviceName)
}
// RemoveRecord removes A records for the service from the configured zone.
func (d *DNSRegistrar) 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)
}
if len(existing) == 0 {
d.logger.Debug("no DNS record to remove", "service", serviceName)
return nil
}
for _, r := range existing {
d.logger.Info("removing DNS record",
"service", serviceName,
"record", r.Name+"."+d.zone,
"id", r.ID,
)
if err := d.deleteRecord(ctx, r.ID); err != nil {
return err
}
}
return nil
}
// listRecords returns A records matching the service name in the zone.
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)
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
}
// createRecord creates an A record in the zone.
func (d *DNSRegistrar) createRecord(ctx context.Context, serviceName string) error {
reqBody := map[string]interface{}{
"name": serviceName,
"type": "A",
"value": d.nodeAddr,
"ttl": 300,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal create request: %w", err)
}
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, d.zone)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
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
}
// updateRecord updates an existing record's value.
func (d *DNSRegistrar) updateRecord(ctx context.Context, recordID int, serviceName string) error {
reqBody := map[string]interface{}{
"name": serviceName,
"type": "A",
"value": d.nodeAddr,
"ttl": 300,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal update request: %w", err)
}
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
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
}
// deleteRecord deletes a record by ID.
func (d *DNSRegistrar) 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
}

214
internal/agent/dns_test.go Normal file
View File

@@ -0,0 +1,214 @@
package agent
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.wntrmute.dev/mc/mcp/internal/config"
)
func TestNilDNSRegistrarIsNoop(t *testing.T) {
var d *DNSRegistrar
if err := d.EnsureRecord(context.Background(), "svc"); err != nil {
t.Fatalf("EnsureRecord on nil: %v", err)
}
if err := d.RemoveRecord(context.Background(), "svc"); err != nil {
t.Fatalf("RemoveRecord on nil: %v", err)
}
}
func TestNewDNSRegistrarDisabledWhenUnconfigured(t *testing.T) {
d, err := NewDNSRegistrar(config.MCNSConfig{}, slog.Default())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d != nil {
t.Fatal("expected nil registrar for empty config")
}
}
func TestEnsureRecordCreatesWhenMissing(t *testing.T) {
var gotMethod, gotPath, gotAuth string
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// List returns empty — no existing records.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"records":[]}`))
return
}
gotMethod = r.Method
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1}`))
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method: got %q, want POST", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records" {
t.Fatalf("path: got %q", gotPath)
}
if gotAuth != "Bearer test-token" {
t.Fatalf("auth: got %q", gotAuth)
}
if gotBody["name"] != "myservice" {
t.Fatalf("name: got %v", gotBody["name"])
}
if gotBody["type"] != "A" {
t.Fatalf("type: got %v", gotBody["type"])
}
if gotBody["value"] != "192.168.88.181" {
t.Fatalf("value: got %v", gotBody["value"])
}
}
func TestEnsureRecordSkipsWhenExists(t *testing.T) {
createCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// 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}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
createCalled = true
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if createCalled {
t.Fatal("should not create when record already exists with correct value")
}
}
func TestEnsureRecordUpdatesWrongValue(t *testing.T) {
var gotMethod string
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// 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}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if gotMethod != http.MethodPut {
t.Fatalf("method: got %q, want PUT", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records/42" {
t.Fatalf("path: got %q", gotPath)
}
}
func TestRemoveRecordDeletes(t *testing.T) {
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
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")
_ = json.NewEncoder(w).Encode(resp)
return
}
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.RemoveRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("RemoveRecord: %v", err)
}
if gotMethod != http.MethodDelete {
t.Fatalf("method: got %q, want DELETE", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records/7" {
t.Fatalf("path: got %q", gotPath)
}
}
func TestRemoveRecordNoopWhenMissing(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// List returns empty.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"records":[]}`))
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.RemoveRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("RemoveRecord: %v", err)
}
}

View File

@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

View File

@@ -5,9 +5,9 @@ import (
"database/sql"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
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"
)
@@ -27,9 +27,23 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
var results []*mcpv1.ComponentResult
for _, c := range components {
containerName := req.GetName() + "-" + c.Name
containerName := ContainerNameFor(req.GetName(), c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
// Remove routes from mc-proxy before stopping the container.
if len(c.Routes) > 0 && a.Proxy != nil {
if err := a.Proxy.RemoveRoutes(ctx, req.GetName(), c.Routes); err != nil {
a.Logger.Warn("failed to remove routes", "service", req.GetName(), "component", c.Name, "err", err)
}
}
// Remove DNS record when stopping the service.
if len(c.Routes) > 0 && a.DNS != nil {
if err := a.DNS.RemoveRecord(ctx, req.GetName()); err != nil {
a.Logger.Warn("failed to remove DNS record", "service", req.GetName(), "err", err)
}
}
if err := a.Runtime.Stop(ctx, containerName); err != nil {
a.Logger.Info("stop container (ignored)", "container", containerName, "error", err)
}
@@ -94,7 +108,7 @@ func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceReq
// startComponent removes any existing container and runs a fresh one from
// the registry spec, then updates state to running.
func startComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
containerName := service + "-" + c.Name
containerName := ContainerNameFor(service, c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
// Remove any pre-existing container; ignore errors for non-existent ones.
@@ -118,7 +132,7 @@ func startComponent(ctx context.Context, a *Agent, service string, c *registry.C
// restartComponent stops, removes, and re-creates a container without
// changing the desired_state in the registry.
func restartComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
containerName := service + "-" + c.Name
containerName := ContainerNameFor(service, c.Name)
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
_ = a.Runtime.Stop(ctx, containerName)
@@ -142,7 +156,7 @@ func restartComponent(ctx context.Context, a *Agent, service string, c *registry
// componentToSpec builds a runtime.ContainerSpec from a registry Component.
func componentToSpec(service string, c *registry.Component) runtime.ContainerSpec {
return runtime.ContainerSpec{
Name: service + "-" + c.Name,
Name: ContainerNameFor(service, c.Name),
Image: c.Image,
Network: c.Network,
User: c.UserSpec,

34
internal/agent/names.go Normal file
View File

@@ -0,0 +1,34 @@
package agent
import "strings"
// ContainerNameFor returns the expected container name for a service and
// component. For single-component services where the component name equals
// the service name, the container name is just the service name (e.g.,
// "mc-proxy" not "mc-proxy-mc-proxy").
func ContainerNameFor(service, component string) string {
if service == component {
return service
}
return service + "-" + component
}
// SplitContainerName splits a container name into service and component parts.
// It checks known service names first to handle names like "mc-proxy" where a
// naive split on "-" would produce the wrong result. If no known service
// matches, it falls back to splitting on the first "-".
func SplitContainerName(name string, knownServices map[string]bool) (service, component string) {
if knownServices[name] {
return name, name
}
for svc := range knownServices {
prefix := svc + "-"
if strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
return svc, name[len(prefix):]
}
}
if i := strings.Index(name, "-"); i >= 0 {
return name[:i], name[i+1:]
}
return name, name
}

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
"golang.org/x/sys/unix"
"google.golang.org/protobuf/types/known/timestamppb"
)

View File

@@ -0,0 +1,68 @@
package agent
import (
"fmt"
"math/rand/v2"
"net"
"sync"
)
const (
portRangeMin = 10000
portRangeMax = 60000
maxRetries = 10
)
// PortAllocator manages host port allocation for route-based deployments.
// It tracks allocated ports within the agent session to avoid double-allocation.
type PortAllocator struct {
mu sync.Mutex
allocated map[int]bool
}
// NewPortAllocator creates a new PortAllocator.
func NewPortAllocator() *PortAllocator {
return &PortAllocator{
allocated: make(map[int]bool),
}
}
// Allocate picks a free port in range [10000, 60000).
// It tries random ports, checks availability with net.Listen, and retries up to 10 times.
func (pa *PortAllocator) Allocate() (int, error) {
pa.mu.Lock()
defer pa.mu.Unlock()
for range maxRetries {
port := portRangeMin + rand.IntN(portRangeMax-portRangeMin) //nolint:gosec // port selection, not security
if pa.allocated[port] {
continue
}
if !isPortFree(port) {
continue
}
pa.allocated[port] = true
return port, nil
}
return 0, fmt.Errorf("failed to allocate port after %d attempts", maxRetries)
}
// Release marks a port as available again.
func (pa *PortAllocator) Release(port int) {
pa.mu.Lock()
defer pa.mu.Unlock()
delete(pa.allocated, port)
}
// isPortFree checks if a TCP port is available by attempting to listen on it.
func isPortFree(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return false
}
_ = ln.Close()
return true
}

View File

@@ -0,0 +1,65 @@
package agent
import (
"testing"
)
func TestPortAllocator_Allocate(t *testing.T) {
pa := NewPortAllocator()
port, err := pa.Allocate()
if err != nil {
t.Fatalf("allocate: %v", err)
}
if port < portRangeMin || port >= portRangeMax {
t.Fatalf("port %d out of range [%d, %d)", port, portRangeMin, portRangeMax)
}
}
func TestPortAllocator_NoDuplicates(t *testing.T) {
pa := NewPortAllocator()
ports := make(map[int]bool)
for range 20 {
port, err := pa.Allocate()
if err != nil {
t.Fatalf("allocate: %v", err)
}
if ports[port] {
t.Fatalf("duplicate port allocated: %d", port)
}
ports[port] = true
}
}
func TestPortAllocator_Release(t *testing.T) {
pa := NewPortAllocator()
port, err := pa.Allocate()
if err != nil {
t.Fatalf("allocate: %v", err)
}
pa.Release(port)
// After release, the port should no longer be tracked as allocated.
pa.mu.Lock()
if pa.allocated[port] {
t.Fatal("port should not be tracked after release")
}
pa.mu.Unlock()
}
func TestPortAllocator_PortIsFree(t *testing.T) {
pa := NewPortAllocator()
port, err := pa.Allocate()
if err != nil {
t.Fatalf("allocate: %v", err)
}
// The port should be free (we only track it, we don't hold the listener).
if !isPortFree(port) {
t.Fatalf("allocated port %d should be free on the system", port)
}
}

138
internal/agent/proxy.go Normal file
View File

@@ -0,0 +1,138 @@
package agent
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"git.wntrmute.dev/mc/mc-proxy/client/mcproxy"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
// ProxyRouter registers and removes routes with mc-proxy.
// If the mc-proxy socket is not configured, it logs and returns nil
// (route registration is optional).
type ProxyRouter struct {
client *mcproxy.Client
certDir string
logger *slog.Logger
}
// NewProxyRouter connects to mc-proxy via Unix socket. Returns nil
// if socketPath is empty (route registration disabled).
func NewProxyRouter(socketPath, certDir string, logger *slog.Logger) (*ProxyRouter, error) {
if socketPath == "" {
logger.Info("mc-proxy socket not configured, route registration disabled")
return nil, nil
}
client, err := mcproxy.Dial(socketPath)
if err != nil {
return nil, fmt.Errorf("connect to mc-proxy at %s: %w", socketPath, err)
}
logger.Info("connected to mc-proxy", "socket", socketPath)
return &ProxyRouter{
client: client,
certDir: certDir,
logger: logger,
}, nil
}
// Close closes the mc-proxy connection.
func (p *ProxyRouter) Close() error {
if p == nil || p.client == nil {
return nil
}
return p.client.Close()
}
// RegisterRoutes registers all routes for a service component with mc-proxy.
// 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 {
if p == nil {
return nil
}
for _, r := range routes {
hostPort, ok := hostPorts[r.Name]
if !ok || hostPort == 0 {
continue
}
hostname := r.Hostname
if hostname == "" {
hostname = serviceName + ".svc.mcp.metacircular.net"
}
listenerAddr := listenerForMode(r.Mode, r.Port)
backend := fmt.Sprintf("127.0.0.1:%d", hostPort)
route := mcproxy.Route{
Hostname: hostname,
Backend: backend,
Mode: r.Mode,
BackendTLS: r.Mode == "l4", // L4 passthrough: backend handles TLS. L7: mc-proxy terminates.
}
// L7 routes need TLS cert/key for mc-proxy to terminate TLS.
if r.Mode == "l7" {
route.TLSCert = filepath.Join(p.certDir, serviceName+".pem")
route.TLSKey = filepath.Join(p.certDir, serviceName+".key")
}
p.logger.Info("registering route",
"service", serviceName,
"hostname", hostname,
"listener", listenerAddr,
"backend", backend,
"mode", r.Mode,
)
if err := p.client.AddRoute(ctx, listenerAddr, route); err != nil {
return fmt.Errorf("register route %s on %s: %w", hostname, listenerAddr, err)
}
}
return nil
}
// RemoveRoutes removes all routes for a service component from mc-proxy.
func (p *ProxyRouter) RemoveRoutes(ctx context.Context, serviceName string, routes []registry.Route) error {
if p == nil {
return nil
}
for _, r := range routes {
hostname := r.Hostname
if hostname == "" {
hostname = serviceName + ".svc.mcp.metacircular.net"
}
listenerAddr := listenerForMode(r.Mode, r.Port)
p.logger.Info("removing route",
"service", serviceName,
"hostname", hostname,
"listener", listenerAddr,
)
if err := p.client.RemoveRoute(ctx, listenerAddr, hostname); err != nil {
// Log but don't fail — the route may already be gone.
p.logger.Warn("failed to remove route",
"hostname", hostname,
"listener", listenerAddr,
"err", err,
)
}
}
return nil
}
// listenerForMode returns the mc-proxy listener address for a given
// route mode and external port.
func listenerForMode(mode string, port int) string {
return fmt.Sprintf(":%d", port)
}

View File

@@ -0,0 +1,57 @@
package agent
import (
"testing"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
func TestListenerForMode(t *testing.T) {
tests := []struct {
mode string
port int
want string
}{
{"l4", 8443, ":8443"},
{"l7", 443, ":443"},
{"l4", 9443, ":9443"},
}
for _, tt := range tests {
got := listenerForMode(tt.mode, tt.port)
if got != tt.want {
t.Errorf("listenerForMode(%q, %d) = %q, want %q", tt.mode, tt.port, got, tt.want)
}
}
}
func TestNilProxyRouterIsNoop(t *testing.T) {
var p *ProxyRouter
// All methods should return nil on a nil ProxyRouter.
if err := p.RegisterRoutes(nil, "svc", nil, nil); err != nil {
t.Errorf("RegisterRoutes on nil: %v", err)
}
if err := p.RemoveRoutes(nil, "svc", nil); err != nil {
t.Errorf("RemoveRoutes on nil: %v", err)
}
if err := p.Close(); err != nil {
t.Errorf("Close on nil: %v", err)
}
}
func TestRegisterRoutesSkipsZeroHostPort(t *testing.T) {
// A nil ProxyRouter should be a no-op, so this tests the skip logic
// indirectly. With a nil proxy, RegisterRoutes returns nil even
// with routes that have zero host ports.
var p *ProxyRouter
routes := []registry.Route{
{Name: "rest", Port: 8443, Mode: "l4"},
}
hostPorts := map[string]int{"rest": 0}
if err := p.RegisterRoutes(nil, "svc", routes, hostPorts); err != nil {
t.Errorf("RegisterRoutes: %v", err)
}
}

155
internal/agent/purge.go Normal file
View File

@@ -0,0 +1,155 @@
package agent
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
// PurgeComponent removes stale registry entries for components that are both
// gone (observed state is removed/unknown/exited) and unwanted (not in any
// current service definition). It never touches running containers.
func (a *Agent) PurgeComponent(ctx context.Context, req *mcpv1.PurgeRequest) (*mcpv1.PurgeResponse, error) {
a.Logger.Info("PurgeComponent",
"service", req.GetService(),
"component", req.GetComponent(),
"dry_run", req.GetDryRun(),
)
// Build a set of defined service/component pairs for quick lookup.
defined := make(map[string]bool, len(req.GetDefinedComponents()))
for _, dc := range req.GetDefinedComponents() {
defined[dc] = true
}
// Determine which services to examine.
var services []registry.Service
if req.GetService() != "" {
svc, err := registry.GetService(a.DB, req.GetService())
if err != nil {
return nil, fmt.Errorf("get service %q: %w", req.GetService(), err)
}
services = []registry.Service{*svc}
} else {
var err error
services, err = registry.ListServices(a.DB)
if err != nil {
return nil, fmt.Errorf("list services: %w", err)
}
}
var results []*mcpv1.PurgeResult
for _, svc := range services {
components, err := registry.ListComponents(a.DB, svc.Name)
if err != nil {
return nil, fmt.Errorf("list components for %q: %w", svc.Name, err)
}
// If a specific component was requested, filter to just that one.
if req.GetComponent() != "" {
var filtered []registry.Component
for _, c := range components {
if c.Name == req.GetComponent() {
filtered = append(filtered, c)
}
}
components = filtered
}
for _, comp := range components {
result := a.evaluatePurge(svc.Name, &comp, defined, req.GetDryRun())
results = append(results, result)
}
// If all components of this service were purged (not dry-run),
// check if the service should be cleaned up too.
if !req.GetDryRun() {
remaining, err := registry.ListComponents(a.DB, svc.Name)
if err != nil {
a.Logger.Warn("failed to check remaining components", "service", svc.Name, "err", err)
continue
}
if len(remaining) == 0 {
if err := registry.DeleteService(a.DB, svc.Name); err != nil {
a.Logger.Warn("failed to delete empty service", "service", svc.Name, "err", err)
} else {
a.Logger.Info("purged empty service", "service", svc.Name)
}
}
}
}
return &mcpv1.PurgeResponse{Results: results}, nil
}
// purgeableStates are observed states that indicate a component's container
// is gone and the registry entry can be safely removed.
var purgeableStates = map[string]bool{
"removed": true,
"unknown": true,
"exited": true,
}
// evaluatePurge checks whether a single component is eligible for purge and,
// if not in dry-run mode, deletes it.
func (a *Agent) evaluatePurge(service string, comp *registry.Component, defined map[string]bool, dryRun bool) *mcpv1.PurgeResult {
key := service + "/" + comp.Name
// Safety: refuse to purge components with a live container.
if !purgeableStates[comp.ObservedState] {
return &mcpv1.PurgeResult{
Service: service,
Component: comp.Name,
Purged: false,
Reason: fmt.Sprintf("observed=%s, container still exists", comp.ObservedState),
}
}
// Don't purge components that are still in service definitions.
if defined[key] {
return &mcpv1.PurgeResult{
Service: service,
Component: comp.Name,
Purged: false,
Reason: "still in service definitions",
}
}
reason := fmt.Sprintf("observed=%s, not in service definitions", comp.ObservedState)
if dryRun {
return &mcpv1.PurgeResult{
Service: service,
Component: comp.Name,
Purged: true,
Reason: reason,
}
}
// Delete events first (events table has no FK to components).
if err := registry.DeleteComponentEvents(a.DB, service, comp.Name); err != nil {
a.Logger.Warn("failed to delete events during purge", "service", service, "component", comp.Name, "err", err)
}
// Delete the component (CASCADE handles ports, volumes, cmd).
if err := registry.DeleteComponent(a.DB, service, comp.Name); err != nil {
return &mcpv1.PurgeResult{
Service: service,
Component: comp.Name,
Purged: false,
Reason: fmt.Sprintf("delete failed: %v", err),
}
}
a.Logger.Info("purged component", "service", service, "component", comp.Name, "reason", reason)
return &mcpv1.PurgeResult{
Service: service,
Component: comp.Name,
Purged: true,
Reason: reason,
}
}

View File

@@ -0,0 +1,405 @@
package agent
import (
"context"
"testing"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
func TestPurgeComponentRemoved(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
// Set up a service with a stale component.
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "coredns",
Service: "mcns",
Image: "coredns:latest",
DesiredState: "running",
ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
// Insert an event for this component.
if err := registry.InsertEvent(a.DB, "mcns", "coredns", "running", "removed"); err != nil {
t.Fatalf("insert event: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
DefinedComponents: []string{"mcns/mcns"},
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
r := resp.Results[0]
if !r.Purged {
t.Fatalf("expected purged=true, got reason: %s", r.Reason)
}
if r.Service != "mcns" || r.Component != "coredns" {
t.Fatalf("unexpected result: %s/%s", r.Service, r.Component)
}
// Verify component was deleted.
_, err = registry.GetComponent(a.DB, "mcns", "coredns")
if err == nil {
t.Fatal("component should have been deleted")
}
// Service should also be deleted since it has no remaining components.
_, err = registry.GetService(a.DB, "mcns")
if err == nil {
t.Fatal("service should have been deleted (no remaining components)")
}
}
func TestPurgeRefusesRunning(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcr", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "api",
Service: "mcr",
Image: "mcr:latest",
DesiredState: "running",
ObservedState: "running",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
Service: "mcr",
Component: "api",
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if resp.Results[0].Purged {
t.Fatal("should not purge a running component")
}
// Verify component still exists.
_, err = registry.GetComponent(a.DB, "mcr", "api")
if err != nil {
t.Fatalf("component should still exist: %v", err)
}
}
func TestPurgeRefusesStopped(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcr", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "api",
Service: "mcr",
Image: "mcr:latest",
DesiredState: "stopped",
ObservedState: "stopped",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
Service: "mcr",
Component: "api",
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if resp.Results[0].Purged {
t.Fatal("should not purge a stopped component")
}
}
func TestPurgeSkipsDefinedComponent(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "mcns",
Service: "mcns",
Image: "mcns:latest",
DesiredState: "running",
ObservedState: "exited",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
DefinedComponents: []string{"mcns/mcns"},
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if resp.Results[0].Purged {
t.Fatal("should not purge a component that is still in service definitions")
}
if resp.Results[0].Reason != "still in service definitions" {
t.Fatalf("unexpected reason: %s", resp.Results[0].Reason)
}
}
func TestPurgeDryRun(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "coredns",
Service: "mcns",
Image: "coredns:latest",
DesiredState: "running",
ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
DryRun: true,
DefinedComponents: []string{"mcns/mcns"},
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if !resp.Results[0].Purged {
t.Fatal("dry run should report purged=true for eligible components")
}
// Verify component was NOT deleted (dry run).
_, err = registry.GetComponent(a.DB, "mcns", "coredns")
if err != nil {
t.Fatalf("component should still exist after dry run: %v", err)
}
}
func TestPurgeServiceFilter(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
// Create two services.
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "coredns", Service: "mcns", Image: "coredns:latest",
DesiredState: "running", ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
if err := registry.CreateService(a.DB, "mcr", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "old", Service: "mcr", Image: "old:latest",
DesiredState: "running", ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
// Purge only mcns.
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{
Service: "mcns",
})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if resp.Results[0].Service != "mcns" {
t.Fatalf("expected mcns, got %s", resp.Results[0].Service)
}
// mcr/old should still exist.
_, err = registry.GetComponent(a.DB, "mcr", "old")
if err != nil {
t.Fatalf("mcr/old should still exist: %v", err)
}
}
func TestPurgeServiceDeletedWhenEmpty(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "coredns", Service: "mcns", Image: "coredns:latest",
DesiredState: "running", ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "old-thing", Service: "mcns", Image: "old:latest",
DesiredState: "stopped", ObservedState: "unknown",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
// Both components should be purged.
if len(resp.Results) != 2 {
t.Fatalf("expected 2 results, got %d", len(resp.Results))
}
for _, r := range resp.Results {
if !r.Purged {
t.Fatalf("expected purged=true for %s/%s: %s", r.Service, r.Component, r.Reason)
}
}
// Service should be deleted.
_, err = registry.GetService(a.DB, "mcns")
if err == nil {
t.Fatal("service should have been deleted")
}
}
func TestPurgeServiceKeptWhenComponentsRemain(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "mcns", true); err != nil {
t.Fatalf("create service: %v", err)
}
// Stale component (will be purged).
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "coredns", Service: "mcns", Image: "coredns:latest",
DesiredState: "running", ObservedState: "removed",
}); err != nil {
t.Fatalf("create component: %v", err)
}
// Live component (will not be purged).
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "mcns", Service: "mcns", Image: "mcns:latest",
DesiredState: "running", ObservedState: "running",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 2 {
t.Fatalf("expected 2 results, got %d", len(resp.Results))
}
// coredns should be purged, mcns should not.
purged := 0
for _, r := range resp.Results {
if r.Purged {
purged++
if r.Component != "coredns" {
t.Fatalf("expected coredns to be purged, got %s", r.Component)
}
}
}
if purged != 1 {
t.Fatalf("expected 1 purged, got %d", purged)
}
// Service should still exist.
_, err = registry.GetService(a.DB, "mcns")
if err != nil {
t.Fatalf("service should still exist: %v", err)
}
}
func TestPurgeExitedState(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "test", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "old", Service: "test", Image: "old:latest",
DesiredState: "stopped", ObservedState: "exited",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 || !resp.Results[0].Purged {
t.Fatalf("exited component should be purgeable")
}
}
func TestPurgeUnknownState(t *testing.T) {
rt := &fakeRuntime{}
a := newTestAgent(t, rt)
ctx := context.Background()
if err := registry.CreateService(a.DB, "test", true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(a.DB, &registry.Component{
Name: "ghost", Service: "test", Image: "ghost:latest",
DesiredState: "running", ObservedState: "unknown",
}); err != nil {
t.Fatalf("create component: %v", err)
}
resp, err := a.PurgeComponent(ctx, &mcpv1.PurgeRequest{})
if err != nil {
t.Fatalf("PurgeComponent: %v", err)
}
if len(resp.Results) != 1 || !resp.Results[0].Purged {
t.Fatalf("unknown component should be purgeable")
}
}

View File

@@ -3,12 +3,11 @@ package agent
import (
"context"
"fmt"
"strings"
"time"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
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/protobuf/types/known/timestamppb"
)
@@ -75,7 +74,10 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
}
var result []*mcpv1.ServiceInfo
knownServices := make(map[string]bool, len(services))
for _, svc := range services {
knownServices[svc.Name] = true
components, err := registry.ListComponents(a.DB, svc.Name)
if err != nil {
return nil, fmt.Errorf("list components for %q: %w", svc.Name, err)
@@ -87,7 +89,7 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
}
for _, comp := range components {
containerName := svc.Name + "-" + comp.Name
containerName := ContainerNameFor(svc.Name, comp.Name)
ci := &mcpv1.ComponentInfo{
Name: comp.Name,
Image: comp.Image,
@@ -116,7 +118,7 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
continue
}
svcName, compName := splitContainerName(c.Name)
svcName, compName := SplitContainerName(c.Name, knownServices)
result = append(result, &mcpv1.ServiceInfo{
Name: svcName,
@@ -210,13 +212,3 @@ func (a *Agent) GetServiceStatus(ctx context.Context, req *mcpv1.GetServiceStatu
RecentEvents: protoEvents,
}, nil
}
// splitContainerName splits a container name like "metacrypt-api" into service
// and component parts. If there is no hyphen, the whole name is used as both
// the service and component name.
func splitContainerName(name string) (service, component string) {
if i := strings.Index(name, "-"); i >= 0 {
return name[:i], name[i+1:]
}
return name, name
}

View File

@@ -4,9 +4,9 @@ import (
"context"
"testing"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
func TestListServices(t *testing.T) {
@@ -253,22 +253,47 @@ func TestGetServiceStatus_IgnoreSkipsDrift(t *testing.T) {
}
func TestSplitContainerName(t *testing.T) {
known := map[string]bool{
"metacrypt": true,
"mc-proxy": true,
"mcr": true,
}
tests := []struct {
name string
service string
comp string
}{
{"metacrypt-api", "metacrypt", "api"},
{"metacrypt-web-ui", "metacrypt", "web-ui"},
{"metacrypt-web", "metacrypt", "web"},
{"mc-proxy", "mc-proxy", "mc-proxy"},
{"mcr-api", "mcr", "api"},
{"standalone", "standalone", "standalone"},
{"unknown-thing", "unknown", "thing"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, comp := splitContainerName(tt.name)
svc, comp := SplitContainerName(tt.name, known)
if svc != tt.service || comp != tt.comp {
t.Fatalf("splitContainerName(%q) = (%q, %q), want (%q, %q)",
t.Fatalf("SplitContainerName(%q) = (%q, %q), want (%q, %q)",
tt.name, svc, comp, tt.service, tt.comp)
}
})
}
}
func TestContainerNameFor(t *testing.T) {
tests := []struct {
service, component, want string
}{
{"metacrypt", "api", "metacrypt-api"},
{"mc-proxy", "mc-proxy", "mc-proxy"},
{"mcr", "web", "mcr-web"},
}
for _, tt := range tests {
got := ContainerNameFor(tt.service, tt.component)
if got != tt.want {
t.Fatalf("ContainerNameFor(%q, %q) = %q, want %q",
tt.service, tt.component, got, tt.want)
}
}
}

View File

@@ -5,9 +5,9 @@ import (
"fmt"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
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"
)
@@ -157,6 +157,24 @@ func (a *Agent) reconcileUntracked(ctx context.Context, known map[string]bool) e
// protoToComponent converts a proto ComponentSpec to a registry Component.
func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState string) *registry.Component {
var routes []registry.Route
for _, r := range cs.GetRoutes() {
mode := r.GetMode()
if mode == "" {
mode = "l4"
}
name := r.GetName()
if name == "" {
name = "default"
}
routes = append(routes, registry.Route{
Name: name,
Port: int(r.GetPort()),
Mode: mode,
Hostname: r.GetHostname(),
})
}
return &registry.Component{
Name: cs.GetName(),
Service: service,
@@ -167,6 +185,7 @@ func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState stri
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
Routes: routes,
DesiredState: desiredState,
Version: runtime.ExtractVersion(cs.GetImage()),
}

View File

@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
// fakeRuntime implements runtime.Runtime for testing.
@@ -22,6 +22,10 @@ func (f *fakeRuntime) Pull(_ context.Context, _ string) error { re
func (f *fakeRuntime) Run(_ context.Context, _ runtime.ContainerSpec) error { return nil }
func (f *fakeRuntime) Stop(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) Remove(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) Build(_ context.Context, _, _, _ string) error { return nil }
func (f *fakeRuntime) Push(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) ImageExists(_ context.Context, _ string) (bool, error) { return true, nil }
func (f *fakeRuntime) List(_ context.Context) ([]runtime.ContainerInfo, error) {
return f.containers, f.listErr

View File

@@ -10,12 +10,67 @@ import (
// AgentConfig is the configuration for the mcp-agent daemon.
type AgentConfig struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"`
Agent AgentSettings `toml:"agent"`
Monitor MonitorConfig `toml:"monitor"`
Log LogConfig `toml:"log"`
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"`
Agent AgentSettings `toml:"agent"`
MCProxy MCProxyConfig `toml:"mcproxy"`
Metacrypt MetacryptConfig `toml:"metacrypt"`
MCNS MCNSConfig `toml:"mcns"`
Monitor MonitorConfig `toml:"monitor"`
Log LogConfig `toml:"log"`
}
// MetacryptConfig holds the Metacrypt CA integration settings for
// automated TLS cert provisioning. If ServerURL is empty, cert
// provisioning is disabled.
type MetacryptConfig struct {
// ServerURL is the Metacrypt API base URL (e.g. "https://metacrypt:8443").
ServerURL string `toml:"server_url"`
// CACert is the path to the CA certificate for verifying Metacrypt's TLS.
CACert string `toml:"ca_cert"`
// Mount is the CA engine mount name. Defaults to "pki".
Mount string `toml:"mount"`
// Issuer is the intermediate CA issuer name. Defaults to "infra".
Issuer string `toml:"issuer"`
// TokenPath is the path to the MCIAS service token file.
TokenPath string `toml:"token_path"`
}
// MCNSConfig holds the MCNS DNS integration settings for automated
// DNS record registration. If ServerURL is empty, DNS registration
// is disabled.
type MCNSConfig struct {
// ServerURL is the MCNS API base URL (e.g. "https://localhost:28443").
ServerURL string `toml:"server_url"`
// CACert is the path to the CA certificate for verifying MCNS's TLS.
CACert string `toml:"ca_cert"`
// TokenPath is the path to the MCIAS service token file.
TokenPath string `toml:"token_path"`
// Zone is the DNS zone for service records. Defaults to "svc.mcp.metacircular.net".
Zone string `toml:"zone"`
// NodeAddr is the IP address to register as the A record value.
NodeAddr string `toml:"node_addr"`
}
// MCProxyConfig holds the mc-proxy connection settings.
type MCProxyConfig struct {
// Socket is the path to the mc-proxy gRPC admin API Unix socket.
// If empty, route registration is disabled.
Socket string `toml:"socket"`
// CertDir is the directory containing TLS certificates for routes.
// Convention: <service>.pem and <service>.key per service.
// Defaults to /srv/mc-proxy/certs.
CertDir string `toml:"cert_dir"`
}
// ServerConfig holds gRPC server listen address and TLS paths.
@@ -134,6 +189,18 @@ func applyAgentDefaults(cfg *AgentConfig) {
if cfg.Agent.ContainerRuntime == "" {
cfg.Agent.ContainerRuntime = "podman"
}
if cfg.MCProxy.CertDir == "" {
cfg.MCProxy.CertDir = "/srv/mc-proxy/certs"
}
if cfg.Metacrypt.Mount == "" {
cfg.Metacrypt.Mount = "pki"
}
if cfg.Metacrypt.Issuer == "" {
cfg.Metacrypt.Issuer = "infra"
}
if cfg.MCNS.Zone == "" {
cfg.MCNS.Zone = "svc.mcp.metacircular.net"
}
}
func applyAgentEnvOverrides(cfg *AgentConfig) {
@@ -158,6 +225,27 @@ func applyAgentEnvOverrides(cfg *AgentConfig) {
if v := os.Getenv("MCP_AGENT_LOG_LEVEL"); v != "" {
cfg.Log.Level = v
}
if v := os.Getenv("MCP_AGENT_MCPROXY_SOCKET"); v != "" {
cfg.MCProxy.Socket = v
}
if v := os.Getenv("MCP_AGENT_MCPROXY_CERT_DIR"); v != "" {
cfg.MCProxy.CertDir = v
}
if v := os.Getenv("MCP_AGENT_METACRYPT_SERVER_URL"); v != "" {
cfg.Metacrypt.ServerURL = v
}
if v := os.Getenv("MCP_AGENT_METACRYPT_TOKEN_PATH"); v != "" {
cfg.Metacrypt.TokenPath = v
}
if v := os.Getenv("MCP_AGENT_MCNS_SERVER_URL"); v != "" {
cfg.MCNS.ServerURL = v
}
if v := os.Getenv("MCP_AGENT_MCNS_TOKEN_PATH"); v != "" {
cfg.MCNS.TokenPath = v
}
if v := os.Getenv("MCP_AGENT_MCNS_NODE_ADDR"); v != "" {
cfg.MCNS.NodeAddr = v
}
}
func validateAgentConfig(cfg *AgentConfig) error {

View File

@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
@@ -10,11 +11,17 @@ import (
// CLIConfig is the configuration for the mcp CLI binary.
type CLIConfig struct {
Services ServicesConfig `toml:"services"`
Build BuildConfig `toml:"build"`
MCIAS MCIASConfig `toml:"mcias"`
Auth AuthConfig `toml:"auth"`
Nodes []NodeConfig `toml:"nodes"`
}
// BuildConfig holds settings for building container images.
type BuildConfig struct {
Workspace string `toml:"workspace"`
}
// ServicesConfig defines where service definition files live.
type ServicesConfig struct {
Dir string `toml:"dir"`
@@ -66,6 +73,9 @@ func applyCLIEnvOverrides(cfg *CLIConfig) {
if v := os.Getenv("MCP_SERVICES_DIR"); v != "" {
cfg.Services.Dir = v
}
if v := os.Getenv("MCP_BUILD_WORKSPACE"); v != "" {
cfg.Build.Workspace = v
}
if v := os.Getenv("MCP_MCIAS_SERVER_URL"); v != "" {
cfg.MCIAS.ServerURL = v
}
@@ -93,5 +103,15 @@ func validateCLIConfig(cfg *CLIConfig) error {
if cfg.Auth.TokenPath == "" {
return fmt.Errorf("auth.token_path is required")
}
// Expand ~ in workspace path.
if strings.HasPrefix(cfg.Build.Workspace, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("expand workspace path: %w", err)
}
cfg.Build.Workspace = home + cfg.Build.Workspace[1:]
}
return nil
}

View File

@@ -163,6 +163,19 @@ func TestLoadAgentConfig(t *testing.T) {
if cfg.Log.Level != "debug" {
t.Fatalf("log.level: got %q", cfg.Log.Level)
}
// Metacrypt defaults when section is omitted.
if cfg.Metacrypt.Mount != "pki" {
t.Fatalf("metacrypt.mount default: got %q, want pki", cfg.Metacrypt.Mount)
}
if cfg.Metacrypt.Issuer != "infra" {
t.Fatalf("metacrypt.issuer default: got %q, want infra", cfg.Metacrypt.Issuer)
}
// MCNS defaults when section is omitted.
if cfg.MCNS.Zone != "svc.mcp.metacircular.net" {
t.Fatalf("mcns.zone default: got %q, want svc.mcp.metacircular.net", cfg.MCNS.Zone)
}
}
func TestCLIConfigValidation(t *testing.T) {
@@ -439,6 +452,155 @@ level = "info"
})
}
func TestAgentConfigMetacrypt(t *testing.T) {
cfgStr := `
[server]
grpc_addr = "0.0.0.0:9444"
tls_cert = "/srv/mcp/cert.pem"
tls_key = "/srv/mcp/key.pem"
[database]
path = "/srv/mcp/mcp.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-agent"
[agent]
node_name = "rift"
[metacrypt]
server_url = "https://metacrypt.metacircular.net:8443"
ca_cert = "/etc/mcp/metacircular-ca.pem"
mount = "custom-pki"
issuer = "custom-issuer"
token_path = "/srv/mcp/metacrypt-token"
`
path := writeTempConfig(t, cfgStr)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Metacrypt.ServerURL != "https://metacrypt.metacircular.net:8443" {
t.Fatalf("metacrypt.server_url: got %q", cfg.Metacrypt.ServerURL)
}
if cfg.Metacrypt.CACert != "/etc/mcp/metacircular-ca.pem" {
t.Fatalf("metacrypt.ca_cert: got %q", cfg.Metacrypt.CACert)
}
if cfg.Metacrypt.Mount != "custom-pki" {
t.Fatalf("metacrypt.mount: got %q", cfg.Metacrypt.Mount)
}
if cfg.Metacrypt.Issuer != "custom-issuer" {
t.Fatalf("metacrypt.issuer: got %q", cfg.Metacrypt.Issuer)
}
if cfg.Metacrypt.TokenPath != "/srv/mcp/metacrypt-token" {
t.Fatalf("metacrypt.token_path: got %q", cfg.Metacrypt.TokenPath)
}
}
func TestAgentConfigMetacryptEnvOverrides(t *testing.T) {
minimal := `
[server]
grpc_addr = "0.0.0.0:9444"
tls_cert = "/srv/mcp/cert.pem"
tls_key = "/srv/mcp/key.pem"
[database]
path = "/srv/mcp/mcp.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-agent"
[agent]
node_name = "rift"
`
t.Setenv("MCP_AGENT_METACRYPT_SERVER_URL", "https://override.metacrypt:8443")
t.Setenv("MCP_AGENT_METACRYPT_TOKEN_PATH", "/override/token")
path := writeTempConfig(t, minimal)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Metacrypt.ServerURL != "https://override.metacrypt:8443" {
t.Fatalf("metacrypt.server_url: got %q", cfg.Metacrypt.ServerURL)
}
if cfg.Metacrypt.TokenPath != "/override/token" {
t.Fatalf("metacrypt.token_path: got %q", cfg.Metacrypt.TokenPath)
}
}
func TestAgentConfigMCNS(t *testing.T) {
cfgStr := `
[server]
grpc_addr = "0.0.0.0:9444"
tls_cert = "/srv/mcp/cert.pem"
tls_key = "/srv/mcp/key.pem"
[database]
path = "/srv/mcp/mcp.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-agent"
[agent]
node_name = "rift"
[mcns]
server_url = "https://localhost:28443"
ca_cert = "/srv/mcp/certs/metacircular-ca.pem"
token_path = "/srv/mcp/metacrypt-token"
zone = "custom.zone"
node_addr = "10.0.0.1"
`
path := writeTempConfig(t, cfgStr)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.MCNS.ServerURL != "https://localhost:28443" {
t.Fatalf("mcns.server_url: got %q", cfg.MCNS.ServerURL)
}
if cfg.MCNS.CACert != "/srv/mcp/certs/metacircular-ca.pem" {
t.Fatalf("mcns.ca_cert: got %q", cfg.MCNS.CACert)
}
if cfg.MCNS.Zone != "custom.zone" {
t.Fatalf("mcns.zone: got %q", cfg.MCNS.Zone)
}
if cfg.MCNS.NodeAddr != "10.0.0.1" {
t.Fatalf("mcns.node_addr: got %q", cfg.MCNS.NodeAddr)
}
}
func TestAgentConfigMCNSEnvOverrides(t *testing.T) {
minimal := `
[server]
grpc_addr = "0.0.0.0:9444"
tls_cert = "/srv/mcp/cert.pem"
tls_key = "/srv/mcp/key.pem"
[database]
path = "/srv/mcp/mcp.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
service_name = "mcp-agent"
[agent]
node_name = "rift"
`
t.Setenv("MCP_AGENT_MCNS_SERVER_URL", "https://override:28443")
t.Setenv("MCP_AGENT_MCNS_TOKEN_PATH", "/override/token")
t.Setenv("MCP_AGENT_MCNS_NODE_ADDR", "10.0.0.99")
path := writeTempConfig(t, minimal)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.MCNS.ServerURL != "https://override:28443" {
t.Fatalf("mcns.server_url: got %q", cfg.MCNS.ServerURL)
}
if cfg.MCNS.TokenPath != "/override/token" {
t.Fatalf("mcns.token_path: got %q", cfg.MCNS.TokenPath)
}
if cfg.MCNS.NodeAddr != "10.0.0.99" {
t.Fatalf("mcns.node_addr: got %q", cfg.MCNS.NodeAddr)
}
}
func TestDurationParsing(t *testing.T) {
tests := []struct {
input string

View File

@@ -8,8 +8,8 @@ import (
"os/exec"
"time"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
// Alerter evaluates state transitions and fires alerts for drift or flapping.

View File

@@ -7,9 +7,9 @@ import (
"log/slog"
"time"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
// Monitor watches container states and compares them to the registry,

View File

@@ -9,9 +9,9 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/registry"
"git.wntrmute.dev/mc/mcp/internal/runtime"
)
func openTestDB(t *testing.T) *sql.DB {
@@ -47,6 +47,10 @@ func (f *fakeRuntime) Pull(_ context.Context, _ string) error { re
func (f *fakeRuntime) Run(_ context.Context, _ runtime.ContainerSpec) error { return nil }
func (f *fakeRuntime) Stop(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) Remove(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) Build(_ context.Context, _, _, _ string) error { return nil }
func (f *fakeRuntime) Push(_ context.Context, _ string) error { return nil }
func (f *fakeRuntime) ImageExists(_ context.Context, _ string) (bool, error) { return true, nil }
func (f *fakeRuntime) Inspect(_ context.Context, _ string) (runtime.ContainerInfo, error) {
return runtime.ContainerInfo{}, nil

View File

@@ -6,6 +6,15 @@ import (
"time"
)
// Route represents a route entry for a component in the registry.
type Route struct {
Name string
Port int
Mode string
Hostname string
HostPort int // agent-assigned host port (0 = not yet allocated)
}
// Component represents a component in the registry.
type Component struct {
Name string
@@ -20,6 +29,7 @@ type Component struct {
Ports []string
Volumes []string
Cmd []string
Routes []Route
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -51,6 +61,9 @@ func CreateComponent(db *sql.DB, c *Component) error {
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
return err
}
if err := setRoutes(tx, c.Service, c.Name, c.Routes); err != nil {
return err
}
return tx.Commit()
}
@@ -84,6 +97,10 @@ func GetComponent(db *sql.DB, service, name string) (*Component, error) {
if err != nil {
return nil, err
}
c.Routes, err = getRoutes(db, service, name)
if err != nil {
return nil, err
}
return c, nil
}
@@ -115,6 +132,7 @@ func ListComponents(db *sql.DB, service string) ([]Component, error) {
c.Ports, _ = getPorts(db, c.Service, c.Name)
c.Volumes, _ = getVolumes(db, c.Service, c.Name)
c.Cmd, _ = getCmd(db, c.Service, c.Name)
c.Routes, _ = getRoutes(db, c.Service, c.Name)
components = append(components, c)
}
@@ -168,6 +186,9 @@ func UpdateComponentSpec(db *sql.DB, c *Component) error {
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
return err
}
if err := setRoutes(tx, c.Service, c.Name, c.Routes); err != nil {
return err
}
return tx.Commit()
}
@@ -274,3 +295,85 @@ func getCmd(db *sql.DB, service, component string) ([]string, error) {
}
return cmd, rows.Err()
}
// helper: set route definitions (delete + re-insert)
func setRoutes(tx *sql.Tx, service, component string, routes []Route) error {
if _, err := tx.Exec("DELETE FROM component_routes WHERE service = ? AND component = ?", service, component); err != nil {
return fmt.Errorf("clear routes %q/%q: %w", service, component, err)
}
for _, r := range routes {
mode := r.Mode
if mode == "" {
mode = "l4"
}
name := r.Name
if name == "" {
name = "default"
}
if _, err := tx.Exec(
"INSERT INTO component_routes (service, component, name, port, mode, hostname, host_port) VALUES (?, ?, ?, ?, ?, ?, ?)",
service, component, name, r.Port, mode, r.Hostname, r.HostPort,
); err != nil {
return fmt.Errorf("insert route %q/%q %q: %w", service, component, name, err)
}
}
return nil
}
func getRoutes(db *sql.DB, service, component string) ([]Route, error) {
rows, err := db.Query(
"SELECT name, port, mode, hostname, host_port FROM component_routes WHERE service = ? AND component = ? ORDER BY name",
service, component,
)
if err != nil {
return nil, fmt.Errorf("get routes %q/%q: %w", service, component, err)
}
defer func() { _ = rows.Close() }()
var routes []Route
for rows.Next() {
var r Route
if err := rows.Scan(&r.Name, &r.Port, &r.Mode, &r.Hostname, &r.HostPort); err != nil {
return nil, err
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// UpdateRouteHostPort updates the agent-assigned host port for a specific route.
func UpdateRouteHostPort(db *sql.DB, service, component, routeName string, hostPort int) error {
res, err := db.Exec(
"UPDATE component_routes SET host_port = ? WHERE service = ? AND component = ? AND name = ?",
hostPort, service, component, routeName,
)
if err != nil {
return fmt.Errorf("update route host_port %q/%q/%q: %w", service, component, routeName, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("update route host_port %q/%q/%q: %w", service, component, routeName, sql.ErrNoRows)
}
return nil
}
// GetRouteHostPorts returns a map of route name to assigned host port for a component.
func GetRouteHostPorts(db *sql.DB, service, component string) (map[string]int, error) {
rows, err := db.Query(
"SELECT name, host_port FROM component_routes WHERE service = ? AND component = ?",
service, component,
)
if err != nil {
return nil, fmt.Errorf("get route host ports %q/%q: %w", service, component, err)
}
defer func() { _ = rows.Close() }()
result := make(map[string]int)
for rows.Next() {
var name string
var port int
if err := rows.Scan(&name, &port); err != nil {
return nil, err
}
result[name] = port
}
return result, rows.Err()
}

View File

@@ -127,4 +127,19 @@ var migrations = []string{
CREATE INDEX IF NOT EXISTS idx_events_component_time
ON events(service, component, timestamp);
`,
// Migration 2: component routes
`
CREATE TABLE IF NOT EXISTS component_routes (
service TEXT NOT NULL,
component TEXT NOT NULL,
name TEXT NOT NULL,
port INTEGER NOT NULL,
mode TEXT NOT NULL DEFAULT 'l4',
hostname TEXT NOT NULL DEFAULT '',
host_port INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (service, component, name),
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
);
`,
}

View File

@@ -83,6 +83,15 @@ func CountEvents(db *sql.DB, service, component string, since time.Time) (int, e
return count, nil
}
// DeleteComponentEvents deletes all events for a specific component.
func DeleteComponentEvents(db *sql.DB, service, component string) error {
_, err := db.Exec("DELETE FROM events WHERE service = ? AND component = ?", service, component)
if err != nil {
return fmt.Errorf("delete events %q/%q: %w", service, component, err)
}
return nil
}
// PruneEvents deletes events older than the given time.
func PruneEvents(db *sql.DB, before time.Time) (int64, error) {
res, err := db.Exec(

View File

@@ -237,6 +237,160 @@ func TestCascadeDelete(t *testing.T) {
}
}
func TestComponentRoutes(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "svc", true); err != nil {
t.Fatalf("create service: %v", err)
}
// Create component with routes
c := &Component{
Name: "api",
Service: "svc",
Image: "img:v1",
Restart: "unless-stopped",
DesiredState: "running",
ObservedState: "unknown",
Routes: []Route{
{Name: "rest", Port: 8443, Mode: "l7", Hostname: "api.example.com"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("create component: %v", err)
}
// Get and verify routes
got, err := GetComponent(db, "svc", "api")
if err != nil {
t.Fatalf("get: %v", err)
}
if len(got.Routes) != 2 {
t.Fatalf("routes: got %d, want 2", len(got.Routes))
}
// Routes are ordered by name: grpc, rest
if got.Routes[0].Name != "grpc" || got.Routes[0].Port != 9443 || got.Routes[0].Mode != "l4" {
t.Fatalf("route[0]: got %+v", got.Routes[0])
}
if got.Routes[1].Name != "rest" || got.Routes[1].Port != 8443 || got.Routes[1].Mode != "l7" || got.Routes[1].Hostname != "api.example.com" {
t.Fatalf("route[1]: got %+v", got.Routes[1])
}
// Update routes via UpdateComponentSpec
c.Routes = []Route{{Name: "http", Port: 8080, Mode: "l7"}}
if err := UpdateComponentSpec(db, c); err != nil {
t.Fatalf("update spec: %v", err)
}
got, _ = GetComponent(db, "svc", "api")
if len(got.Routes) != 1 || got.Routes[0].Name != "http" {
t.Fatalf("updated routes: got %+v", got.Routes)
}
// List components includes routes
comps, err := ListComponents(db, "svc")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(comps) != 1 || len(comps[0].Routes) != 1 {
t.Fatalf("list routes: got %d components, %d routes", len(comps), len(comps[0].Routes))
}
}
func TestRouteHostPort(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "svc", true); err != nil {
t.Fatalf("create service: %v", err)
}
c := &Component{
Name: "api",
Service: "svc",
Image: "img:v1",
Restart: "unless-stopped",
DesiredState: "running",
ObservedState: "unknown",
Routes: []Route{
{Name: "rest", Port: 8443, Mode: "l7"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("create component: %v", err)
}
// Initially host_port is 0
ports, err := GetRouteHostPorts(db, "svc", "api")
if err != nil {
t.Fatalf("get host ports: %v", err)
}
if ports["rest"] != 0 || ports["grpc"] != 0 {
t.Fatalf("initial host ports should be 0: %+v", ports)
}
// Update host ports
if err := UpdateRouteHostPort(db, "svc", "api", "rest", 12345); err != nil {
t.Fatalf("update rest: %v", err)
}
if err := UpdateRouteHostPort(db, "svc", "api", "grpc", 12346); err != nil {
t.Fatalf("update grpc: %v", err)
}
ports, _ = GetRouteHostPorts(db, "svc", "api")
if ports["rest"] != 12345 {
t.Fatalf("rest host_port: got %d, want 12345", ports["rest"])
}
if ports["grpc"] != 12346 {
t.Fatalf("grpc host_port: got %d, want 12346", ports["grpc"])
}
// Verify host_port is visible via GetComponent
got, _ := GetComponent(db, "svc", "api")
for _, r := range got.Routes {
if r.Name == "rest" && r.HostPort != 12345 {
t.Fatalf("GetComponent rest host_port: got %d", r.HostPort)
}
if r.Name == "grpc" && r.HostPort != 12346 {
t.Fatalf("GetComponent grpc host_port: got %d", r.HostPort)
}
}
// Update nonexistent route should fail
err = UpdateRouteHostPort(db, "svc", "api", "nonexistent", 99999)
if err == nil {
t.Fatal("expected error updating nonexistent route")
}
}
func TestRouteCascadeDelete(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "svc", true); err != nil {
t.Fatalf("create service: %v", err)
}
c := &Component{
Name: "api", Service: "svc", Image: "img:v1",
Restart: "unless-stopped", DesiredState: "running", ObservedState: "unknown",
Routes: []Route{{Name: "rest", Port: 8443, Mode: "l4"}},
}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("create component: %v", err)
}
// Delete service cascades to routes
if err := DeleteService(db, "svc"); err != nil {
t.Fatalf("delete service: %v", err)
}
// Routes table should be empty
ports, err := GetRouteHostPorts(db, "svc", "api")
if err != nil {
t.Fatalf("get routes after cascade: %v", err)
}
if len(ports) != 0 {
t.Fatalf("routes should be empty after cascade, got %d", len(ports))
}
}
func TestEvents(t *testing.T) {
db := openTestDB(t)

View File

@@ -3,6 +3,7 @@ package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
@@ -49,6 +50,9 @@ func (p *Podman) BuildRunArgs(spec ContainerSpec) []string {
for _, vol := range spec.Volumes {
args = append(args, "-v", vol)
}
for _, env := range spec.Env {
args = append(args, "-e", env)
}
args = append(args, spec.Image)
args = append(args, spec.Cmd...)
@@ -174,12 +178,46 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
return info, nil
}
// Build builds a container image from a Dockerfile.
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
args := []string{"build", "-t", image, "-f", dockerfile, contextDir}
cmd := exec.CommandContext(ctx, p.command(), args...) //nolint:gosec // args built programmatically
cmd.Dir = contextDir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman build %q: %w: %s", image, err, out)
}
return nil
}
// Push pushes a container image to a remote registry.
func (p *Podman) Push(ctx context.Context, image string) error {
cmd := exec.CommandContext(ctx, p.command(), "push", image) //nolint:gosec // args built programmatically
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman push %q: %w: %s", image, err, out)
}
return nil
}
// ImageExists checks whether an image tag exists in a remote registry.
func (p *Podman) ImageExists(ctx context.Context, image string) (bool, error) {
cmd := exec.CommandContext(ctx, p.command(), "manifest", "inspect", "docker://"+image) //nolint:gosec // args built programmatically
if err := cmd.Run(); err != nil {
// Exit code 1 means the manifest was not found.
var exitErr *exec.ExitError
if ok := errors.As(err, &exitErr); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("podman manifest inspect %q: %w", image, err)
}
return true, nil
}
// podmanPSEntry is a single entry from podman ps --format json.
type podmanPSEntry struct {
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Command string `json:"Command"`
Command []string `json:"Command"`
}
// List returns information about all containers.

View File

@@ -16,6 +16,7 @@ type ContainerSpec struct {
Ports []string // "host:container" port mappings
Volumes []string // "host:container" volume mounts
Cmd []string // command and arguments
Env []string // environment variables (KEY=VALUE)
}
// ContainerInfo describes the observed state of a running or stopped container.
@@ -33,7 +34,9 @@ type ContainerInfo struct {
Started time.Time // when the container started (zero if not running)
}
// Runtime is the container runtime abstraction.
// Runtime is the container runtime abstraction. The first six methods are
// used by the agent for container lifecycle. The last three are used by the
// CLI for building and pushing images.
type Runtime interface {
Pull(ctx context.Context, image string) error
Run(ctx context.Context, spec ContainerSpec) error
@@ -41,6 +44,10 @@ type Runtime interface {
Remove(ctx context.Context, name string) error
Inspect(ctx context.Context, name string) (ContainerInfo, error)
List(ctx context.Context) ([]ContainerInfo, error)
Build(ctx context.Context, image, contextDir, dockerfile string) error
Push(ctx context.Context, image string) error
ImageExists(ctx context.Context, image string) (bool, error)
}
// ExtractVersion parses the tag from an image reference.

View File

@@ -76,6 +76,38 @@ func TestBuildRunArgs(t *testing.T) {
})
})
t.Run("env vars", func(t *testing.T) {
spec := ContainerSpec{
Name: "test-app",
Image: "img:latest",
Env: []string{"PORT=12345", "PORT_GRPC=12346"},
}
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
"run", "-d", "--name", "test-app",
"-e", "PORT=12345", "-e", "PORT_GRPC=12346",
"img:latest",
})
})
t.Run("full spec with env", func(t *testing.T) {
spec := ContainerSpec{
Name: "svc-api",
Image: "img:latest",
Network: "net",
Ports: []string{"127.0.0.1:12345:8443"},
Volumes: []string{"/srv:/srv"},
Env: []string{"PORT=12345"},
}
requireEqualArgs(t, p.BuildRunArgs(spec), []string{
"run", "-d", "--name", "svc-api",
"--network", "net",
"-p", "127.0.0.1:12345:8443",
"-v", "/srv:/srv",
"-e", "PORT=12345",
"img:latest",
})
})
t.Run("cmd after image", func(t *testing.T) {
spec := ContainerSpec{
Name: "test-app",

View File

@@ -10,7 +10,7 @@ import (
toml "github.com/pelletier/go-toml/v2"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
)
// ServiceDef is the top-level TOML structure for a service definition file.
@@ -18,19 +18,38 @@ type ServiceDef struct {
Name string `toml:"name"`
Node string `toml:"node"`
Active *bool `toml:"active,omitempty"`
Path string `toml:"path,omitempty"`
Build *BuildDef `toml:"build,omitempty"`
Components []ComponentDef `toml:"components"`
}
// BuildDef describes how to build container images for a service.
type BuildDef struct {
Images map[string]string `toml:"images"`
UsesMCDSL bool `toml:"uses_mcdsl,omitempty"`
}
// RouteDef describes a route for a component, used for automatic port
// allocation and mc-proxy integration.
type RouteDef struct {
Name string `toml:"name,omitempty"`
Port int `toml:"port"`
Mode string `toml:"mode,omitempty"`
Hostname string `toml:"hostname,omitempty"`
}
// ComponentDef describes a single container component within a service.
type ComponentDef struct {
Name string `toml:"name"`
Image string `toml:"image"`
Network string `toml:"network,omitempty"`
User string `toml:"user,omitempty"`
Restart string `toml:"restart,omitempty"`
Ports []string `toml:"ports,omitempty"`
Volumes []string `toml:"volumes,omitempty"`
Cmd []string `toml:"cmd,omitempty"`
Name string `toml:"name"`
Image string `toml:"image"`
Network string `toml:"network,omitempty"`
User string `toml:"user,omitempty"`
Restart string `toml:"restart,omitempty"`
Ports []string `toml:"ports,omitempty"`
Volumes []string `toml:"volumes,omitempty"`
Cmd []string `toml:"cmd,omitempty"`
Routes []RouteDef `toml:"routes,omitempty"`
Env []string `toml:"env,omitempty"`
}
// Load reads and parses a TOML service definition file. If the active field
@@ -129,11 +148,46 @@ func validate(def *ServiceDef) error {
return fmt.Errorf("duplicate component name %q in service %q", c.Name, def.Name)
}
seen[c.Name] = true
if err := validateRoutes(c.Name, def.Name, c.Routes); err != nil {
return err
}
}
return nil
}
// validateRoutes checks that routes within a component are valid.
func validateRoutes(compName, svcName string, routes []RouteDef) error {
if len(routes) == 0 {
return nil
}
routeNames := make(map[string]bool)
for i, r := range routes {
if r.Port <= 0 {
return fmt.Errorf("route port must be > 0 in component %q of service %q", compName, svcName)
}
if r.Mode != "" && r.Mode != "l4" && r.Mode != "l7" {
return fmt.Errorf("route mode must be \"l4\" or \"l7\" in component %q of service %q", compName, svcName)
}
if len(routes) > 1 && r.Name == "" {
return fmt.Errorf("route name is required when component has multiple routes in component %q of service %q", compName, svcName)
}
// Use index-based key for unnamed single routes.
key := r.Name
if key == "" {
key = fmt.Sprintf("_route_%d", i)
}
if routeNames[key] {
return fmt.Errorf("duplicate route name %q in component %q of service %q", r.Name, compName, svcName)
}
routeNames[key] = true
}
return nil
}
// ToProto converts a ServiceDef to a proto ServiceSpec.
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
@@ -142,7 +196,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
}
for _, c := range def.Components {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
cs := &mcpv1.ComponentSpec{
Name: c.Name,
Image: c.Image,
Network: c.Network,
@@ -151,7 +205,17 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
Ports: c.Ports,
Volumes: c.Volumes,
Cmd: c.Cmd,
})
Env: c.Env,
}
for _, r := range c.Routes {
cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{
Name: r.Name,
Port: int32(r.Port), //nolint:gosec // port range validated
Mode: r.Mode,
Hostname: r.Hostname,
})
}
spec.Components = append(spec.Components, cs)
}
return spec
@@ -169,7 +233,7 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
}
for _, c := range spec.GetComponents() {
def.Components = append(def.Components, ComponentDef{
cd := ComponentDef{
Name: c.GetName(),
Image: c.GetImage(),
Network: c.GetNetwork(),
@@ -178,7 +242,17 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
Ports: c.GetPorts(),
Volumes: c.GetVolumes(),
Cmd: c.GetCmd(),
})
Env: c.GetEnv(),
}
for _, r := range c.GetRoutes() {
cd.Routes = append(cd.Routes, RouteDef{
Name: r.GetName(),
Port: int(r.GetPort()),
Mode: r.GetMode(),
Hostname: r.GetHostname(),
})
}
def.Components = append(def.Components, cd)
}
return def

View File

@@ -261,6 +261,203 @@ image = "img:latest"
}
}
func TestLoadWriteWithRoutes(t *testing.T) {
def := &ServiceDef{
Name: "myservice",
Node: "rift",
Active: boolPtr(true),
Components: []ComponentDef{
{
Name: "api",
Image: "img:latest",
Network: "docker_default",
Routes: []RouteDef{
{Name: "rest", Port: 8443, Mode: "l7", Hostname: "api.example.com"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
Env: []string{"FOO=bar"},
},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "myservice.toml")
if err := Write(path, def); err != nil {
t.Fatalf("write: %v", err)
}
got, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got.Components[0].Routes) != 2 {
t.Fatalf("routes: got %d, want 2", len(got.Components[0].Routes))
}
r := got.Components[0].Routes[0]
if r.Name != "rest" || r.Port != 8443 || r.Mode != "l7" || r.Hostname != "api.example.com" {
t.Fatalf("route[0] mismatch: %+v", r)
}
r2 := got.Components[0].Routes[1]
if r2.Name != "grpc" || r2.Port != 9443 || r2.Mode != "l4" {
t.Fatalf("route[1] mismatch: %+v", r2)
}
if len(got.Components[0].Env) != 1 || got.Components[0].Env[0] != "FOO=bar" {
t.Fatalf("env mismatch: %v", got.Components[0].Env)
}
}
func TestRouteValidation(t *testing.T) {
tests := []struct {
name string
def *ServiceDef
wantErr string
}{
{
name: "route missing port",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Name: "rest", Port: 0}},
}},
},
wantErr: "route port must be > 0",
},
{
name: "route invalid mode",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443, Mode: "tcp"}},
}},
},
wantErr: "route mode must be",
},
{
name: "multi-route missing name",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443},
{Port: 9443},
},
}},
},
wantErr: "route name is required when component has multiple routes",
},
{
name: "duplicate route name",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443},
{Name: "rest", Port: 9443},
},
}},
},
wantErr: "duplicate route name",
},
{
name: "single unnamed route is valid",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443}},
}},
},
wantErr: "",
},
{
name: "valid l4 mode",
def: &ServiceDef{
Name: "svc", Node: "rift",
Components: []ComponentDef{{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{{Port: 8443, Mode: "l4"}},
}},
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate(tt.def)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected validation error")
}
if got := err.Error(); !strings.Contains(got, tt.wantErr) {
t.Fatalf("error %q does not contain %q", got, tt.wantErr)
}
})
}
}
func TestProtoConversionWithRoutes(t *testing.T) {
def := &ServiceDef{
Name: "svc",
Node: "rift",
Active: boolPtr(true),
Components: []ComponentDef{
{
Name: "api",
Image: "img:v1",
Routes: []RouteDef{
{Name: "rest", Port: 8443, Mode: "l7", Hostname: "api.example.com"},
{Name: "grpc", Port: 9443, Mode: "l4"},
},
Env: []string{"PORT_REST=12345", "PORT_GRPC=12346"},
},
},
}
spec := ToProto(def)
if len(spec.Components[0].Routes) != 2 {
t.Fatalf("proto routes: got %d, want 2", len(spec.Components[0].Routes))
}
r := spec.Components[0].Routes[0]
if r.GetName() != "rest" || r.GetPort() != 8443 || r.GetMode() != "l7" || r.GetHostname() != "api.example.com" {
t.Fatalf("proto route[0] mismatch: %+v", r)
}
if len(spec.Components[0].Env) != 2 {
t.Fatalf("proto env: got %d, want 2", len(spec.Components[0].Env))
}
got := FromProto(spec, "rift")
if len(got.Components[0].Routes) != 2 {
t.Fatalf("round-trip routes: got %d, want 2", len(got.Components[0].Routes))
}
gotR := got.Components[0].Routes[0]
if gotR.Name != "rest" || gotR.Port != 8443 || gotR.Mode != "l7" || gotR.Hostname != "api.example.com" {
t.Fatalf("round-trip route[0] mismatch: %+v", gotR)
}
if len(got.Components[0].Env) != 2 {
t.Fatalf("round-trip env: got %d, want 2", len(got.Components[0].Env))
}
}
func TestProtoConversion(t *testing.T) {
def := sampleDef()

View File

@@ -1,7 +1,7 @@
syntax = "proto3";
package mcp.v1;
option go_package = "git.wntrmute.dev/kyle/mcp/gen/mcp/v1;mcpv1";
option go_package = "git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1";
import "google/protobuf/timestamp.proto";
@@ -23,6 +23,9 @@ service McpAgentService {
// Adopt
rpc AdoptContainers(AdoptContainersRequest) returns (AdoptContainersResponse);
// Purge
rpc PurgeComponent(PurgeRequest) returns (PurgeResponse);
// File transfer
rpc PushFile(PushFileRequest) returns (PushFileResponse);
rpc PullFile(PullFileRequest) returns (PullFileResponse);
@@ -33,6 +36,13 @@ service McpAgentService {
// --- Service lifecycle ---
message RouteSpec {
string name = 1; // route name (used for $PORT_<NAME>)
int32 port = 2; // external port on mc-proxy
string mode = 3; // "l4" or "l7"
string hostname = 4; // optional public hostname override
}
message ComponentSpec {
string name = 1;
string image = 2;
@@ -42,6 +52,8 @@ message ComponentSpec {
repeated string ports = 6;
repeated string volumes = 7;
repeated string cmd = 8;
repeated RouteSpec routes = 9;
repeated string env = 10;
}
message ServiceSpec {
@@ -234,3 +246,30 @@ message NodeStatusResponse {
double cpu_usage_percent = 10;
google.protobuf.Timestamp uptime_since = 11;
}
// --- Purge ---
message PurgeRequest {
// Service name (empty = all services).
string service = 1;
// Component name (empty = all eligible in service).
string component = 2;
// Preview only, do not modify registry.
bool dry_run = 3;
// Currently-defined service/component pairs (e.g., "mcns/mcns").
// The agent uses this to determine what is "not in any service definition".
repeated string defined_components = 4;
}
message PurgeResponse {
repeated PurgeResult results = 1;
}
message PurgeResult {
string service = 1;
string component = 2;
// true if removed (or would be, in dry-run).
bool purged = 3;
// Why eligible, or why refused.
string reason = 4;
}

View File

@@ -0,0 +1,331 @@
// Package mcproxy provides a client for the mc-proxy gRPC admin API.
package mcproxy
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
pb "git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1"
)
// Client provides access to the mc-proxy admin API.
type Client struct {
conn *grpc.ClientConn
admin pb.ProxyAdminServiceClient
health healthpb.HealthClient
}
// Dial connects to the mc-proxy admin API via Unix socket.
func Dial(socketPath string) (*Client, error) {
conn, err := grpc.NewClient("unix://"+socketPath,
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connecting to %s: %w", socketPath, err)
}
return &Client{
conn: conn,
admin: pb.NewProxyAdminServiceClient(conn),
health: healthpb.NewHealthClient(conn),
}, nil
}
// Close closes the connection to the server.
func (c *Client) Close() error {
return c.conn.Close()
}
// L7Policy represents an HTTP-level blocking policy.
type L7Policy struct {
Type string // "block_user_agent" or "require_header"
Value string
}
// Route represents a hostname to backend mapping with mode and options.
type Route struct {
Hostname string
Backend string
Mode string // "l4" or "l7"
TLSCert string
TLSKey string
BackendTLS bool
SendProxyProtocol bool
L7Policies []L7Policy
}
// ListRoutes returns all routes for the given listener address.
func (c *Client) ListRoutes(ctx context.Context, listenerAddr string) ([]Route, error) {
resp, err := c.admin.ListRoutes(ctx, &pb.ListRoutesRequest{
ListenerAddr: listenerAddr,
})
if err != nil {
return nil, err
}
routes := make([]Route, len(resp.Routes))
for i, r := range resp.Routes {
routes[i] = Route{
Hostname: r.Hostname,
Backend: r.Backend,
Mode: r.Mode,
TLSCert: r.TlsCert,
TLSKey: r.TlsKey,
BackendTLS: r.BackendTls,
SendProxyProtocol: r.SendProxyProtocol,
}
}
return routes, nil
}
// AddRoute adds a route to the given listener.
func (c *Client) AddRoute(ctx context.Context, listenerAddr string, route Route) error {
_, err := c.admin.AddRoute(ctx, &pb.AddRouteRequest{
ListenerAddr: listenerAddr,
Route: &pb.Route{
Hostname: route.Hostname,
Backend: route.Backend,
Mode: route.Mode,
TlsCert: route.TLSCert,
TlsKey: route.TLSKey,
BackendTls: route.BackendTLS,
SendProxyProtocol: route.SendProxyProtocol,
},
})
return err
}
// RemoveRoute removes a route from the given listener.
func (c *Client) RemoveRoute(ctx context.Context, listenerAddr, hostname string) error {
_, err := c.admin.RemoveRoute(ctx, &pb.RemoveRouteRequest{
ListenerAddr: listenerAddr,
Hostname: hostname,
})
return err
}
// FirewallRuleType represents the type of firewall rule.
type FirewallRuleType string
const (
FirewallRuleIP FirewallRuleType = "ip"
FirewallRuleCIDR FirewallRuleType = "cidr"
FirewallRuleCountry FirewallRuleType = "country"
)
// FirewallRule represents a firewall block rule.
type FirewallRule struct {
Type FirewallRuleType
Value string
}
// GetFirewallRules returns all firewall rules.
func (c *Client) GetFirewallRules(ctx context.Context) ([]FirewallRule, error) {
resp, err := c.admin.GetFirewallRules(ctx, &pb.GetFirewallRulesRequest{})
if err != nil {
return nil, err
}
rules := make([]FirewallRule, len(resp.Rules))
for i, r := range resp.Rules {
rules[i] = FirewallRule{
Type: protoToRuleType(r.Type),
Value: r.Value,
}
}
return rules, nil
}
// AddFirewallRule adds a firewall rule.
func (c *Client) AddFirewallRule(ctx context.Context, ruleType FirewallRuleType, value string) error {
_, err := c.admin.AddFirewallRule(ctx, &pb.AddFirewallRuleRequest{
Rule: &pb.FirewallRule{
Type: ruleTypeToProto(ruleType),
Value: value,
},
})
return err
}
// RemoveFirewallRule removes a firewall rule.
func (c *Client) RemoveFirewallRule(ctx context.Context, ruleType FirewallRuleType, value string) error {
_, err := c.admin.RemoveFirewallRule(ctx, &pb.RemoveFirewallRuleRequest{
Rule: &pb.FirewallRule{
Type: ruleTypeToProto(ruleType),
Value: value,
},
})
return err
}
// RouteStatus contains status information for a single route.
type RouteStatus struct {
Hostname string
Backend string
Mode string // "l4" or "l7"
BackendTLS bool
SendProxyProtocol bool
}
// ListenerStatus contains status information for a single listener.
type ListenerStatus struct {
Addr string
RouteCount int
ActiveConnections int64
ProxyProtocol bool
MaxConnections int64
Routes []RouteStatus
}
// Status contains the server's current status.
type Status struct {
Version string
StartedAt time.Time
TotalConnections int64
Listeners []ListenerStatus
}
// GetStatus returns the server's current status.
func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
resp, err := c.admin.GetStatus(ctx, &pb.GetStatusRequest{})
if err != nil {
return nil, err
}
status := &Status{
Version: resp.Version,
TotalConnections: resp.TotalConnections,
}
if resp.StartedAt != nil {
status.StartedAt = resp.StartedAt.AsTime()
}
status.Listeners = make([]ListenerStatus, len(resp.Listeners))
for i, ls := range resp.Listeners {
routes := make([]RouteStatus, len(ls.Routes))
for j, r := range ls.Routes {
routes[j] = RouteStatus{
Hostname: r.Hostname,
Backend: r.Backend,
Mode: r.Mode,
BackendTLS: r.BackendTls,
SendProxyProtocol: r.SendProxyProtocol,
}
}
status.Listeners[i] = ListenerStatus{
Addr: ls.Addr,
RouteCount: int(ls.RouteCount),
ActiveConnections: ls.ActiveConnections,
ProxyProtocol: ls.ProxyProtocol,
MaxConnections: ls.MaxConnections,
Routes: routes,
}
}
return status, nil
}
// SetListenerMaxConnections updates the per-listener connection limit.
// 0 means unlimited.
func (c *Client) SetListenerMaxConnections(ctx context.Context, listenerAddr string, maxConns int64) error {
_, err := c.admin.SetListenerMaxConnections(ctx, &pb.SetListenerMaxConnectionsRequest{
ListenerAddr: listenerAddr,
MaxConnections: maxConns,
})
return err
}
// ListL7Policies returns L7 policies for a route.
func (c *Client) ListL7Policies(ctx context.Context, listenerAddr, hostname string) ([]L7Policy, error) {
resp, err := c.admin.ListL7Policies(ctx, &pb.ListL7PoliciesRequest{
ListenerAddr: listenerAddr,
Hostname: hostname,
})
if err != nil {
return nil, err
}
policies := make([]L7Policy, len(resp.Policies))
for i, p := range resp.Policies {
policies[i] = L7Policy{Type: p.Type, Value: p.Value}
}
return policies, nil
}
// AddL7Policy adds an L7 policy to a route.
func (c *Client) AddL7Policy(ctx context.Context, listenerAddr, hostname string, policy L7Policy) error {
_, err := c.admin.AddL7Policy(ctx, &pb.AddL7PolicyRequest{
ListenerAddr: listenerAddr,
Hostname: hostname,
Policy: &pb.L7Policy{Type: policy.Type, Value: policy.Value},
})
return err
}
// RemoveL7Policy removes an L7 policy from a route.
func (c *Client) RemoveL7Policy(ctx context.Context, listenerAddr, hostname string, policy L7Policy) error {
_, err := c.admin.RemoveL7Policy(ctx, &pb.RemoveL7PolicyRequest{
ListenerAddr: listenerAddr,
Hostname: hostname,
Policy: &pb.L7Policy{Type: policy.Type, Value: policy.Value},
})
return err
}
// HealthStatus represents the health of the server.
type HealthStatus int
const (
HealthUnknown HealthStatus = 0
HealthServing HealthStatus = 1
HealthNotServing HealthStatus = 2
)
func (h HealthStatus) String() string {
switch h {
case HealthServing:
return "SERVING"
case HealthNotServing:
return "NOT_SERVING"
default:
return "UNKNOWN"
}
}
// CheckHealth checks the health of the server.
func (c *Client) CheckHealth(ctx context.Context) (HealthStatus, error) {
resp, err := c.health.Check(ctx, &healthpb.HealthCheckRequest{})
if err != nil {
return HealthUnknown, err
}
return HealthStatus(resp.Status), nil
}
func protoToRuleType(t pb.FirewallRuleType) FirewallRuleType {
switch t {
case pb.FirewallRuleType_FIREWALL_RULE_TYPE_IP:
return FirewallRuleIP
case pb.FirewallRuleType_FIREWALL_RULE_TYPE_CIDR:
return FirewallRuleCIDR
case pb.FirewallRuleType_FIREWALL_RULE_TYPE_COUNTRY:
return FirewallRuleCountry
default:
return ""
}
}
func ruleTypeToProto(t FirewallRuleType) pb.FirewallRuleType {
switch t {
case FirewallRuleIP:
return pb.FirewallRuleType_FIREWALL_RULE_TYPE_IP
case FirewallRuleCIDR:
return pb.FirewallRuleType_FIREWALL_RULE_TYPE_CIDR
case FirewallRuleCountry:
return pb.FirewallRuleType_FIREWALL_RULE_TYPE_COUNTRY
default:
return pb.FirewallRuleType_FIREWALL_RULE_TYPE_UNSPECIFIED
}
}

View File

@@ -0,0 +1,41 @@
// Package mcproxy provides a Go client for the mc-proxy gRPC admin API.
//
// The client connects to mc-proxy via Unix socket and provides methods
// for managing routes, firewall rules, and querying server status.
//
// # Basic Usage
//
// client, err := mcproxy.Dial("/srv/mc-proxy/mc-proxy.sock")
// if err != nil {
// log.Fatal(err)
// }
// defer client.Close()
//
// // Get server status
// status, err := client.GetStatus(ctx)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("mc-proxy %s, %d connections\n", status.Version, status.TotalConnections)
//
// // List routes for a listener
// routes, err := client.ListRoutes(ctx, ":443")
// if err != nil {
// log.Fatal(err)
// }
// for _, r := range routes {
// fmt.Printf(" %s -> %s\n", r.Hostname, r.Backend)
// }
//
// // Add a route
// err = client.AddRoute(ctx, ":443", "example.com", "127.0.0.1:8443")
//
// // Add a firewall rule
// err = client.AddFirewallRule(ctx, mcproxy.FirewallRuleCIDR, "10.0.0.0/8")
//
// // Check health
// health, err := client.CheckHealth(ctx)
// if health == mcproxy.HealthServing {
// fmt.Println("Server is healthy")
// }
package mcproxy

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,511 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mc_proxy/v1/admin.proto
package mcproxyv1
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 (
ProxyAdminService_ListRoutes_FullMethodName = "/mc_proxy.v1.ProxyAdminService/ListRoutes"
ProxyAdminService_AddRoute_FullMethodName = "/mc_proxy.v1.ProxyAdminService/AddRoute"
ProxyAdminService_RemoveRoute_FullMethodName = "/mc_proxy.v1.ProxyAdminService/RemoveRoute"
ProxyAdminService_GetFirewallRules_FullMethodName = "/mc_proxy.v1.ProxyAdminService/GetFirewallRules"
ProxyAdminService_AddFirewallRule_FullMethodName = "/mc_proxy.v1.ProxyAdminService/AddFirewallRule"
ProxyAdminService_RemoveFirewallRule_FullMethodName = "/mc_proxy.v1.ProxyAdminService/RemoveFirewallRule"
ProxyAdminService_SetListenerMaxConnections_FullMethodName = "/mc_proxy.v1.ProxyAdminService/SetListenerMaxConnections"
ProxyAdminService_ListL7Policies_FullMethodName = "/mc_proxy.v1.ProxyAdminService/ListL7Policies"
ProxyAdminService_AddL7Policy_FullMethodName = "/mc_proxy.v1.ProxyAdminService/AddL7Policy"
ProxyAdminService_RemoveL7Policy_FullMethodName = "/mc_proxy.v1.ProxyAdminService/RemoveL7Policy"
ProxyAdminService_GetStatus_FullMethodName = "/mc_proxy.v1.ProxyAdminService/GetStatus"
)
// ProxyAdminServiceClient is the client API for ProxyAdminService 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.
type ProxyAdminServiceClient interface {
// Routes
ListRoutes(ctx context.Context, in *ListRoutesRequest, opts ...grpc.CallOption) (*ListRoutesResponse, error)
AddRoute(ctx context.Context, in *AddRouteRequest, opts ...grpc.CallOption) (*AddRouteResponse, error)
RemoveRoute(ctx context.Context, in *RemoveRouteRequest, opts ...grpc.CallOption) (*RemoveRouteResponse, error)
// Firewall
GetFirewallRules(ctx context.Context, in *GetFirewallRulesRequest, opts ...grpc.CallOption) (*GetFirewallRulesResponse, error)
AddFirewallRule(ctx context.Context, in *AddFirewallRuleRequest, opts ...grpc.CallOption) (*AddFirewallRuleResponse, error)
RemoveFirewallRule(ctx context.Context, in *RemoveFirewallRuleRequest, opts ...grpc.CallOption) (*RemoveFirewallRuleResponse, error)
// Connection limits
SetListenerMaxConnections(ctx context.Context, in *SetListenerMaxConnectionsRequest, opts ...grpc.CallOption) (*SetListenerMaxConnectionsResponse, error)
// L7 policies
ListL7Policies(ctx context.Context, in *ListL7PoliciesRequest, opts ...grpc.CallOption) (*ListL7PoliciesResponse, error)
AddL7Policy(ctx context.Context, in *AddL7PolicyRequest, opts ...grpc.CallOption) (*AddL7PolicyResponse, error)
RemoveL7Policy(ctx context.Context, in *RemoveL7PolicyRequest, opts ...grpc.CallOption) (*RemoveL7PolicyResponse, error)
// Status
GetStatus(ctx context.Context, in *GetStatusRequest, opts ...grpc.CallOption) (*GetStatusResponse, error)
}
type proxyAdminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewProxyAdminServiceClient(cc grpc.ClientConnInterface) ProxyAdminServiceClient {
return &proxyAdminServiceClient{cc}
}
func (c *proxyAdminServiceClient) ListRoutes(ctx context.Context, in *ListRoutesRequest, opts ...grpc.CallOption) (*ListRoutesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoutesResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_ListRoutes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) AddRoute(ctx context.Context, in *AddRouteRequest, opts ...grpc.CallOption) (*AddRouteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddRouteResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_AddRoute_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) RemoveRoute(ctx context.Context, in *RemoveRouteRequest, opts ...grpc.CallOption) (*RemoveRouteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveRouteResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_RemoveRoute_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) GetFirewallRules(ctx context.Context, in *GetFirewallRulesRequest, opts ...grpc.CallOption) (*GetFirewallRulesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetFirewallRulesResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_GetFirewallRules_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) AddFirewallRule(ctx context.Context, in *AddFirewallRuleRequest, opts ...grpc.CallOption) (*AddFirewallRuleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddFirewallRuleResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_AddFirewallRule_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) RemoveFirewallRule(ctx context.Context, in *RemoveFirewallRuleRequest, opts ...grpc.CallOption) (*RemoveFirewallRuleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveFirewallRuleResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_RemoveFirewallRule_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) SetListenerMaxConnections(ctx context.Context, in *SetListenerMaxConnectionsRequest, opts ...grpc.CallOption) (*SetListenerMaxConnectionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetListenerMaxConnectionsResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_SetListenerMaxConnections_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) ListL7Policies(ctx context.Context, in *ListL7PoliciesRequest, opts ...grpc.CallOption) (*ListL7PoliciesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListL7PoliciesResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_ListL7Policies_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) AddL7Policy(ctx context.Context, in *AddL7PolicyRequest, opts ...grpc.CallOption) (*AddL7PolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddL7PolicyResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_AddL7Policy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) RemoveL7Policy(ctx context.Context, in *RemoveL7PolicyRequest, opts ...grpc.CallOption) (*RemoveL7PolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveL7PolicyResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_RemoveL7Policy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *proxyAdminServiceClient) GetStatus(ctx context.Context, in *GetStatusRequest, opts ...grpc.CallOption) (*GetStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetStatusResponse)
err := c.cc.Invoke(ctx, ProxyAdminService_GetStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ProxyAdminServiceServer is the server API for ProxyAdminService service.
// All implementations must embed UnimplementedProxyAdminServiceServer
// for forward compatibility.
type ProxyAdminServiceServer interface {
// Routes
ListRoutes(context.Context, *ListRoutesRequest) (*ListRoutesResponse, error)
AddRoute(context.Context, *AddRouteRequest) (*AddRouteResponse, error)
RemoveRoute(context.Context, *RemoveRouteRequest) (*RemoveRouteResponse, error)
// Firewall
GetFirewallRules(context.Context, *GetFirewallRulesRequest) (*GetFirewallRulesResponse, error)
AddFirewallRule(context.Context, *AddFirewallRuleRequest) (*AddFirewallRuleResponse, error)
RemoveFirewallRule(context.Context, *RemoveFirewallRuleRequest) (*RemoveFirewallRuleResponse, error)
// Connection limits
SetListenerMaxConnections(context.Context, *SetListenerMaxConnectionsRequest) (*SetListenerMaxConnectionsResponse, error)
// L7 policies
ListL7Policies(context.Context, *ListL7PoliciesRequest) (*ListL7PoliciesResponse, error)
AddL7Policy(context.Context, *AddL7PolicyRequest) (*AddL7PolicyResponse, error)
RemoveL7Policy(context.Context, *RemoveL7PolicyRequest) (*RemoveL7PolicyResponse, error)
// Status
GetStatus(context.Context, *GetStatusRequest) (*GetStatusResponse, error)
mustEmbedUnimplementedProxyAdminServiceServer()
}
// UnimplementedProxyAdminServiceServer 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 UnimplementedProxyAdminServiceServer struct{}
func (UnimplementedProxyAdminServiceServer) ListRoutes(context.Context, *ListRoutesRequest) (*ListRoutesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRoutes not implemented")
}
func (UnimplementedProxyAdminServiceServer) AddRoute(context.Context, *AddRouteRequest) (*AddRouteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddRoute not implemented")
}
func (UnimplementedProxyAdminServiceServer) RemoveRoute(context.Context, *RemoveRouteRequest) (*RemoveRouteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveRoute not implemented")
}
func (UnimplementedProxyAdminServiceServer) GetFirewallRules(context.Context, *GetFirewallRulesRequest) (*GetFirewallRulesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetFirewallRules not implemented")
}
func (UnimplementedProxyAdminServiceServer) AddFirewallRule(context.Context, *AddFirewallRuleRequest) (*AddFirewallRuleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddFirewallRule not implemented")
}
func (UnimplementedProxyAdminServiceServer) RemoveFirewallRule(context.Context, *RemoveFirewallRuleRequest) (*RemoveFirewallRuleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveFirewallRule not implemented")
}
func (UnimplementedProxyAdminServiceServer) SetListenerMaxConnections(context.Context, *SetListenerMaxConnectionsRequest) (*SetListenerMaxConnectionsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetListenerMaxConnections not implemented")
}
func (UnimplementedProxyAdminServiceServer) ListL7Policies(context.Context, *ListL7PoliciesRequest) (*ListL7PoliciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListL7Policies not implemented")
}
func (UnimplementedProxyAdminServiceServer) AddL7Policy(context.Context, *AddL7PolicyRequest) (*AddL7PolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddL7Policy not implemented")
}
func (UnimplementedProxyAdminServiceServer) RemoveL7Policy(context.Context, *RemoveL7PolicyRequest) (*RemoveL7PolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveL7Policy not implemented")
}
func (UnimplementedProxyAdminServiceServer) GetStatus(context.Context, *GetStatusRequest) (*GetStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetStatus not implemented")
}
func (UnimplementedProxyAdminServiceServer) mustEmbedUnimplementedProxyAdminServiceServer() {}
func (UnimplementedProxyAdminServiceServer) testEmbeddedByValue() {}
// UnsafeProxyAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ProxyAdminServiceServer will
// result in compilation errors.
type UnsafeProxyAdminServiceServer interface {
mustEmbedUnimplementedProxyAdminServiceServer()
}
func RegisterProxyAdminServiceServer(s grpc.ServiceRegistrar, srv ProxyAdminServiceServer) {
// If the following call panics, it indicates UnimplementedProxyAdminServiceServer 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(&ProxyAdminService_ServiceDesc, srv)
}
func _ProxyAdminService_ListRoutes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoutesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).ListRoutes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_ListRoutes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).ListRoutes(ctx, req.(*ListRoutesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_AddRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddRouteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).AddRoute(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_AddRoute_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).AddRoute(ctx, req.(*AddRouteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_RemoveRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveRouteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).RemoveRoute(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_RemoveRoute_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).RemoveRoute(ctx, req.(*RemoveRouteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_GetFirewallRules_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetFirewallRulesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).GetFirewallRules(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_GetFirewallRules_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).GetFirewallRules(ctx, req.(*GetFirewallRulesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_AddFirewallRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddFirewallRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).AddFirewallRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_AddFirewallRule_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).AddFirewallRule(ctx, req.(*AddFirewallRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_RemoveFirewallRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveFirewallRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).RemoveFirewallRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_RemoveFirewallRule_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).RemoveFirewallRule(ctx, req.(*RemoveFirewallRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_SetListenerMaxConnections_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetListenerMaxConnectionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).SetListenerMaxConnections(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_SetListenerMaxConnections_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).SetListenerMaxConnections(ctx, req.(*SetListenerMaxConnectionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_ListL7Policies_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListL7PoliciesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).ListL7Policies(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_ListL7Policies_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).ListL7Policies(ctx, req.(*ListL7PoliciesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_AddL7Policy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddL7PolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).AddL7Policy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_AddL7Policy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).AddL7Policy(ctx, req.(*AddL7PolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_RemoveL7Policy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveL7PolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).RemoveL7Policy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_RemoveL7Policy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).RemoveL7Policy(ctx, req.(*RemoveL7PolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ProxyAdminService_GetStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ProxyAdminServiceServer).GetStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ProxyAdminService_GetStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ProxyAdminServiceServer).GetStatus(ctx, req.(*GetStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// ProxyAdminService_ServiceDesc is the grpc.ServiceDesc for ProxyAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ProxyAdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mc_proxy.v1.ProxyAdminService",
HandlerType: (*ProxyAdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListRoutes",
Handler: _ProxyAdminService_ListRoutes_Handler,
},
{
MethodName: "AddRoute",
Handler: _ProxyAdminService_AddRoute_Handler,
},
{
MethodName: "RemoveRoute",
Handler: _ProxyAdminService_RemoveRoute_Handler,
},
{
MethodName: "GetFirewallRules",
Handler: _ProxyAdminService_GetFirewallRules_Handler,
},
{
MethodName: "AddFirewallRule",
Handler: _ProxyAdminService_AddFirewallRule_Handler,
},
{
MethodName: "RemoveFirewallRule",
Handler: _ProxyAdminService_RemoveFirewallRule_Handler,
},
{
MethodName: "SetListenerMaxConnections",
Handler: _ProxyAdminService_SetListenerMaxConnections_Handler,
},
{
MethodName: "ListL7Policies",
Handler: _ProxyAdminService_ListL7Policies_Handler,
},
{
MethodName: "AddL7Policy",
Handler: _ProxyAdminService_AddL7Policy_Handler,
},
{
MethodName: "RemoveL7Policy",
Handler: _ProxyAdminService_RemoveL7Policy_Handler,
},
{
MethodName: "GetStatus",
Handler: _ProxyAdminService_GetStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mc_proxy/v1/admin.proto",
}

21
vendor/github.com/dustin/go-humanize/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,21 @@
sudo: false
language: go
go_import_path: github.com/dustin/go-humanize
go:
- 1.13.x
- 1.14.x
- 1.15.x
- 1.16.x
- stable
- master
matrix:
allow_failures:
- go: master
fast_finish: true
install:
- # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step).
script:
- diff -u <(echo -n) <(gofmt -d -s .)
- go vet .
- go install -v -race ./...
- go test -v -race ./...

21
vendor/github.com/dustin/go-humanize/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>

124
vendor/github.com/dustin/go-humanize/README.markdown generated vendored Normal file
View File

@@ -0,0 +1,124 @@
# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize)
Just a few functions for helping humanize times and sizes.
`go get` it as `github.com/dustin/go-humanize`, import it as
`"github.com/dustin/go-humanize"`, use it as `humanize`.
See [godoc](https://pkg.go.dev/github.com/dustin/go-humanize) for
complete documentation.
## Sizes
This lets you take numbers like `82854982` and convert them to useful
strings like, `83 MB` or `79 MiB` (whichever you prefer).
Example:
```go
fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB.
```
## Times
This lets you take a `time.Time` and spit it out in relative terms.
For example, `12 seconds ago` or `3 days from now`.
Example:
```go
fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago.
```
Thanks to Kyle Lemons for the time implementation from an IRC
conversation one day. It's pretty neat.
## Ordinals
From a [mailing list discussion][odisc] where a user wanted to be able
to label ordinals.
0 -> 0th
1 -> 1st
2 -> 2nd
3 -> 3rd
4 -> 4th
[...]
Example:
```go
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend.
```
## Commas
Want to shove commas into numbers? Be my guest.
0 -> 0
100 -> 100
1000 -> 1,000
1000000000 -> 1,000,000,000
-100000 -> -100,000
Example:
```go
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491.
```
## Ftoa
Nicer float64 formatter that removes trailing zeros.
```go
fmt.Printf("%f", 2.24) // 2.240000
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
fmt.Printf("%f", 2.0) // 2.000000
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
```
## SI notation
Format numbers with [SI notation][sinotation].
Example:
```go
humanize.SI(0.00000000223, "M") // 2.23 nM
```
## English-specific functions
The following functions are in the `humanize/english` subpackage.
### Plurals
Simple English pluralization
```go
english.PluralWord(1, "object", "") // object
english.PluralWord(42, "object", "") // objects
english.PluralWord(2, "bus", "") // buses
english.PluralWord(99, "locus", "loci") // loci
english.Plural(1, "object", "") // 1 object
english.Plural(42, "object", "") // 42 objects
english.Plural(2, "bus", "") // 2 buses
english.Plural(99, "locus", "loci") // 99 loci
```
### Word series
Format comma-separated words lists with conjuctions:
```go
english.WordSeries([]string{"foo"}, "and") // foo
english.WordSeries([]string{"foo", "bar"}, "and") // foo and bar
english.WordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar and baz
english.OxfordWordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar, and baz
```
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix

31
vendor/github.com/dustin/go-humanize/big.go generated vendored Normal file
View File

@@ -0,0 +1,31 @@
package humanize
import (
"math/big"
)
// order of magnitude (to a max order)
func oomm(n, b *big.Int, maxmag int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
if mag == maxmag && maxmag >= 0 {
break
}
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
// total order of magnitude
// (same as above, but with no upper limit)
func oom(n, b *big.Int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}

189
vendor/github.com/dustin/go-humanize/bigbytes.go generated vendored Normal file
View File

@@ -0,0 +1,189 @@
package humanize
import (
"fmt"
"math/big"
"strings"
"unicode"
)
var (
bigIECExp = big.NewInt(1024)
// BigByte is one byte in bit.Ints
BigByte = big.NewInt(1)
// BigKiByte is 1,024 bytes in bit.Ints
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
// BigMiByte is 1,024 k bytes in bit.Ints
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
// BigGiByte is 1,024 m bytes in bit.Ints
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
// BigTiByte is 1,024 g bytes in bit.Ints
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
// BigPiByte is 1,024 t bytes in bit.Ints
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
// BigEiByte is 1,024 p bytes in bit.Ints
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
// BigZiByte is 1,024 e bytes in bit.Ints
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
// BigYiByte is 1,024 z bytes in bit.Ints
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
// BigRiByte is 1,024 y bytes in bit.Ints
BigRiByte = (&big.Int{}).Mul(BigYiByte, bigIECExp)
// BigQiByte is 1,024 r bytes in bit.Ints
BigQiByte = (&big.Int{}).Mul(BigRiByte, bigIECExp)
)
var (
bigSIExp = big.NewInt(1000)
// BigSIByte is one SI byte in big.Ints
BigSIByte = big.NewInt(1)
// BigKByte is 1,000 SI bytes in big.Ints
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
// BigMByte is 1,000 SI k bytes in big.Ints
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
// BigGByte is 1,000 SI m bytes in big.Ints
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
// BigTByte is 1,000 SI g bytes in big.Ints
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
// BigPByte is 1,000 SI t bytes in big.Ints
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
// BigEByte is 1,000 SI p bytes in big.Ints
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
// BigZByte is 1,000 SI e bytes in big.Ints
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
// BigYByte is 1,000 SI z bytes in big.Ints
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
// BigRByte is 1,000 SI y bytes in big.Ints
BigRByte = (&big.Int{}).Mul(BigYByte, bigSIExp)
// BigQByte is 1,000 SI r bytes in big.Ints
BigQByte = (&big.Int{}).Mul(BigRByte, bigSIExp)
)
var bigBytesSizeTable = map[string]*big.Int{
"b": BigByte,
"kib": BigKiByte,
"kb": BigKByte,
"mib": BigMiByte,
"mb": BigMByte,
"gib": BigGiByte,
"gb": BigGByte,
"tib": BigTiByte,
"tb": BigTByte,
"pib": BigPiByte,
"pb": BigPByte,
"eib": BigEiByte,
"eb": BigEByte,
"zib": BigZiByte,
"zb": BigZByte,
"yib": BigYiByte,
"yb": BigYByte,
"rib": BigRiByte,
"rb": BigRByte,
"qib": BigQiByte,
"qb": BigQByte,
// Without suffix
"": BigByte,
"ki": BigKiByte,
"k": BigKByte,
"mi": BigMiByte,
"m": BigMByte,
"gi": BigGiByte,
"g": BigGByte,
"ti": BigTiByte,
"t": BigTByte,
"pi": BigPiByte,
"p": BigPByte,
"ei": BigEiByte,
"e": BigEByte,
"z": BigZByte,
"zi": BigZiByte,
"y": BigYByte,
"yi": BigYiByte,
"r": BigRByte,
"ri": BigRiByte,
"q": BigQByte,
"qi": BigQiByte,
}
var ten = big.NewInt(10)
func humanateBigBytes(s, base *big.Int, sizes []string) string {
if s.Cmp(ten) < 0 {
return fmt.Sprintf("%d B", s)
}
c := (&big.Int{}).Set(s)
val, mag := oomm(c, base, len(sizes)-1)
suffix := sizes[mag]
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// BigBytes produces a human readable representation of an SI size.
//
// See also: ParseBigBytes.
//
// BigBytes(82854982) -> 83 MB
func BigBytes(s *big.Int) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"}
return humanateBigBytes(s, bigSIExp, sizes)
}
// BigIBytes produces a human readable representation of an IEC size.
//
// See also: ParseBigBytes.
//
// BigIBytes(82854982) -> 79 MiB
func BigIBytes(s *big.Int) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"}
return humanateBigBytes(s, bigIECExp, sizes)
}
// ParseBigBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See also: BigBytes, BigIBytes.
//
// ParseBigBytes("42 MB") -> 42000000, nil
// ParseBigBytes("42 mib") -> 44040192, nil
func ParseBigBytes(s string) (*big.Int, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
val := &big.Rat{}
_, err := fmt.Sscanf(num, "%f", val)
if err != nil {
return nil, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bigBytesSizeTable[extra]; ok {
mv := (&big.Rat{}).SetInt(m)
val.Mul(val, mv)
rv := &big.Int{}
rv.Div(val.Num(), val.Denom())
return rv, nil
}
return nil, fmt.Errorf("unhandled size name: %v", extra)
}

143
vendor/github.com/dustin/go-humanize/bytes.go generated vendored Normal file
View File

@@ -0,0 +1,143 @@
package humanize
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// IEC Sizes.
// kibis of bits
const (
Byte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
// SI Sizes.
const (
IByte = 1
KByte = IByte * 1000
MByte = KByte * 1000
GByte = MByte * 1000
TByte = GByte * 1000
PByte = TByte * 1000
EByte = PByte * 1000
)
var bytesSizeTable = map[string]uint64{
"b": Byte,
"kib": KiByte,
"kb": KByte,
"mib": MiByte,
"mb": MByte,
"gib": GiByte,
"gb": GByte,
"tib": TiByte,
"tb": TByte,
"pib": PiByte,
"pb": PByte,
"eib": EiByte,
"eb": EByte,
// Without suffix
"": Byte,
"ki": KiByte,
"k": KByte,
"mi": MiByte,
"m": MByte,
"gi": GiByte,
"g": GByte,
"ti": TiByte,
"t": TByte,
"pi": PiByte,
"p": PByte,
"ei": EiByte,
"e": EByte,
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// Bytes produces a human readable representation of an SI size.
//
// See also: ParseBytes.
//
// Bytes(82854982) -> 83 MB
func Bytes(s uint64) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(s, 1000, sizes)
}
// IBytes produces a human readable representation of an IEC size.
//
// See also: ParseBytes.
//
// IBytes(82854982) -> 79 MiB
func IBytes(s uint64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanateBytes(s, 1024, sizes)
}
// ParseBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See Also: Bytes, IBytes.
//
// ParseBytes("42 MB") -> 42000000, nil
// ParseBytes("42 mib") -> 44040192, nil
func ParseBytes(s string) (uint64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bytesSizeTable[extra]; ok {
f *= float64(m)
if f >= math.MaxUint64 {
return 0, fmt.Errorf("too large: %v", s)
}
return uint64(f), nil
}
return 0, fmt.Errorf("unhandled size name: %v", extra)
}

116
vendor/github.com/dustin/go-humanize/comma.go generated vendored Normal file
View File

@@ -0,0 +1,116 @@
package humanize
import (
"bytes"
"math"
"math/big"
"strconv"
"strings"
)
// Comma produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Comma(834142) -> 834,142
func Comma(v int64) string {
sign := ""
// Min int64 can't be negated to a usable value, so it has to be special cased.
if v == math.MinInt64 {
return "-9,223,372,036,854,775,808"
}
if v < 0 {
sign = "-"
v = 0 - v
}
parts := []string{"", "", "", "", "", "", ""}
j := len(parts) - 1
for v > 999 {
parts[j] = strconv.FormatInt(v%1000, 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
v = v / 1000
j--
}
parts[j] = strconv.Itoa(int(v))
return sign + strings.Join(parts[j:], ",")
}
// Commaf produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Commaf(834142.32) -> 834,142.32
func Commaf(v float64) string {
buf := &bytes.Buffer{}
if v < 0 {
buf.Write([]byte{'-'})
v = 0 - v
}
comma := []byte{','}
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
// CommafWithDigits works like the Commaf but limits the resulting
// string to the given number of decimal places.
//
// e.g. CommafWithDigits(834142.32, 1) -> 834,142.3
func CommafWithDigits(f float64, decimals int) string {
return stripTrailingDigits(Commaf(f), decimals)
}
// BigComma produces a string form of the given big.Int in base 10
// with commas after every three orders of magnitude.
func BigComma(b *big.Int) string {
sign := ""
if b.Sign() < 0 {
sign = "-"
b.Abs(b)
}
athousand := big.NewInt(1000)
c := (&big.Int{}).Set(b)
_, m := oom(c, athousand)
parts := make([]string, m+1)
j := len(parts) - 1
mod := &big.Int{}
for b.Cmp(athousand) >= 0 {
b.DivMod(b, athousand, mod)
parts[j] = strconv.FormatInt(mod.Int64(), 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
j--
}
parts[j] = strconv.Itoa(int(b.Int64()))
return sign + strings.Join(parts[j:], ",")
}

41
vendor/github.com/dustin/go-humanize/commaf.go generated vendored Normal file
View File

@@ -0,0 +1,41 @@
//go:build go1.6
// +build go1.6
package humanize
import (
"bytes"
"math/big"
"strings"
)
// BigCommaf produces a string form of the given big.Float in base 10
// with commas after every three orders of magnitude.
func BigCommaf(v *big.Float) string {
buf := &bytes.Buffer{}
if v.Sign() < 0 {
buf.Write([]byte{'-'})
v.Abs(v)
}
comma := []byte{','}
parts := strings.Split(v.Text('f', -1), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}

49
vendor/github.com/dustin/go-humanize/ftoa.go generated vendored Normal file
View File

@@ -0,0 +1,49 @@
package humanize
import (
"strconv"
"strings"
)
func stripTrailingZeros(s string) string {
if !strings.ContainsRune(s, '.') {
return s
}
offset := len(s) - 1
for offset > 0 {
if s[offset] == '.' {
offset--
break
}
if s[offset] != '0' {
break
}
offset--
}
return s[:offset+1]
}
func stripTrailingDigits(s string, digits int) string {
if i := strings.Index(s, "."); i >= 0 {
if digits <= 0 {
return s[:i]
}
i++
if i+digits >= len(s) {
return s
}
return s[:i+digits]
}
return s
}
// Ftoa converts a float to a string with no trailing zeros.
func Ftoa(num float64) string {
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
}
// FtoaWithDigits converts a float to a string but limits the resulting string
// to the given number of decimal places, and no trailing zeros.
func FtoaWithDigits(num float64, digits int) string {
return stripTrailingZeros(stripTrailingDigits(strconv.FormatFloat(num, 'f', 6, 64), digits))
}

8
vendor/github.com/dustin/go-humanize/humanize.go generated vendored Normal file
View File

@@ -0,0 +1,8 @@
/*
Package humanize converts boring ugly numbers to human-friendly strings and back.
Durations can be turned into strings such as "3 days ago", numbers
representing sizes like 82854982 into useful strings like, "83 MB" or
"79 MiB" (whichever you prefer).
*/
package humanize

192
vendor/github.com/dustin/go-humanize/number.go generated vendored Normal file
View File

@@ -0,0 +1,192 @@
package humanize
/*
Slightly adapted from the source to fit go-humanize.
Author: https://github.com/gorhill
Source: https://gist.github.com/gorhill/5285193
*/
import (
"math"
"strconv"
)
var (
renderFloatPrecisionMultipliers = [...]float64{
1,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
}
renderFloatPrecisionRounders = [...]float64{
0.5,
0.05,
0.005,
0.0005,
0.00005,
0.000005,
0.0000005,
0.00000005,
0.000000005,
0.0000000005,
}
)
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
// * thousands separator
// * decimal separator
// * decimal precision
//
// Usage: s := RenderFloat(format, n)
// The format parameter tells how to render the number n.
//
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
//
// Examples of format strings, given n = 12345.6789:
// "#,###.##" => "12,345.67"
// "#,###." => "12,345"
// "#,###" => "12345,678"
// "#\u202F###,##" => "12345,68"
// "#.###,###### => 12.345,678900
// "" (aka default format) => 12,345.67
//
// The highest precision allowed is 9 digits after the decimal symbol.
// There is also a version for integer number, FormatInteger(),
// which is convenient for calls within template.
func FormatFloat(format string, n float64) string {
// Special cases:
// NaN = "NaN"
// +Inf = "+Infinity"
// -Inf = "-Infinity"
if math.IsNaN(n) {
return "NaN"
}
if n > math.MaxFloat64 {
return "Infinity"
}
if n < (0.0 - math.MaxFloat64) {
return "-Infinity"
}
// default format
precision := 2
decimalStr := "."
thousandStr := ","
positiveStr := ""
negativeStr := "-"
if len(format) > 0 {
format := []rune(format)
// If there is an explicit format directive,
// then default values are these:
precision = 9
thousandStr = ""
// collect indices of meaningful formatting directives
formatIndx := []int{}
for i, char := range format {
if char != '#' && char != '0' {
formatIndx = append(formatIndx, i)
}
}
if len(formatIndx) > 0 {
// Directive at index 0:
// Must be a '+'
// Raise an error if not the case
// index: 0123456789
// +0.000,000
// +000,000.0
// +0000.00
// +0000
if formatIndx[0] == 0 {
if format[formatIndx[0]] != '+' {
panic("RenderFloat(): invalid positive sign directive")
}
positiveStr = "+"
formatIndx = formatIndx[1:]
}
// Two directives:
// First is thousands separator
// Raise an error if not followed by 3-digit
// 0123456789
// 0.000,000
// 000,000.00
if len(formatIndx) == 2 {
if (formatIndx[1] - formatIndx[0]) != 4 {
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
}
thousandStr = string(format[formatIndx[0]])
formatIndx = formatIndx[1:]
}
// One directive:
// Directive is decimal separator
// The number of digit-specifier following the separator indicates wanted precision
// 0123456789
// 0.00
// 000,0000
if len(formatIndx) == 1 {
decimalStr = string(format[formatIndx[0]])
precision = len(format) - formatIndx[0] - 1
}
}
}
// generate sign part
var signStr string
if n >= 0.000000001 {
signStr = positiveStr
} else if n <= -0.000000001 {
signStr = negativeStr
n = -n
} else {
signStr = ""
n = 0.0
}
// split number into integer and fractional parts
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
// generate integer part string
intStr := strconv.FormatInt(int64(intf), 10)
// add thousand separator if required
if len(thousandStr) > 0 {
for i := len(intStr); i > 3; {
i -= 3
intStr = intStr[:i] + thousandStr + intStr[i:]
}
}
// no fractional part, we can leave now
if precision == 0 {
return signStr + intStr
}
// generate fractional part
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
// may need padding
if len(fracStr) < precision {
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
}
return signStr + intStr + decimalStr + fracStr
}
// FormatInteger produces a formatted number as string.
// See FormatFloat.
func FormatInteger(format string, n int) string {
return FormatFloat(format, float64(n))
}

25
vendor/github.com/dustin/go-humanize/ordinals.go generated vendored Normal file
View File

@@ -0,0 +1,25 @@
package humanize
import "strconv"
// Ordinal gives you the input number in a rank/ordinal format.
//
// Ordinal(3) -> 3rd
func Ordinal(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}

127
vendor/github.com/dustin/go-humanize/si.go generated vendored Normal file
View File

@@ -0,0 +1,127 @@
package humanize
import (
"errors"
"math"
"regexp"
"strconv"
)
var siPrefixTable = map[float64]string{
-30: "q", // quecto
-27: "r", // ronto
-24: "y", // yocto
-21: "z", // zepto
-18: "a", // atto
-15: "f", // femto
-12: "p", // pico
-9: "n", // nano
-6: "µ", // micro
-3: "m", // milli
0: "",
3: "k", // kilo
6: "M", // mega
9: "G", // giga
12: "T", // tera
15: "P", // peta
18: "E", // exa
21: "Z", // zetta
24: "Y", // yotta
27: "R", // ronna
30: "Q", // quetta
}
var revSIPrefixTable = revfmap(siPrefixTable)
// revfmap reverses the map and precomputes the power multiplier
func revfmap(in map[float64]string) map[string]float64 {
rv := map[string]float64{}
for k, v := range in {
rv[v] = math.Pow(10, k)
}
return rv
}
var riParseRegex *regexp.Regexp
func init() {
ri := `^([\-0-9.]+)\s?([`
for _, v := range siPrefixTable {
ri += v
}
ri += `]?)(.*)`
riParseRegex = regexp.MustCompile(ri)
}
// ComputeSI finds the most appropriate SI prefix for the given number
// and returns the prefix along with the value adjusted to be within
// that prefix.
//
// See also: SI, ParseSI.
//
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
func ComputeSI(input float64) (float64, string) {
if input == 0 {
return 0, ""
}
mag := math.Abs(input)
exponent := math.Floor(logn(mag, 10))
exponent = math.Floor(exponent/3) * 3
value := mag / math.Pow(10, exponent)
// Handle special case where value is exactly 1000.0
// Should return 1 M instead of 1000 k
if value == 1000.0 {
exponent += 3
value = mag / math.Pow(10, exponent)
}
value = math.Copysign(value, input)
prefix := siPrefixTable[exponent]
return value, prefix
}
// SI returns a string with default formatting.
//
// SI uses Ftoa to format float value, removing trailing zeros.
//
// See also: ComputeSI, ParseSI.
//
// e.g. SI(1000000, "B") -> 1 MB
// e.g. SI(2.2345e-12, "F") -> 2.2345 pF
func SI(input float64, unit string) string {
value, prefix := ComputeSI(input)
return Ftoa(value) + " " + prefix + unit
}
// SIWithDigits works like SI but limits the resulting string to the
// given number of decimal places.
//
// e.g. SIWithDigits(1000000, 0, "B") -> 1 MB
// e.g. SIWithDigits(2.2345e-12, 2, "F") -> 2.23 pF
func SIWithDigits(input float64, decimals int, unit string) string {
value, prefix := ComputeSI(input)
return FtoaWithDigits(value, decimals) + " " + prefix + unit
}
var errInvalid = errors.New("invalid input")
// ParseSI parses an SI string back into the number and unit.
//
// See also: SI, ComputeSI.
//
// e.g. ParseSI("2.2345 pF") -> (2.2345e-12, "F", nil)
func ParseSI(input string) (float64, string, error) {
found := riParseRegex.FindStringSubmatch(input)
if len(found) != 4 {
return 0, "", errInvalid
}
mag := revSIPrefixTable[found[2]]
unit := found[3]
base, err := strconv.ParseFloat(found[1], 64)
return base * mag, unit, err
}

117
vendor/github.com/dustin/go-humanize/times.go generated vendored Normal file
View File

@@ -0,0 +1,117 @@
package humanize
import (
"fmt"
"math"
"sort"
"time"
)
// Seconds-based time units
const (
Day = 24 * time.Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
LongTime = 37 * Year
)
// Time formats a time into a relative string.
//
// Time(someT) -> "3 weeks ago"
func Time(then time.Time) string {
return RelTime(then, time.Now(), "ago", "from now")
}
// A RelTimeMagnitude struct contains a relative time point at which
// the relative format of time will switch to a new format string. A
// slice of these in ascending order by their "D" field is passed to
// CustomRelTime to format durations.
//
// The Format field is a string that may contain a "%s" which will be
// replaced with the appropriate signed label (e.g. "ago" or "from
// now") and a "%d" that will be replaced by the quantity.
//
// The DivBy field is the amount of time the time difference must be
// divided by in order to display correctly.
//
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
// DivBy should be time.Minute so whatever the duration is will be
// expressed in minutes.
type RelTimeMagnitude struct {
D time.Duration
Format string
DivBy time.Duration
}
var defaultMagnitudes = []RelTimeMagnitude{
{time.Second, "now", time.Second},
{2 * time.Second, "1 second %s", 1},
{time.Minute, "%d seconds %s", time.Second},
{2 * time.Minute, "1 minute %s", 1},
{time.Hour, "%d minutes %s", time.Minute},
{2 * time.Hour, "1 hour %s", 1},
{Day, "%d hours %s", time.Hour},
{2 * Day, "1 day %s", 1},
{Week, "%d days %s", Day},
{2 * Week, "1 week %s", 1},
{Month, "%d weeks %s", Week},
{2 * Month, "1 month %s", 1},
{Year, "%d months %s", Month},
{18 * Month, "1 year %s", 1},
{2 * Year, "2 years %s", 1},
{LongTime, "%d years %s", Year},
{math.MaxInt64, "a long while %s", 1},
}
// RelTime formats a time into a relative string.
//
// It takes two times and two labels. In addition to the generic time
// delta string (e.g. 5 minutes), the labels are used applied so that
// the label corresponding to the smaller time is applied.
//
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
func RelTime(a, b time.Time, albl, blbl string) string {
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
}
// CustomRelTime formats a time into a relative string.
//
// It takes two times two labels and a table of relative time formats.
// In addition to the generic time delta string (e.g. 5 minutes), the
// labels are used applied so that the label corresponding to the
// smaller time is applied.
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
lbl := albl
diff := b.Sub(a)
if a.After(b) {
lbl = blbl
diff = a.Sub(b)
}
n := sort.Search(len(magnitudes), func(i int) bool {
return magnitudes[i].D > diff
})
if n >= len(magnitudes) {
n = len(magnitudes) - 1
}
mag := magnitudes[n]
args := []interface{}{}
escaped := false
for _, ch := range mag.Format {
if escaped {
switch ch {
case 's':
args = append(args, lbl)
case 'd':
args = append(args, diff/mag.DivBy)
}
escaped = false
} else {
escaped = ch == '%'
}
}
return fmt.Sprintf(mag.Format, args...)
}

41
vendor/github.com/google/uuid/CHANGELOG.md generated vendored Normal file
View File

@@ -0,0 +1,41 @@
# Changelog
## [1.6.0](https://github.com/google/uuid/compare/v1.5.0...v1.6.0) (2024-01-16)
### Features
* add Max UUID constant ([#149](https://github.com/google/uuid/issues/149)) ([c58770e](https://github.com/google/uuid/commit/c58770eb495f55fe2ced6284f93c5158a62e53e3))
### Bug Fixes
* fix typo in version 7 uuid documentation ([#153](https://github.com/google/uuid/issues/153)) ([016b199](https://github.com/google/uuid/commit/016b199544692f745ffc8867b914129ecb47ef06))
* Monotonicity in UUIDv7 ([#150](https://github.com/google/uuid/issues/150)) ([a2b2b32](https://github.com/google/uuid/commit/a2b2b32373ff0b1a312b7fdf6d38a977099698a6))
## [1.5.0](https://github.com/google/uuid/compare/v1.4.0...v1.5.0) (2023-12-12)
### Features
* Validate UUID without creating new UUID ([#141](https://github.com/google/uuid/issues/141)) ([9ee7366](https://github.com/google/uuid/commit/9ee7366e66c9ad96bab89139418a713dc584ae29))
## [1.4.0](https://github.com/google/uuid/compare/v1.3.1...v1.4.0) (2023-10-26)
### Features
* UUIDs slice type with Strings() convenience method ([#133](https://github.com/google/uuid/issues/133)) ([cd5fbbd](https://github.com/google/uuid/commit/cd5fbbdd02f3e3467ac18940e07e062be1f864b4))
### Fixes
* Clarify that Parse's job is to parse but not necessarily validate strings. (Documents current behavior)
## [1.3.1](https://github.com/google/uuid/compare/v1.3.0...v1.3.1) (2023-08-18)
### Bug Fixes
* Use .EqualFold() to parse urn prefixed UUIDs ([#118](https://github.com/google/uuid/issues/118)) ([574e687](https://github.com/google/uuid/commit/574e6874943741fb99d41764c705173ada5293f0))
## Changelog

26
vendor/github.com/google/uuid/CONTRIBUTING.md generated vendored Normal file
View File

@@ -0,0 +1,26 @@
# How to contribute
We definitely welcome patches and contribution to this project!
### Tips
Commits must be formatted according to the [Conventional Commits Specification](https://www.conventionalcommits.org).
Always try to include a test case! If it is not possible or not necessary,
please explain why in the pull request description.
### Releasing
Commits that would precipitate a SemVer change, as described in the Conventional
Commits Specification, will trigger [`release-please`](https://github.com/google-github-actions/release-please-action)
to create a release candidate pull request. Once submitted, `release-please`
will create a release.
For tips on how to work with `release-please`, see its documentation.
### Legal requirements
In order to protect both you and ourselves, you will need to sign the
[Contributor License Agreement](https://cla.developers.google.com/clas).
You may have already signed it for other Google projects.

9
vendor/github.com/google/uuid/CONTRIBUTORS generated vendored Normal file
View File

@@ -0,0 +1,9 @@
Paul Borman <borman@google.com>
bmatsuo
shawnps
theory
jboverfelt
dsymonds
cd1
wallclockbuilder
dansouza

27
vendor/github.com/google/uuid/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,27 @@
Copyright (c) 2009,2014 Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
vendor/github.com/google/uuid/README.md generated vendored Normal file
View File

@@ -0,0 +1,21 @@
# uuid
The uuid package generates and inspects UUIDs based on
[RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)
and DCE 1.1: Authentication and Security Services.
This package is based on the github.com/pborman/uuid package (previously named
code.google.com/p/go-uuid). It differs from these earlier packages in that
a UUID is a 16 byte array rather than a byte slice. One loss due to this
change is the ability to represent an invalid UUID (vs a NIL UUID).
###### Install
```sh
go get github.com/google/uuid
```
###### Documentation
[![Go Reference](https://pkg.go.dev/badge/github.com/google/uuid.svg)](https://pkg.go.dev/github.com/google/uuid)
Full `go doc` style documentation for the package can be viewed online without
installing this package by using the GoDoc site here:
http://pkg.go.dev/github.com/google/uuid

80
vendor/github.com/google/uuid/dce.go generated vendored Normal file
View File

@@ -0,0 +1,80 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
"fmt"
"os"
)
// A Domain represents a Version 2 domain
type Domain byte
// Domain constants for DCE Security (Version 2) UUIDs.
const (
Person = Domain(0)
Group = Domain(1)
Org = Domain(2)
)
// NewDCESecurity returns a DCE Security (Version 2) UUID.
//
// The domain should be one of Person, Group or Org.
// On a POSIX system the id should be the users UID for the Person
// domain and the users GID for the Group. The meaning of id for
// the domain Org or on non-POSIX systems is site defined.
//
// For a given domain/id pair the same token may be returned for up to
// 7 minutes and 10 seconds.
func NewDCESecurity(domain Domain, id uint32) (UUID, error) {
uuid, err := NewUUID()
if err == nil {
uuid[6] = (uuid[6] & 0x0f) | 0x20 // Version 2
uuid[9] = byte(domain)
binary.BigEndian.PutUint32(uuid[0:], id)
}
return uuid, err
}
// NewDCEPerson returns a DCE Security (Version 2) UUID in the person
// domain with the id returned by os.Getuid.
//
// NewDCESecurity(Person, uint32(os.Getuid()))
func NewDCEPerson() (UUID, error) {
return NewDCESecurity(Person, uint32(os.Getuid()))
}
// NewDCEGroup returns a DCE Security (Version 2) UUID in the group
// domain with the id returned by os.Getgid.
//
// NewDCESecurity(Group, uint32(os.Getgid()))
func NewDCEGroup() (UUID, error) {
return NewDCESecurity(Group, uint32(os.Getgid()))
}
// Domain returns the domain for a Version 2 UUID. Domains are only defined
// for Version 2 UUIDs.
func (uuid UUID) Domain() Domain {
return Domain(uuid[9])
}
// ID returns the id for a Version 2 UUID. IDs are only defined for Version 2
// UUIDs.
func (uuid UUID) ID() uint32 {
return binary.BigEndian.Uint32(uuid[0:4])
}
func (d Domain) String() string {
switch d {
case Person:
return "Person"
case Group:
return "Group"
case Org:
return "Org"
}
return fmt.Sprintf("Domain%d", int(d))
}

12
vendor/github.com/google/uuid/doc.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package uuid generates and inspects UUIDs.
//
// UUIDs are based on RFC 4122 and DCE 1.1: Authentication and Security
// Services.
//
// A UUID is a 16 byte (128 bit) array. UUIDs may be used as keys to
// maps or compared directly.
package uuid

59
vendor/github.com/google/uuid/hash.go generated vendored Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"crypto/md5"
"crypto/sha1"
"hash"
)
// Well known namespace IDs and UUIDs
var (
NameSpaceDNS = Must(Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceURL = Must(Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceOID = Must(Parse("6ba7b812-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceX500 = Must(Parse("6ba7b814-9dad-11d1-80b4-00c04fd430c8"))
Nil UUID // empty UUID, all zeros
// The Max UUID is special form of UUID that is specified to have all 128 bits set to 1.
Max = UUID{
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
}
)
// NewHash returns a new UUID derived from the hash of space concatenated with
// data generated by h. The hash should be at least 16 byte in length. The
// first 16 bytes of the hash are used to form the UUID. The version of the
// UUID will be the lower 4 bits of version. NewHash is used to implement
// NewMD5 and NewSHA1.
func NewHash(h hash.Hash, space UUID, data []byte, version int) UUID {
h.Reset()
h.Write(space[:]) //nolint:errcheck
h.Write(data) //nolint:errcheck
s := h.Sum(nil)
var uuid UUID
copy(uuid[:], s)
uuid[6] = (uuid[6] & 0x0f) | uint8((version&0xf)<<4)
uuid[8] = (uuid[8] & 0x3f) | 0x80 // RFC 4122 variant
return uuid
}
// NewMD5 returns a new MD5 (Version 3) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(md5.New(), space, data, 3)
func NewMD5(space UUID, data []byte) UUID {
return NewHash(md5.New(), space, data, 3)
}
// NewSHA1 returns a new SHA1 (Version 5) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(sha1.New(), space, data, 5)
func NewSHA1(space UUID, data []byte) UUID {
return NewHash(sha1.New(), space, data, 5)
}

38
vendor/github.com/google/uuid/marshal.go generated vendored Normal file
View File

@@ -0,0 +1,38 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import "fmt"
// MarshalText implements encoding.TextMarshaler.
func (uuid UUID) MarshalText() ([]byte, error) {
var js [36]byte
encodeHex(js[:], uuid)
return js[:], nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (uuid *UUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
return err
}
*uuid = id
return nil
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (uuid UUID) MarshalBinary() ([]byte, error) {
return uuid[:], nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (uuid *UUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(uuid[:], data)
return nil
}

90
vendor/github.com/google/uuid/node.go generated vendored Normal file
View File

@@ -0,0 +1,90 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"sync"
)
var (
nodeMu sync.Mutex
ifname string // name of interface being used
nodeID [6]byte // hardware for version 1 UUIDs
zeroID [6]byte // nodeID with only 0's
)
// NodeInterface returns the name of the interface from which the NodeID was
// derived. The interface "user" is returned if the NodeID was set by
// SetNodeID.
func NodeInterface() string {
defer nodeMu.Unlock()
nodeMu.Lock()
return ifname
}
// SetNodeInterface selects the hardware address to be used for Version 1 UUIDs.
// If name is "" then the first usable interface found will be used or a random
// Node ID will be generated. If a named interface cannot be found then false
// is returned.
//
// SetNodeInterface never fails when name is "".
func SetNodeInterface(name string) bool {
defer nodeMu.Unlock()
nodeMu.Lock()
return setNodeInterface(name)
}
func setNodeInterface(name string) bool {
iname, addr := getHardwareInterface(name) // null implementation for js
if iname != "" && addr != nil {
ifname = iname
copy(nodeID[:], addr)
return true
}
// We found no interfaces with a valid hardware address. If name
// does not specify a specific interface generate a random Node ID
// (section 4.1.6)
if name == "" {
ifname = "random"
randomBits(nodeID[:])
return true
}
return false
}
// NodeID returns a slice of a copy of the current Node ID, setting the Node ID
// if not already set.
func NodeID() []byte {
defer nodeMu.Unlock()
nodeMu.Lock()
if nodeID == zeroID {
setNodeInterface("")
}
nid := nodeID
return nid[:]
}
// SetNodeID sets the Node ID to be used for Version 1 UUIDs. The first 6 bytes
// of id are used. If id is less than 6 bytes then false is returned and the
// Node ID is not set.
func SetNodeID(id []byte) bool {
if len(id) < 6 {
return false
}
defer nodeMu.Unlock()
nodeMu.Lock()
copy(nodeID[:], id)
ifname = "user"
return true
}
// NodeID returns the 6 byte node id encoded in uuid. It returns nil if uuid is
// not valid. The NodeID is only well defined for version 1 and 2 UUIDs.
func (uuid UUID) NodeID() []byte {
var node [6]byte
copy(node[:], uuid[10:])
return node[:]
}

12
vendor/github.com/google/uuid/node_js.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build js
package uuid
// getHardwareInterface returns nil values for the JS version of the code.
// This removes the "net" dependency, because it is not used in the browser.
// Using the "net" library inflates the size of the transpiled JS code by 673k bytes.
func getHardwareInterface(name string) (string, []byte) { return "", nil }

33
vendor/github.com/google/uuid/node_net.go generated vendored Normal file
View File

@@ -0,0 +1,33 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !js
package uuid
import "net"
var interfaces []net.Interface // cached list of interfaces
// getHardwareInterface returns the name and hardware address of interface name.
// If name is "" then the name and hardware address of one of the system's
// interfaces is returned. If no interfaces are found (name does not exist or
// there are no interfaces) then "", nil is returned.
//
// Only addresses of at least 6 bytes are returned.
func getHardwareInterface(name string) (string, []byte) {
if interfaces == nil {
var err error
interfaces, err = net.Interfaces()
if err != nil {
return "", nil
}
}
for _, ifs := range interfaces {
if len(ifs.HardwareAddr) >= 6 && (name == "" || name == ifs.Name) {
return ifs.Name, ifs.HardwareAddr
}
}
return "", nil
}

Some files were not shown because too many files have changed in this diff Show More