Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 169b3a0d4a | |||
| 2bda7fc138 | |||
| 76247978c2 | |||
| ca3bc736f6 | |||
| 9d9ad6588e | |||
| e4d131021e | |||
| 8d6c060483 | |||
| c7e1232f98 | |||
| 572d2fb196 | |||
| c6a84a1b80 | |||
| 08b3e2a472 | |||
| 6e30cf12f2 | |||
| c28562dbcf | |||
| 84c487e7f8 | |||
| 8b1c89fdc9 | |||
| d7f18a5d90 | |||
| 5a802bceb6 | |||
| 777ba8a0e1 | |||
| 503c52dc26 | |||
| 6465da3547 | |||
| e18a3647bf | |||
| 1e58dcce27 | |||
| 1afbf5e1f6 | |||
| ea8a42a696 | |||
| ff9bfc5087 | |||
| 17ac0f3014 | |||
| 7133871be2 | |||
| efa32a7712 |
@@ -5,6 +5,24 @@ run:
|
|||||||
tests: true
|
tests: true
|
||||||
|
|
||||||
linters:
|
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
|
default: none
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
@@ -69,12 +87,3 @@ formatters:
|
|||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|
||||||
exclusions:
|
|
||||||
paths:
|
|
||||||
- vendor
|
|
||||||
rules:
|
|
||||||
- path: "_test\\.go"
|
|
||||||
linters:
|
|
||||||
- gosec
|
|
||||||
text: "G101"
|
|
||||||
|
|||||||
285
ARCHITECTURE.md
285
ARCHITECTURE.md
@@ -192,6 +192,9 @@ for a service by prefix and derive component names automatically
|
|||||||
```
|
```
|
||||||
mcp login Authenticate to MCIAS, store token
|
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> Deploy all components from service definition
|
||||||
mcp deploy <service>/<component> Deploy a single component
|
mcp deploy <service>/<component> Deploy a single component
|
||||||
mcp deploy <service> -f <file> Deploy from explicit file
|
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
|
mcp ps Live check: query runtime on all agents, show running
|
||||||
containers with uptime and version
|
containers with uptime and version
|
||||||
mcp status [service] Full picture: live query + drift + recent events
|
mcp status [service] Full picture: live query + drift + recent events
|
||||||
mcp sync Push service definitions to agent (update desired
|
mcp sync Push service definitions to agent; build missing
|
||||||
state without deploying)
|
images if source tree is available
|
||||||
|
|
||||||
mcp adopt <service> Adopt all <service>-* containers into a service
|
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 show <service> Print current spec from agent registry
|
||||||
mcp service edit <service> Open service definition in $EDITOR
|
mcp service edit <service> Open service definition in $EDITOR
|
||||||
@@ -234,25 +238,34 @@ Example: `~/.config/mcp/services/metacrypt.toml`
|
|||||||
name = "metacrypt"
|
name = "metacrypt"
|
||||||
node = "rift"
|
node = "rift"
|
||||||
active = true
|
active = true
|
||||||
|
version = "v1.0.0"
|
||||||
|
|
||||||
|
[build.images]
|
||||||
|
metacrypt = "Dockerfile.api"
|
||||||
|
metacrypt-web = "Dockerfile.web"
|
||||||
|
|
||||||
[[components]]
|
[[components]]
|
||||||
name = "api"
|
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"]
|
volumes = ["/srv/metacrypt:/srv/metacrypt"]
|
||||||
|
|
||||||
|
[[components.routes]]
|
||||||
|
name = "rest"
|
||||||
|
port = 8443
|
||||||
|
mode = "l4"
|
||||||
|
|
||||||
|
[[components.routes]]
|
||||||
|
name = "grpc"
|
||||||
|
port = 9443
|
||||||
|
mode = "l4"
|
||||||
|
|
||||||
[[components]]
|
[[components]]
|
||||||
name = "web"
|
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"]
|
volumes = ["/srv/metacrypt:/srv/metacrypt"]
|
||||||
cmd = ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
|
cmd = ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
|
||||||
|
|
||||||
|
[[components.routes]]
|
||||||
|
port = 443
|
||||||
|
mode = "l7"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Active State
|
### Active State
|
||||||
@@ -286,6 +299,12 @@ chain:
|
|||||||
If neither exists (first deploy, no file), the deploy fails with an error
|
If neither exists (first deploy, no file), the deploy fails with an error
|
||||||
telling the operator to create a service definition.
|
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
|
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
|
registry and executes the deploy. The service definition file on disk is
|
||||||
**not** modified -- it represents the operator's declared intent, not the
|
**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
|
- **Generated by converting from mcdeploy.toml** during initial MCP
|
||||||
migration (one-time).
|
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
|
## 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
|
this user. All containers are owned by `mcp`. The NixOS configuration
|
||||||
provisions the `mcp` user with podman access.
|
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
|
#### Deploy Flow
|
||||||
|
|
||||||
When the agent receives a `Deploy` RPC:
|
When the agent receives a `Deploy` RPC:
|
||||||
@@ -1133,6 +1252,7 @@ mcp/
|
|||||||
│ ├── mcp/ CLI
|
│ ├── mcp/ CLI
|
||||||
│ │ ├── main.go
|
│ │ ├── main.go
|
||||||
│ │ ├── login.go
|
│ │ ├── login.go
|
||||||
|
│ │ ├── build.go build and push images
|
||||||
│ │ ├── deploy.go
|
│ │ ├── deploy.go
|
||||||
│ │ ├── lifecycle.go stop, start, restart
|
│ │ ├── lifecycle.go stop, start, restart
|
||||||
│ │ ├── status.go list, ps, status
|
│ │ ├── 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+)
|
## Future Work (v2+)
|
||||||
|
|
||||||
These are explicitly out of scope for v1 but inform the design:
|
These are explicitly out of scope for v1 but inform the design:
|
||||||
|
|||||||
@@ -55,4 +55,4 @@ Run a single test: `go test ./internal/registry/ -run TestComponentCRUD`
|
|||||||
|
|
||||||
## Module Path
|
## Module Path
|
||||||
|
|
||||||
`git.wntrmute.dev/kyle/mcp`
|
`git.wntrmute.dev/mc/mcp`
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -21,8 +21,8 @@ lint:
|
|||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_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/kyle/mcp \
|
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcp \
|
||||||
proto/mcp/v1/*.proto
|
proto/mcp/v1/*.proto
|
||||||
|
|
||||||
proto-lint:
|
proto-lint:
|
||||||
|
|||||||
108
PROGRESS_V1.md
108
PROGRESS_V1.md
@@ -47,5 +47,109 @@
|
|||||||
## Phase 5: Integration and Polish
|
## Phase 5: Integration and Polish
|
||||||
|
|
||||||
- [ ] **P5.1** Integration test suite
|
- [ ] **P5.1** Integration test suite
|
||||||
- [ ] **P5.2** Bootstrap procedure test
|
- [x] **P5.2** Bootstrap procedure — documented in `docs/bootstrap.md`
|
||||||
- [ ] **P5.3** Documentation (CLAUDE.md, README.md, RUNBOOK.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
|
||||||
|
```
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ else builds on.
|
|||||||
structure, and configure tooling.
|
structure, and configure tooling.
|
||||||
|
|
||||||
**Deliverables:**
|
**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,
|
- `Makefile` with standard targets (build, test, vet, lint, proto,
|
||||||
proto-lint, clean, all)
|
proto-lint, clean, all)
|
||||||
- `.golangci.yaml` with platform-standard linter config
|
- `.golangci.yaml` with platform-standard linter config
|
||||||
|
|||||||
119
README.md
Normal file
119
README.md
Normal 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
305
RUNBOOK.md
Normal 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 |
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/agent"
|
"git.wntrmute.dev/mc/mcp/internal/agent"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
168
cmd/mcp/build.go
Normal file
168
cmd/mcp/build.go
Normal 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
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
|
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func deployCmd() *cobra.Command {
|
func deployCmd() *cobra.Command {
|
||||||
@@ -31,6 +32,12 @@ func deployCmd() *cobra.Command {
|
|||||||
return err
|
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)
|
spec := servicedef.ToProto(def)
|
||||||
|
|
||||||
address, err := findNodeAddress(cfg, def.Node)
|
address, err := findNodeAddress(cfg, def.Node)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -68,5 +69,5 @@ func loadBearerToken(cfg *config.CLIConfig) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read token from %q: %w (run 'mcp login' first)", cfg.Auth.TokenPath, err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// findNodeAddress looks up a node by name in the CLI config and returns
|
// findNodeAddress looks up a node by name in the CLI config and returns
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func stopCmd() *cobra.Command {
|
func stopCmd() *cobra.Command {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/auth"
|
"git.wntrmute.dev/mc/mcp/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loginCmd() *cobra.Command {
|
func loginCmd() *cobra.Command {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,11 @@ func main() {
|
|||||||
Use: "mcp",
|
Use: "mcp",
|
||||||
Short: "Metacircular Control Plane CLI",
|
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{
|
root.AddCommand(&cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
@@ -29,6 +34,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
root.AddCommand(loginCmd())
|
root.AddCommand(loginCmd())
|
||||||
|
root.AddCommand(buildCmd())
|
||||||
root.AddCommand(deployCmd())
|
root.AddCommand(deployCmd())
|
||||||
root.AddCommand(stopCmd())
|
root.AddCommand(stopCmd())
|
||||||
root.AddCommand(startCmd())
|
root.AddCommand(startCmd())
|
||||||
@@ -42,6 +48,7 @@ func main() {
|
|||||||
root.AddCommand(pushCmd())
|
root.AddCommand(pushCmd())
|
||||||
root.AddCommand(pullCmd())
|
root.AddCommand(pullCmd())
|
||||||
root.AddCommand(nodeCmd())
|
root.AddCommand(nodeCmd())
|
||||||
|
root.AddCommand(purgeCmd())
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
toml "github.com/pelletier/go-toml/v2"
|
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"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
119
cmd/mcp/purge.go
Normal file
119
cmd/mcp/purge.go
Normal 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
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
||||||
toml "github.com/pelletier/go-toml/v2"
|
toml "github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
|
"git.wntrmute.dev/mc/mcp/internal/servicedef"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ RestartSec=5
|
|||||||
|
|
||||||
User=mcp
|
User=mcp
|
||||||
Group=mcp
|
Group=mcp
|
||||||
|
Environment=HOME=/srv/mcp
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||||
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
|
|||||||
198
docs/bootstrap.md
Normal file
198
docs/bootstrap.md
Normal 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
27
flake.lock
generated
Normal 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
48
flake.nix
Normal 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
@@ -28,6 +28,7 @@ const (
|
|||||||
McpAgentService_GetServiceStatus_FullMethodName = "/mcp.v1.McpAgentService/GetServiceStatus"
|
McpAgentService_GetServiceStatus_FullMethodName = "/mcp.v1.McpAgentService/GetServiceStatus"
|
||||||
McpAgentService_LiveCheck_FullMethodName = "/mcp.v1.McpAgentService/LiveCheck"
|
McpAgentService_LiveCheck_FullMethodName = "/mcp.v1.McpAgentService/LiveCheck"
|
||||||
McpAgentService_AdoptContainers_FullMethodName = "/mcp.v1.McpAgentService/AdoptContainers"
|
McpAgentService_AdoptContainers_FullMethodName = "/mcp.v1.McpAgentService/AdoptContainers"
|
||||||
|
McpAgentService_PurgeComponent_FullMethodName = "/mcp.v1.McpAgentService/PurgeComponent"
|
||||||
McpAgentService_PushFile_FullMethodName = "/mcp.v1.McpAgentService/PushFile"
|
McpAgentService_PushFile_FullMethodName = "/mcp.v1.McpAgentService/PushFile"
|
||||||
McpAgentService_PullFile_FullMethodName = "/mcp.v1.McpAgentService/PullFile"
|
McpAgentService_PullFile_FullMethodName = "/mcp.v1.McpAgentService/PullFile"
|
||||||
McpAgentService_NodeStatus_FullMethodName = "/mcp.v1.McpAgentService/NodeStatus"
|
McpAgentService_NodeStatus_FullMethodName = "/mcp.v1.McpAgentService/NodeStatus"
|
||||||
@@ -50,6 +51,8 @@ type McpAgentServiceClient interface {
|
|||||||
LiveCheck(ctx context.Context, in *LiveCheckRequest, opts ...grpc.CallOption) (*LiveCheckResponse, error)
|
LiveCheck(ctx context.Context, in *LiveCheckRequest, opts ...grpc.CallOption) (*LiveCheckResponse, error)
|
||||||
// Adopt
|
// Adopt
|
||||||
AdoptContainers(ctx context.Context, in *AdoptContainersRequest, opts ...grpc.CallOption) (*AdoptContainersResponse, error)
|
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
|
// File transfer
|
||||||
PushFile(ctx context.Context, in *PushFileRequest, opts ...grpc.CallOption) (*PushFileResponse, error)
|
PushFile(ctx context.Context, in *PushFileRequest, opts ...grpc.CallOption) (*PushFileResponse, error)
|
||||||
PullFile(ctx context.Context, in *PullFileRequest, opts ...grpc.CallOption) (*PullFileResponse, 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
|
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) {
|
func (c *mcpAgentServiceClient) PushFile(ctx context.Context, in *PushFileRequest, opts ...grpc.CallOption) (*PushFileResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(PushFileResponse)
|
out := new(PushFileResponse)
|
||||||
@@ -202,6 +215,8 @@ type McpAgentServiceServer interface {
|
|||||||
LiveCheck(context.Context, *LiveCheckRequest) (*LiveCheckResponse, error)
|
LiveCheck(context.Context, *LiveCheckRequest) (*LiveCheckResponse, error)
|
||||||
// Adopt
|
// Adopt
|
||||||
AdoptContainers(context.Context, *AdoptContainersRequest) (*AdoptContainersResponse, error)
|
AdoptContainers(context.Context, *AdoptContainersRequest) (*AdoptContainersResponse, error)
|
||||||
|
// Purge
|
||||||
|
PurgeComponent(context.Context, *PurgeRequest) (*PurgeResponse, error)
|
||||||
// File transfer
|
// File transfer
|
||||||
PushFile(context.Context, *PushFileRequest) (*PushFileResponse, error)
|
PushFile(context.Context, *PushFileRequest) (*PushFileResponse, error)
|
||||||
PullFile(context.Context, *PullFileRequest) (*PullFileResponse, 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) {
|
func (UnimplementedMcpAgentServiceServer) AdoptContainers(context.Context, *AdoptContainersRequest) (*AdoptContainersResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method AdoptContainers not implemented")
|
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) {
|
func (UnimplementedMcpAgentServiceServer) PushFile(context.Context, *PushFileRequest) (*PushFileResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method PushFile not implemented")
|
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)
|
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) {
|
func _McpAgentService_PushFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(PushFileRequest)
|
in := new(PushFileRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -533,6 +569,10 @@ var McpAgentService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "AdoptContainers",
|
MethodName: "AdoptContainers",
|
||||||
Handler: _McpAgentService_AdoptContainers_Handler,
|
Handler: _McpAgentService_AdoptContainers_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "PurgeComponent",
|
||||||
|
Handler: _McpAgentService_PurgeComponent_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "PushFile",
|
MethodName: "PushFile",
|
||||||
Handler: _McpAgentService_PushFile_Handler,
|
Handler: _McpAgentService_PushFile_Handler,
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -1,8 +1,9 @@
|
|||||||
module git.wntrmute.dev/kyle/mcp
|
module git.wntrmute.dev/mc/mcp
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.wntrmute.dev/mc/mc-proxy v1.2.0
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
|
|||||||
20
go.sum
20
go.sum
@@ -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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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=
|
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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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=
|
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/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 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
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=
|
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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdoptContainers discovers running containers that match the given service
|
// AdoptContainers discovers running containers that match the given service
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdoptContainers(t *testing.T) {
|
func TestAdoptContainers(t *testing.T) {
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/auth"
|
"git.wntrmute.dev/mc/mcp/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/monitor"
|
"git.wntrmute.dev/mc/mcp/internal/monitor"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
)
|
)
|
||||||
@@ -26,11 +26,15 @@ import (
|
|||||||
type Agent struct {
|
type Agent struct {
|
||||||
mcpv1.UnimplementedMcpAgentServiceServer
|
mcpv1.UnimplementedMcpAgentServiceServer
|
||||||
|
|
||||||
Config *config.AgentConfig
|
Config *config.AgentConfig
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
Runtime runtime.Runtime
|
Runtime runtime.Runtime
|
||||||
Monitor *monitor.Monitor
|
Monitor *monitor.Monitor
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
PortAlloc *PortAllocator
|
||||||
|
Proxy *ProxyRouter
|
||||||
|
Certs *CertProvisioner
|
||||||
|
DNS *DNSRegistrar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the agent: opens the database, sets up the gRPC server with
|
// Run starts the agent: opens the database, sets up the gRPC server with
|
||||||
@@ -50,12 +54,31 @@ func Run(cfg *config.AgentConfig) error {
|
|||||||
|
|
||||||
mon := monitor.New(db, rt, cfg.Monitor, cfg.Agent.NodeName, logger)
|
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{
|
a := &Agent{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
DB: db,
|
DB: db,
|
||||||
Runtime: rt,
|
Runtime: rt,
|
||||||
Monitor: mon,
|
Monitor: mon,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
PortAlloc: NewPortAllocator(),
|
||||||
|
Proxy: proxy,
|
||||||
|
Certs: certs,
|
||||||
|
DNS: dns,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
||||||
@@ -106,6 +129,7 @@ func Run(cfg *config.AgentConfig) error {
|
|||||||
logger.Info("shutting down")
|
logger.Info("shutting down")
|
||||||
mon.Stop()
|
mon.Stop()
|
||||||
server.GracefulStop()
|
server.GracefulStop()
|
||||||
|
_ = proxy.Close()
|
||||||
return nil
|
return nil
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
mon.Stop()
|
mon.Stop()
|
||||||
|
|||||||
244
internal/agent/certs.go
Normal file
244
internal/agent/certs.go
Normal 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
|
||||||
|
}
|
||||||
392
internal/agent/certs_test.go
Normal file
392
internal/agent/certs_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deploy deploys a service (or a single component of it) to this node.
|
// 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.
|
// 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 {
|
func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcpv1.ComponentSpec, active bool) *mcpv1.ComponentResult {
|
||||||
compName := cs.GetName()
|
compName := cs.GetName()
|
||||||
containerName := serviceName + "-" + compName
|
containerName := ContainerNameFor(serviceName, compName)
|
||||||
|
|
||||||
desiredState := "running"
|
desiredState := "running"
|
||||||
if !active {
|
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)
|
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 := ®istry.Component{
|
regComp := ®istry.Component{
|
||||||
Name: compName,
|
Name: compName,
|
||||||
Service: serviceName,
|
Service: serviceName,
|
||||||
@@ -70,6 +90,7 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
|
|||||||
Ports: cs.GetPorts(),
|
Ports: cs.GetPorts(),
|
||||||
Volumes: cs.GetVolumes(),
|
Volumes: cs.GetVolumes(),
|
||||||
Cmd: cs.GetCmd(),
|
Cmd: cs.GetCmd(),
|
||||||
|
Routes: regRoutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureComponent(a.DB, regComp); err != nil {
|
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.Stop(ctx, containerName) // may not exist yet
|
||||||
_ = a.Runtime.Remove(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{
|
runSpec := runtime.ContainerSpec{
|
||||||
Name: containerName,
|
Name: containerName,
|
||||||
Image: cs.GetImage(),
|
Image: cs.GetImage(),
|
||||||
Network: cs.GetNetwork(),
|
Network: cs.GetNetwork(),
|
||||||
User: cs.GetUser(),
|
User: cs.GetUser(),
|
||||||
Restart: cs.GetRestart(),
|
Restart: cs.GetRestart(),
|
||||||
Ports: cs.GetPorts(),
|
|
||||||
Volumes: cs.GetVolumes(),
|
Volumes: cs.GetVolumes(),
|
||||||
Cmd: cs.GetCmd(),
|
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 {
|
if err := a.Runtime.Run(ctx, runSpec); err != nil {
|
||||||
_ = registry.UpdateComponentState(a.DB, serviceName, compName, "", "removed")
|
_ = registry.UpdateComponentState(a.DB, serviceName, compName, "", "removed")
|
||||||
return &mcpv1.ComponentResult{
|
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 {
|
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)
|
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
|
// ensureService creates the service if it does not exist, or updates its
|
||||||
// active flag if it does.
|
// active flag if it does.
|
||||||
func ensureService(db *sql.DB, name string, active bool) error {
|
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)
|
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
|
// ensureComponent creates the component if it does not exist, or updates its
|
||||||
// spec if it does.
|
// spec if it does.
|
||||||
func ensureComponent(db *sql.DB, c *registry.Component) error {
|
func ensureComponent(db *sql.DB, c *registry.Component) error {
|
||||||
|
|||||||
265
internal/agent/dns.go
Normal file
265
internal/agent/dns.go
Normal 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
214
internal/agent/dns_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -27,9 +27,23 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
|
|||||||
|
|
||||||
var results []*mcpv1.ComponentResult
|
var results []*mcpv1.ComponentResult
|
||||||
for _, c := range components {
|
for _, c := range components {
|
||||||
containerName := req.GetName() + "-" + c.Name
|
containerName := ContainerNameFor(req.GetName(), c.Name)
|
||||||
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
|
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 {
|
if err := a.Runtime.Stop(ctx, containerName); err != nil {
|
||||||
a.Logger.Info("stop container (ignored)", "container", containerName, "error", err)
|
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
|
// startComponent removes any existing container and runs a fresh one from
|
||||||
// the registry spec, then updates state to running.
|
// the registry spec, then updates state to running.
|
||||||
func startComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
|
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}
|
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
|
||||||
|
|
||||||
// Remove any pre-existing container; ignore errors for non-existent ones.
|
// 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
|
// restartComponent stops, removes, and re-creates a container without
|
||||||
// changing the desired_state in the registry.
|
// changing the desired_state in the registry.
|
||||||
func restartComponent(ctx context.Context, a *Agent, service string, c *registry.Component) *mcpv1.ComponentResult {
|
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}
|
r := &mcpv1.ComponentResult{Name: c.Name, Success: true}
|
||||||
|
|
||||||
_ = a.Runtime.Stop(ctx, containerName)
|
_ = 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.
|
// componentToSpec builds a runtime.ContainerSpec from a registry Component.
|
||||||
func componentToSpec(service string, c *registry.Component) runtime.ContainerSpec {
|
func componentToSpec(service string, c *registry.Component) runtime.ContainerSpec {
|
||||||
return runtime.ContainerSpec{
|
return runtime.ContainerSpec{
|
||||||
Name: service + "-" + c.Name,
|
Name: ContainerNameFor(service, c.Name),
|
||||||
Image: c.Image,
|
Image: c.Image,
|
||||||
Network: c.Network,
|
Network: c.Network,
|
||||||
User: c.UserSpec,
|
User: c.UserSpec,
|
||||||
|
|||||||
34
internal/agent/names.go
Normal file
34
internal/agent/names.go
Normal 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
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|||||||
68
internal/agent/portalloc.go
Normal file
68
internal/agent/portalloc.go
Normal 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
|
||||||
|
}
|
||||||
65
internal/agent/portalloc_test.go
Normal file
65
internal/agent/portalloc_test.go
Normal 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
138
internal/agent/proxy.go
Normal 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)
|
||||||
|
}
|
||||||
57
internal/agent/proxy_test.go
Normal file
57
internal/agent/proxy_test.go
Normal 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
155
internal/agent/purge.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
405
internal/agent/purge_test.go
Normal file
405
internal/agent/purge_test.go
Normal 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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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, ®istry.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,11 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"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
|
var result []*mcpv1.ServiceInfo
|
||||||
|
knownServices := make(map[string]bool, len(services))
|
||||||
for _, svc := range services {
|
for _, svc := range services {
|
||||||
|
knownServices[svc.Name] = true
|
||||||
|
|
||||||
components, err := registry.ListComponents(a.DB, svc.Name)
|
components, err := registry.ListComponents(a.DB, svc.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list components for %q: %w", svc.Name, err)
|
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 {
|
for _, comp := range components {
|
||||||
containerName := svc.Name + "-" + comp.Name
|
containerName := ContainerNameFor(svc.Name, comp.Name)
|
||||||
ci := &mcpv1.ComponentInfo{
|
ci := &mcpv1.ComponentInfo{
|
||||||
Name: comp.Name,
|
Name: comp.Name,
|
||||||
Image: comp.Image,
|
Image: comp.Image,
|
||||||
@@ -116,7 +118,7 @@ func (a *Agent) liveCheckServices(ctx context.Context) ([]*mcpv1.ServiceInfo, er
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
svcName, compName := splitContainerName(c.Name)
|
svcName, compName := SplitContainerName(c.Name, knownServices)
|
||||||
|
|
||||||
result = append(result, &mcpv1.ServiceInfo{
|
result = append(result, &mcpv1.ServiceInfo{
|
||||||
Name: svcName,
|
Name: svcName,
|
||||||
@@ -210,13 +212,3 @@ func (a *Agent) GetServiceStatus(ctx context.Context, req *mcpv1.GetServiceStatu
|
|||||||
RecentEvents: protoEvents,
|
RecentEvents: protoEvents,
|
||||||
}, nil
|
}, 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestListServices(t *testing.T) {
|
func TestListServices(t *testing.T) {
|
||||||
@@ -253,22 +253,47 @@ func TestGetServiceStatus_IgnoreSkipsDrift(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitContainerName(t *testing.T) {
|
func TestSplitContainerName(t *testing.T) {
|
||||||
|
known := map[string]bool{
|
||||||
|
"metacrypt": true,
|
||||||
|
"mc-proxy": true,
|
||||||
|
"mcr": true,
|
||||||
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
service string
|
service string
|
||||||
comp string
|
comp string
|
||||||
}{
|
}{
|
||||||
{"metacrypt-api", "metacrypt", "api"},
|
{"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"},
|
{"standalone", "standalone", "standalone"},
|
||||||
|
{"unknown-thing", "unknown", "thing"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
mcpv1 "git.wntrmute.dev/mc/mcp/gen/mcp/v1"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"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.
|
// protoToComponent converts a proto ComponentSpec to a registry Component.
|
||||||
func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState string) *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 ®istry.Component{
|
return ®istry.Component{
|
||||||
Name: cs.GetName(),
|
Name: cs.GetName(),
|
||||||
Service: service,
|
Service: service,
|
||||||
@@ -167,6 +185,7 @@ func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState stri
|
|||||||
Ports: cs.GetPorts(),
|
Ports: cs.GetPorts(),
|
||||||
Volumes: cs.GetVolumes(),
|
Volumes: cs.GetVolumes(),
|
||||||
Cmd: cs.GetCmd(),
|
Cmd: cs.GetCmd(),
|
||||||
|
Routes: routes,
|
||||||
DesiredState: desiredState,
|
DesiredState: desiredState,
|
||||||
Version: runtime.ExtractVersion(cs.GetImage()),
|
Version: runtime.ExtractVersion(cs.GetImage()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeRuntime implements runtime.Runtime for testing.
|
// 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) Run(_ context.Context, _ runtime.ContainerSpec) error { return nil }
|
||||||
func (f *fakeRuntime) Stop(_ context.Context, _ string) 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) 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) {
|
func (f *fakeRuntime) List(_ context.Context) ([]runtime.ContainerInfo, error) {
|
||||||
return f.containers, f.listErr
|
return f.containers, f.listErr
|
||||||
|
|||||||
@@ -10,12 +10,67 @@ import (
|
|||||||
|
|
||||||
// AgentConfig is the configuration for the mcp-agent daemon.
|
// AgentConfig is the configuration for the mcp-agent daemon.
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
MCIAS MCIASConfig `toml:"mcias"`
|
MCIAS MCIASConfig `toml:"mcias"`
|
||||||
Agent AgentSettings `toml:"agent"`
|
Agent AgentSettings `toml:"agent"`
|
||||||
Monitor MonitorConfig `toml:"monitor"`
|
MCProxy MCProxyConfig `toml:"mcproxy"`
|
||||||
Log LogConfig `toml:"log"`
|
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.
|
// ServerConfig holds gRPC server listen address and TLS paths.
|
||||||
@@ -134,6 +189,18 @@ func applyAgentDefaults(cfg *AgentConfig) {
|
|||||||
if cfg.Agent.ContainerRuntime == "" {
|
if cfg.Agent.ContainerRuntime == "" {
|
||||||
cfg.Agent.ContainerRuntime = "podman"
|
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) {
|
func applyAgentEnvOverrides(cfg *AgentConfig) {
|
||||||
@@ -158,6 +225,27 @@ func applyAgentEnvOverrides(cfg *AgentConfig) {
|
|||||||
if v := os.Getenv("MCP_AGENT_LOG_LEVEL"); v != "" {
|
if v := os.Getenv("MCP_AGENT_LOG_LEVEL"); v != "" {
|
||||||
cfg.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 {
|
func validateAgentConfig(cfg *AgentConfig) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
toml "github.com/pelletier/go-toml/v2"
|
toml "github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
@@ -10,11 +11,17 @@ import (
|
|||||||
// CLIConfig is the configuration for the mcp CLI binary.
|
// CLIConfig is the configuration for the mcp CLI binary.
|
||||||
type CLIConfig struct {
|
type CLIConfig struct {
|
||||||
Services ServicesConfig `toml:"services"`
|
Services ServicesConfig `toml:"services"`
|
||||||
|
Build BuildConfig `toml:"build"`
|
||||||
MCIAS MCIASConfig `toml:"mcias"`
|
MCIAS MCIASConfig `toml:"mcias"`
|
||||||
Auth AuthConfig `toml:"auth"`
|
Auth AuthConfig `toml:"auth"`
|
||||||
Nodes []NodeConfig `toml:"nodes"`
|
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.
|
// ServicesConfig defines where service definition files live.
|
||||||
type ServicesConfig struct {
|
type ServicesConfig struct {
|
||||||
Dir string `toml:"dir"`
|
Dir string `toml:"dir"`
|
||||||
@@ -66,6 +73,9 @@ func applyCLIEnvOverrides(cfg *CLIConfig) {
|
|||||||
if v := os.Getenv("MCP_SERVICES_DIR"); v != "" {
|
if v := os.Getenv("MCP_SERVICES_DIR"); v != "" {
|
||||||
cfg.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 != "" {
|
if v := os.Getenv("MCP_MCIAS_SERVER_URL"); v != "" {
|
||||||
cfg.MCIAS.ServerURL = v
|
cfg.MCIAS.ServerURL = v
|
||||||
}
|
}
|
||||||
@@ -93,5 +103,15 @@ func validateCLIConfig(cfg *CLIConfig) error {
|
|||||||
if cfg.Auth.TokenPath == "" {
|
if cfg.Auth.TokenPath == "" {
|
||||||
return fmt.Errorf("auth.token_path is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,19 @@ func TestLoadAgentConfig(t *testing.T) {
|
|||||||
if cfg.Log.Level != "debug" {
|
if cfg.Log.Level != "debug" {
|
||||||
t.Fatalf("log.level: got %q", cfg.Log.Level)
|
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) {
|
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) {
|
func TestDurationParsing(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alerter evaluates state transitions and fires alerts for drift or flapping.
|
// Alerter evaluates state transitions and fires alerts for drift or flapping.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Monitor watches container states and compares them to the registry,
|
// Monitor watches container states and compares them to the registry,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/config"
|
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
"git.wntrmute.dev/mc/mcp/internal/registry"
|
||||||
"git.wntrmute.dev/kyle/mcp/internal/runtime"
|
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func openTestDB(t *testing.T) *sql.DB {
|
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) Run(_ context.Context, _ runtime.ContainerSpec) error { return nil }
|
||||||
func (f *fakeRuntime) Stop(_ context.Context, _ string) 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) 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) {
|
func (f *fakeRuntime) Inspect(_ context.Context, _ string) (runtime.ContainerInfo, error) {
|
||||||
return runtime.ContainerInfo{}, nil
|
return runtime.ContainerInfo{}, nil
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import (
|
|||||||
"time"
|
"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.
|
// Component represents a component in the registry.
|
||||||
type Component struct {
|
type Component struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -20,6 +29,7 @@ type Component struct {
|
|||||||
Ports []string
|
Ports []string
|
||||||
Volumes []string
|
Volumes []string
|
||||||
Cmd []string
|
Cmd []string
|
||||||
|
Routes []Route
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt 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 {
|
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := setRoutes(tx, c.Service, c.Name, c.Routes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
@@ -84,6 +97,10 @@ func GetComponent(db *sql.DB, service, name string) (*Component, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.Routes, err = getRoutes(db, service, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
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.Ports, _ = getPorts(db, c.Service, c.Name)
|
||||||
c.Volumes, _ = getVolumes(db, c.Service, c.Name)
|
c.Volumes, _ = getVolumes(db, c.Service, c.Name)
|
||||||
c.Cmd, _ = getCmd(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)
|
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 {
|
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := setRoutes(tx, c.Service, c.Name, c.Routes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
@@ -274,3 +295,85 @@ func getCmd(db *sql.DB, service, component string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return cmd, rows.Err()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,4 +127,19 @@ var migrations = []string{
|
|||||||
CREATE INDEX IF NOT EXISTS idx_events_component_time
|
CREATE INDEX IF NOT EXISTS idx_events_component_time
|
||||||
ON events(service, component, timestamp);
|
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
|
||||||
|
);
|
||||||
|
`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ func CountEvents(db *sql.DB, service, component string, since time.Time) (int, e
|
|||||||
return count, nil
|
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.
|
// PruneEvents deletes events older than the given time.
|
||||||
func PruneEvents(db *sql.DB, before time.Time) (int64, error) {
|
func PruneEvents(db *sql.DB, before time.Time) (int64, error) {
|
||||||
res, err := db.Exec(
|
res, err := db.Exec(
|
||||||
|
|||||||
@@ -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) {
|
func TestEvents(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package runtime
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -49,6 +50,9 @@ func (p *Podman) BuildRunArgs(spec ContainerSpec) []string {
|
|||||||
for _, vol := range spec.Volumes {
|
for _, vol := range spec.Volumes {
|
||||||
args = append(args, "-v", vol)
|
args = append(args, "-v", vol)
|
||||||
}
|
}
|
||||||
|
for _, env := range spec.Env {
|
||||||
|
args = append(args, "-e", env)
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args, spec.Image)
|
args = append(args, spec.Image)
|
||||||
args = append(args, spec.Cmd...)
|
args = append(args, spec.Cmd...)
|
||||||
@@ -174,12 +178,46 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
|
|||||||
return info, nil
|
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.
|
// podmanPSEntry is a single entry from podman ps --format json.
|
||||||
type podmanPSEntry struct {
|
type podmanPSEntry struct {
|
||||||
Names []string `json:"Names"`
|
Names []string `json:"Names"`
|
||||||
Image string `json:"Image"`
|
Image string `json:"Image"`
|
||||||
State string `json:"State"`
|
State string `json:"State"`
|
||||||
Command string `json:"Command"`
|
Command []string `json:"Command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns information about all containers.
|
// List returns information about all containers.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ContainerSpec struct {
|
|||||||
Ports []string // "host:container" port mappings
|
Ports []string // "host:container" port mappings
|
||||||
Volumes []string // "host:container" volume mounts
|
Volumes []string // "host:container" volume mounts
|
||||||
Cmd []string // command and arguments
|
Cmd []string // command and arguments
|
||||||
|
Env []string // environment variables (KEY=VALUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerInfo describes the observed state of a running or stopped container.
|
// 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)
|
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 {
|
type Runtime interface {
|
||||||
Pull(ctx context.Context, image string) error
|
Pull(ctx context.Context, image string) error
|
||||||
Run(ctx context.Context, spec ContainerSpec) error
|
Run(ctx context.Context, spec ContainerSpec) error
|
||||||
@@ -41,6 +44,10 @@ type Runtime interface {
|
|||||||
Remove(ctx context.Context, name string) error
|
Remove(ctx context.Context, name string) error
|
||||||
Inspect(ctx context.Context, name string) (ContainerInfo, error)
|
Inspect(ctx context.Context, name string) (ContainerInfo, error)
|
||||||
List(ctx context.Context) ([]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.
|
// ExtractVersion parses the tag from an image reference.
|
||||||
|
|||||||
@@ -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) {
|
t.Run("cmd after image", func(t *testing.T) {
|
||||||
spec := ContainerSpec{
|
spec := ContainerSpec{
|
||||||
Name: "test-app",
|
Name: "test-app",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
toml "github.com/pelletier/go-toml/v2"
|
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.
|
// ServiceDef is the top-level TOML structure for a service definition file.
|
||||||
@@ -18,19 +18,38 @@ type ServiceDef struct {
|
|||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Node string `toml:"node"`
|
Node string `toml:"node"`
|
||||||
Active *bool `toml:"active,omitempty"`
|
Active *bool `toml:"active,omitempty"`
|
||||||
|
Path string `toml:"path,omitempty"`
|
||||||
|
Build *BuildDef `toml:"build,omitempty"`
|
||||||
Components []ComponentDef `toml:"components"`
|
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.
|
// ComponentDef describes a single container component within a service.
|
||||||
type ComponentDef struct {
|
type ComponentDef struct {
|
||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Image string `toml:"image"`
|
Image string `toml:"image"`
|
||||||
Network string `toml:"network,omitempty"`
|
Network string `toml:"network,omitempty"`
|
||||||
User string `toml:"user,omitempty"`
|
User string `toml:"user,omitempty"`
|
||||||
Restart string `toml:"restart,omitempty"`
|
Restart string `toml:"restart,omitempty"`
|
||||||
Ports []string `toml:"ports,omitempty"`
|
Ports []string `toml:"ports,omitempty"`
|
||||||
Volumes []string `toml:"volumes,omitempty"`
|
Volumes []string `toml:"volumes,omitempty"`
|
||||||
Cmd []string `toml:"cmd,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
|
// 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)
|
return fmt.Errorf("duplicate component name %q in service %q", c.Name, def.Name)
|
||||||
}
|
}
|
||||||
seen[c.Name] = true
|
seen[c.Name] = true
|
||||||
|
|
||||||
|
if err := validateRoutes(c.Name, def.Name, c.Routes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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.
|
// ToProto converts a ServiceDef to a proto ServiceSpec.
|
||||||
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
||||||
spec := &mcpv1.ServiceSpec{
|
spec := &mcpv1.ServiceSpec{
|
||||||
@@ -142,7 +196,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range def.Components {
|
for _, c := range def.Components {
|
||||||
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
|
cs := &mcpv1.ComponentSpec{
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Image: c.Image,
|
Image: c.Image,
|
||||||
Network: c.Network,
|
Network: c.Network,
|
||||||
@@ -151,7 +205,17 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec {
|
|||||||
Ports: c.Ports,
|
Ports: c.Ports,
|
||||||
Volumes: c.Volumes,
|
Volumes: c.Volumes,
|
||||||
Cmd: c.Cmd,
|
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
|
return spec
|
||||||
@@ -169,7 +233,7 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range spec.GetComponents() {
|
for _, c := range spec.GetComponents() {
|
||||||
def.Components = append(def.Components, ComponentDef{
|
cd := ComponentDef{
|
||||||
Name: c.GetName(),
|
Name: c.GetName(),
|
||||||
Image: c.GetImage(),
|
Image: c.GetImage(),
|
||||||
Network: c.GetNetwork(),
|
Network: c.GetNetwork(),
|
||||||
@@ -178,7 +242,17 @@ func FromProto(spec *mcpv1.ServiceSpec, node string) *ServiceDef {
|
|||||||
Ports: c.GetPorts(),
|
Ports: c.GetPorts(),
|
||||||
Volumes: c.GetVolumes(),
|
Volumes: c.GetVolumes(),
|
||||||
Cmd: c.GetCmd(),
|
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
|
return def
|
||||||
|
|||||||
@@ -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) {
|
func TestProtoConversion(t *testing.T) {
|
||||||
def := sampleDef()
|
def := sampleDef()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package mcp.v1;
|
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";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ service McpAgentService {
|
|||||||
// Adopt
|
// Adopt
|
||||||
rpc AdoptContainers(AdoptContainersRequest) returns (AdoptContainersResponse);
|
rpc AdoptContainers(AdoptContainersRequest) returns (AdoptContainersResponse);
|
||||||
|
|
||||||
|
// Purge
|
||||||
|
rpc PurgeComponent(PurgeRequest) returns (PurgeResponse);
|
||||||
|
|
||||||
// File transfer
|
// File transfer
|
||||||
rpc PushFile(PushFileRequest) returns (PushFileResponse);
|
rpc PushFile(PushFileRequest) returns (PushFileResponse);
|
||||||
rpc PullFile(PullFileRequest) returns (PullFileResponse);
|
rpc PullFile(PullFileRequest) returns (PullFileResponse);
|
||||||
@@ -33,6 +36,13 @@ service McpAgentService {
|
|||||||
|
|
||||||
// --- Service lifecycle ---
|
// --- 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 {
|
message ComponentSpec {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string image = 2;
|
string image = 2;
|
||||||
@@ -42,6 +52,8 @@ message ComponentSpec {
|
|||||||
repeated string ports = 6;
|
repeated string ports = 6;
|
||||||
repeated string volumes = 7;
|
repeated string volumes = 7;
|
||||||
repeated string cmd = 8;
|
repeated string cmd = 8;
|
||||||
|
repeated RouteSpec routes = 9;
|
||||||
|
repeated string env = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ServiceSpec {
|
message ServiceSpec {
|
||||||
@@ -234,3 +246,30 @@ message NodeStatusResponse {
|
|||||||
double cpu_usage_percent = 10;
|
double cpu_usage_percent = 10;
|
||||||
google.protobuf.Timestamp uptime_since = 11;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
331
vendor/git.wntrmute.dev/mc/mc-proxy/client/mcproxy/client.go
vendored
Normal file
331
vendor/git.wntrmute.dev/mc/mc-proxy/client/mcproxy/client.go
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
vendor/git.wntrmute.dev/mc/mc-proxy/client/mcproxy/doc.go
vendored
Normal file
41
vendor/git.wntrmute.dev/mc/mc-proxy/client/mcproxy/doc.go
vendored
Normal 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
|
||||||
1564
vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go
vendored
Normal file
1564
vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
511
vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin_grpc.pb.go
vendored
Normal file
511
vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin_grpc.pb.go
vendored
Normal 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
21
vendor/github.com/dustin/go-humanize/.travis.yml
generated
vendored
Normal 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
21
vendor/github.com/dustin/go-humanize/LICENSE
generated
vendored
Normal 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
124
vendor/github.com/dustin/go-humanize/README.markdown
generated
vendored
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Humane Units [](https://travis-ci.org/dustin/go-humanize) [](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
31
vendor/github.com/dustin/go-humanize/big.go
generated
vendored
Normal 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
189
vendor/github.com/dustin/go-humanize/bigbytes.go
generated
vendored
Normal 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
143
vendor/github.com/dustin/go-humanize/bytes.go
generated
vendored
Normal 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
116
vendor/github.com/dustin/go-humanize/comma.go
generated
vendored
Normal 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
41
vendor/github.com/dustin/go-humanize/commaf.go
generated
vendored
Normal 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
49
vendor/github.com/dustin/go-humanize/ftoa.go
generated
vendored
Normal 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
8
vendor/github.com/dustin/go-humanize/humanize.go
generated
vendored
Normal 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
192
vendor/github.com/dustin/go-humanize/number.go
generated
vendored
Normal 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###,##" => "12 345,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
25
vendor/github.com/dustin/go-humanize/ordinals.go
generated
vendored
Normal 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
127
vendor/github.com/dustin/go-humanize/si.go
generated
vendored
Normal 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
117
vendor/github.com/dustin/go-humanize/times.go
generated
vendored
Normal 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
41
vendor/github.com/google/uuid/CHANGELOG.md
generated
vendored
Normal 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
26
vendor/github.com/google/uuid/CONTRIBUTING.md
generated
vendored
Normal 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
9
vendor/github.com/google/uuid/CONTRIBUTORS
generated
vendored
Normal 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
27
vendor/github.com/google/uuid/LICENSE
generated
vendored
Normal 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
21
vendor/github.com/google/uuid/README.md
generated
vendored
Normal 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
|
||||||
|
[](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
80
vendor/github.com/google/uuid/dce.go
generated
vendored
Normal 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
12
vendor/github.com/google/uuid/doc.go
generated
vendored
Normal 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
59
vendor/github.com/google/uuid/hash.go
generated
vendored
Normal 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
38
vendor/github.com/google/uuid/marshal.go
generated
vendored
Normal 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
90
vendor/github.com/google/uuid/node.go
generated
vendored
Normal 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
12
vendor/github.com/google/uuid/node_js.go
generated
vendored
Normal 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
33
vendor/github.com/google/uuid/node_net.go
generated
vendored
Normal 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
Reference in New Issue
Block a user