1c7faf37
feat(mcp): an LLM publishes a VM's port
a73x 2026-08-06 10:55
Commit message
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 1 | # eitri-mcp: Claude ↔ eitri VMs | 1 | # eitri-mcp: Claude ↔ eitri VMs |
| 2 | 2 | ||
| 3 | `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude seven | 3 | `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude ten |
| 4 | explicit tools for creating and controlling VMs on an eitri fleet. It is an | 4 | explicit tools for creating and controlling VMs on an eitri fleet. It is an |
| 5 | API client of the eitri control plane plus SSH—it embeds no control-plane | 5 | API client of the eitri control plane plus SSH—it embeds no control-plane |
| 6 | or agent code. | 6 | or agent code. |
| @@ -15,6 +15,9 @@ or agent code. | |||
| 15 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | | 15 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | |
| 16 | | `vm_write_file` | Write content to a path in a VM over SFTP (parent dirs created). | | 16 | | `vm_write_file` | Write content to a path in a VM over SFTP (parent dirs created). | |
| 17 | | `vm_read_file` | Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged). | | 17 | | `vm_read_file` | Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged). | |
| 18 | | `vm_expose` | Publish a guest TCP port on the VM's host; returns the exposure id, both ports, the address to dial and the listener's state. Omit `host_port` to allocate one from 30000–32767. | | ||
| 19 | | `vm_exposures` | List a VM's published ports, same shape. | | ||
| 20 | | `vm_unexpose` | Stop publishing a guest port (`host_port` disambiguates when one guest port is published twice). | | ||
| 18 | | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | | 21 | | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | |
| 19 | 22 | ||
| 20 | Deliberately absent: any host or fleet-level operation (enroll, decommission, | 23 | Deliberately absent: any host or fleet-level operation (enroll, decommission, |
| @@ -101,9 +104,11 @@ error. | |||
| 101 | is instructed to treat it as explicit-only, never automatic cleanup. | 104 | is instructed to treat it as explicit-only, never automatic cleanup. |
| 102 | - The tool surface has no host or fleet operations by design—see the tools | 105 | - The tool surface has no host or fleet operations by design—see the tools |
| 103 | table above. | 106 | table above. |
| 104 | - Service exposure (ports, DNS, TLS certs, routing) is out of scope: the | 107 | - A published port is **unauthenticated**. `vm_expose` binds a host port and |
| 105 | tools hand back a host and an `ssh` command; getting a service reachable | 108 | pipes it to the guest; whoever can reach the host on that port reaches the |
| 106 | from outside the VM is the caller's business. | 109 | service. The tool descriptions say so, so the model treats publishing as a |
| 110 | deliberate act. DNS, TLS certs and routing remain out of scope—the tools | ||
| 111 | hand back a host address and a port. | ||
| 107 | - The claude.ai connector (streamable HTTP transport + auth + ingress) is | 112 | - The claude.ai connector (streamable HTTP transport + auth + ingress) is |
| 108 | phase 2 and not built—today's transport is stdio, for Claude Code only. | 113 | phase 2 and not built—today's transport is stdio, for Claude Code only. |
| 109 | Phase 2's VM access is expected to reuse the same short-lived-certificate | 114 | Phase 2's VM access is expected to reuse the same short-lived-certificate |
internal/mcpserver/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -55,8 +55,9 @@ func run(cfgPath string) error { | |||
| 55 | // own user CA, and verifies both hops' host certs against the eitri host CA. | 55 | // own user CA, and verifies both hops' host certs against the eitri host CA. |
| 56 | // On first gate use it derives the caller's tenant (an empty cfg.Tenant) and | 56 | // On first gate use it derives the caller's tenant (an empty cfg.Tenant) and |
| 57 | // registers this user CA's public key so VMs trust those certs — all backed by | 57 | // registers this user CA's public key so VMs trust those certs — all backed by |
| 58 | // the same API client. | 58 | // the same API client. The upload carries a per-install label so the console |
| 59 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token} | 59 | // shows WHICH install holds signing power, and revocation stays per-install. |
| 60 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token, UserCALabel: caLabel()} | ||
| 60 | tools := &Tools{ | 61 | tools := &Tools{ |
| 61 | API: API{Client: api}, | 62 | API: API{Client: api}, |
| 62 | Runner: NewRunner(RunnerConfig{ | 63 | Runner: NewRunner(RunnerConfig{ |
| @@ -77,6 +78,9 @@ func run(cfgPath string) error { | |||
| 77 | register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec) | 78 | register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec) |
| 78 | register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile) | 79 | register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile) |
| 79 | register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile) | 80 | register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile) |
| 81 | register(server, "vm_expose", "Publish a VM's guest TCP port on its host and return the address to dial. Omit host_port to allocate one from 30000-32767. WARNING: a published port has NO AUTHENTICATION in front of it — whoever can reach the host on that port reaches the service. Publish only what is meant to be reachable.", tools.VMExpose) | ||
| 82 | register(server, "vm_exposures", "List a VM's published ports, with the address to dial and each listener's state. These ports are unauthenticated.", tools.VMExposures) | ||
| 83 | register(server, "vm_unexpose", "Stop publishing a VM's guest port; the host closes the listener.", tools.VMUnexpose) | ||
| 80 | register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy) | 84 | register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy) |
| 81 | 85 | ||
| 82 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | 86 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| @@ -88,6 +92,16 @@ func run(cfgPath string) error { | |||
| 88 | return server.Run(ctx, &mcp.StdioTransport{}) | 92 | return server.Run(ctx, &mcp.StdioTransport{}) |
| 89 | } | 93 | } |
| 90 | 94 | ||
| 95 | // caLabel names this install's user CA in the tenant's CA list. Hostname is | ||
| 96 | // the natural per-install discriminator; a host that can't name itself still | ||
| 97 | // gets the binary's name rather than an anonymous row. | ||
| 98 | func caLabel() string { | ||
| 99 | if hn, err := os.Hostname(); err == nil && hn != "" { | ||
| 100 | return "eitri-mcp@" + hn | ||
| 101 | } | ||
| 102 | return "eitri-mcp" | ||
| 103 | } | ||
| 104 | |||
| 91 | // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file | 105 | // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file |
| 92 | // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a | 106 | // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a |
| 93 | // concurrent creator can't clobber it and a symlink can't be followed), and | 107 | // concurrent creator can't clobber it and a symlink can't be followed), and |
internal/mcpserver/cli_test.go
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "context" | 4 | "context" |
| 5 | "os" | 5 | "os" |
| 6 | "path/filepath" | 6 | "path/filepath" |
| 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" |
| 8 | 9 | ||
| 9 | "github.com/modelcontextprotocol/go-sdk/mcp" | 10 | "github.com/modelcontextprotocol/go-sdk/mcp" |
| @@ -31,6 +32,17 @@ func TestLoadOrCreateCACreatesThenLoads(t *testing.T) { | |||
| 31 | "an existing key must load back unchanged, not be regenerated") | 32 | "an existing key must load back unchanged, not be regenerated") |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 35 | // TestCALabelNamesTheInstall pins the audit contract: the CA this client | ||
| 36 | // uploads is never an anonymous row — it carries the binary's name, and the | ||
| 37 | // hostname when one exists. | ||
| 38 | func TestCALabelNamesTheInstall(t *testing.T) { | ||
| 39 | label := caLabel() | ||
| 40 | assert.True(t, strings.HasPrefix(label, "eitri-mcp"), "label %q must name the client", label) | ||
| 41 | if hn, err := os.Hostname(); err == nil && hn != "" { | ||
| 42 | assert.Equal(t, "eitri-mcp@"+hn, label) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 34 | // TestLoadOrCreateCARejectsGarbage rejects an unparseable key file without | 46 | // TestLoadOrCreateCARejectsGarbage rejects an unparseable key file without |
| 35 | // leaking its bytes in the error. | 47 | // leaking its bytes in the error. |
| 36 | func TestLoadOrCreateCARejectsGarbage(t *testing.T) { | 48 | func TestLoadOrCreateCARejectsGarbage(t *testing.T) { |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,9 @@ import ( | |||
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "io/fs" | 7 | "io/fs" |
| 8 | "net" | ||
| 9 | "strconv" | ||
| 10 | "strings" | ||
| 8 | "time" | 11 | "time" |
| 9 | 12 | ||
| 10 | "github.com/a73x/eitri/internal/gateclient" | 13 | "github.com/a73x/eitri/internal/gateclient" |
| @@ -19,6 +22,9 @@ type api interface { | |||
| 19 | CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | 22 | CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) |
| 20 | DeleteVM(ctx context.Context, id string) error | 23 | DeleteVM(ctx context.Context, id string) error |
| 21 | FirstOnlineHost(ctx context.Context) (client.Host, error) | 24 | FirstOnlineHost(ctx context.Context) (client.Host, error) |
| 25 | CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) | ||
| 26 | ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error) | ||
| 27 | DeleteExposure(ctx context.Context, id string) error | ||
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | type runner interface { | 30 | type runner interface { |
| @@ -60,7 +66,7 @@ var ( | |||
| 60 | _ gateclient.CertAuthority = (*client.Client)(nil) | 66 | _ gateclient.CertAuthority = (*client.Client)(nil) |
| 61 | ) | 67 | ) |
| 62 | 68 | ||
| 63 | // Tools implements the seven eitri-mcp tools over the API and SSH seams. | 69 | // Tools implements the ten eitri-mcp tools over the API and SSH seams. |
| 64 | type Tools struct { | 70 | type Tools struct { |
| 65 | API api | 71 | API api |
| 66 | Runner runner | 72 | Runner runner |
| @@ -417,6 +423,151 @@ func (t *Tools) VMReadFile(ctx context.Context, in VMReadFileIn) (VMReadFileOut, | |||
| 417 | return VMReadFileOut{Content: string(data), Truncated: truncated}, nil | 423 | return VMReadFileOut{Content: string(data), Truncated: truncated}, nil |
| 418 | } | 424 | } |
| 419 | 425 | ||
| 426 | // ── vm_expose / vm_exposures / vm_unexpose ─────────────────────────────────── | ||
| 427 | |||
| 428 | // ExposureView is the MCP view of one published port: the id needed to talk | ||
| 429 | // about it, both ends of the pipe, the address to dial, and what the host says | ||
| 430 | // its listener is doing. Address is empty until the host has reported its | ||
| 431 | // uplink — a grant exists before there is anywhere to name. | ||
| 432 | type ExposureView struct { | ||
| 433 | ID string `json:"id"` | ||
| 434 | GuestPort int64 `json:"guest_port"` | ||
| 435 | HostPort int64 `json:"host_port"` | ||
| 436 | Address string `json:"address,omitempty"` | ||
| 437 | State string `json:"state"` | ||
| 438 | Reason string `json:"reason,omitempty"` | ||
| 439 | } | ||
| 440 | |||
| 441 | // exposureView folds an API exposure into the MCP view, joining the host | ||
| 442 | // address and host port into one dialable string. | ||
| 443 | func exposureView(e client.Exposure) ExposureView { | ||
| 444 | v := ExposureView{ | ||
| 445 | ID: e.ID, GuestPort: e.GuestPort, HostPort: e.HostPort, | ||
| 446 | State: e.State, Reason: e.Reason, | ||
| 447 | } | ||
| 448 | if e.HostAddr != "" { | ||
| 449 | v.Address = net.JoinHostPort(e.HostAddr, strconv.FormatInt(e.HostPort, 10)) | ||
| 450 | } | ||
| 451 | return v | ||
| 452 | } | ||
| 453 | |||
| 454 | type VMExposeIn struct { | ||
| 455 | VM string `json:"vm" jsonschema:"VM id or exact name"` | ||
| 456 | GuestPort int64 `json:"guest_port" jsonschema:"TCP port the service listens on inside the guest"` | ||
| 457 | HostPort int64 `json:"host_port,omitempty" jsonschema:"port to bind on the host; omit to allocate one from 30000-32767, a named port must be >= 1024"` | ||
| 458 | } | ||
| 459 | |||
| 460 | type VMExposeOut struct { | ||
| 461 | Exposure ExposureView `json:"exposure"` | ||
| 462 | } | ||
| 463 | |||
| 464 | // VMExpose publishes a guest port on the VM's host. The VM's lifecycle is not | ||
| 465 | // checked: an exposure is a durable grant the host binds when it next | ||
| 466 | // converges, so publishing a port on a VM that is still booting is legitimate. | ||
| 467 | func (t *Tools) VMExpose(ctx context.Context, in VMExposeIn) (VMExposeOut, error) { | ||
| 468 | vm, err := t.resolveVM(ctx, in.VM) | ||
| 469 | if err != nil { | ||
| 470 | return VMExposeOut{}, err | ||
| 471 | } | ||
| 472 | e, err := t.API.CreateExposure(ctx, vm.ID, in.GuestPort, in.HostPort) | ||
| 473 | if err != nil { | ||
| 474 | return VMExposeOut{}, fmt.Errorf("expose vm %s port %d: %w", vm.Name, in.GuestPort, err) | ||
| 475 | } | ||
| 476 | return VMExposeOut{Exposure: exposureView(e)}, nil | ||
| 477 | } | ||
| 478 | |||
| 479 | type VMExposuresIn struct { | ||
| 480 | VM string `json:"vm" jsonschema:"VM id or exact name"` | ||
| 481 | } | ||
| 482 | |||
| 483 | type VMExposuresOut struct { | ||
| 484 | Exposures []ExposureView `json:"exposures"` | ||
| 485 | } | ||
| 486 | |||
| 487 | func (t *Tools) VMExposures(ctx context.Context, in VMExposuresIn) (VMExposuresOut, error) { | ||
| 488 | vm, err := t.resolveVM(ctx, in.VM) | ||
| 489 | if err != nil { | ||
| 490 | return VMExposuresOut{}, err | ||
| 491 | } | ||
| 492 | exps, err := t.API.ListExposures(ctx, vm.ID) | ||
| 493 | if err != nil { | ||
| 494 | return VMExposuresOut{}, fmt.Errorf("list exposures for vm %s: %w", vm.Name, err) | ||
| 495 | } | ||
| 496 | out := VMExposuresOut{Exposures: make([]ExposureView, 0, len(exps))} | ||
| 497 | for _, e := range exps { | ||
| 498 | out.Exposures = append(out.Exposures, exposureView(e)) | ||
| 499 | } | ||
| 500 | return out, nil | ||
| 501 | } | ||
| 502 | |||
| 503 | type VMUnexposeIn struct { | ||
| 504 | VM string `json:"vm" jsonschema:"VM id or exact name"` | ||
| 505 | GuestPort int64 `json:"guest_port" jsonschema:"the guest port to stop publishing"` | ||
| 506 | HostPort int64 `json:"host_port,omitempty" jsonschema:"host port, only needed when the same guest port is published more than once"` | ||
| 507 | } | ||
| 508 | |||
| 509 | type VMUnexposeOut struct { | ||
| 510 | ID string `json:"id"` | ||
| 511 | GuestPort int64 `json:"guest_port"` | ||
| 512 | HostPort int64 `json:"host_port"` | ||
| 513 | } | ||
| 514 | |||
| 515 | // VMUnexpose revokes one of a VM's published ports. The caller names the guest | ||
| 516 | // port it published, not the exposure id it never saw, so the id is resolved | ||
| 517 | // from the VM's own list. | ||
| 518 | func (t *Tools) VMUnexpose(ctx context.Context, in VMUnexposeIn) (VMUnexposeOut, error) { | ||
| 519 | vm, err := t.resolveVM(ctx, in.VM) | ||
| 520 | if err != nil { | ||
| 521 | return VMUnexposeOut{}, err | ||
| 522 | } | ||
| 523 | exps, err := t.API.ListExposures(ctx, vm.ID) | ||
| 524 | if err != nil { | ||
| 525 | return VMUnexposeOut{}, fmt.Errorf("list exposures for vm %s: %w", vm.Name, err) | ||
| 526 | } | ||
| 527 | e, err := matchExposure(exps, vm.Name, in.GuestPort, in.HostPort) | ||
| 528 | if err != nil { | ||
| 529 | return VMUnexposeOut{}, err | ||
| 530 | } | ||
| 531 | if err := t.API.DeleteExposure(ctx, e.ID); err != nil { | ||
| 532 | return VMUnexposeOut{}, fmt.Errorf("unexpose vm %s port %d: %w", vm.Name, in.GuestPort, err) | ||
| 533 | } | ||
| 534 | return VMUnexposeOut{ID: e.ID, GuestPort: e.GuestPort, HostPort: e.HostPort}, nil | ||
| 535 | } | ||
| 536 | |||
| 537 | // matchExposure picks the one exposure of vmName publishing guestPort. Nothing | ||
| 538 | // stops a guest port being published on two host ports, so hostPort (0 = any) | ||
| 539 | // disambiguates; an ambiguous match refuses rather than guessing which | ||
| 540 | // listener to close. Errors name what IS published, so the model can correct | ||
| 541 | // itself without a second listing call. | ||
| 542 | func matchExposure(exps []client.Exposure, vmName string, guestPort, hostPort int64) (client.Exposure, error) { | ||
| 543 | var matches []client.Exposure | ||
| 544 | for _, e := range exps { | ||
| 545 | if e.GuestPort == guestPort && (hostPort == 0 || e.HostPort == hostPort) { | ||
| 546 | matches = append(matches, e) | ||
| 547 | } | ||
| 548 | } | ||
| 549 | switch len(matches) { | ||
| 550 | case 1: | ||
| 551 | return matches[0], nil | ||
| 552 | case 0: | ||
| 553 | return client.Exposure{}, fmt.Errorf("vm %s publishes no guest port %d (published: %s)", vmName, guestPort, describeExposures(exps)) | ||
| 554 | default: | ||
| 555 | return client.Exposure{}, fmt.Errorf("vm %s publishes guest port %d more than once (%s); name host_port to pick one", vmName, guestPort, describeExposures(matches)) | ||
| 556 | } | ||
| 557 | } | ||
| 558 | |||
| 559 | // describeExposures renders exposures as "guest:host" pairs for an error. | ||
| 560 | func describeExposures(exps []client.Exposure) string { | ||
| 561 | if len(exps) == 0 { | ||
| 562 | return "none" | ||
| 563 | } | ||
| 564 | parts := make([]string, 0, len(exps)) | ||
| 565 | for _, e := range exps { | ||
| 566 | parts = append(parts, fmt.Sprintf("%d:%d", e.GuestPort, e.HostPort)) | ||
| 567 | } | ||
| 568 | return strings.Join(parts, ", ") | ||
| 569 | } | ||
| 570 | |||
| 420 | // ── vm_destroy ─────────────────────────────────────────────────────────────── | 571 | // ── vm_destroy ─────────────────────────────────────────────────────────────── |
| 421 | 572 | ||
| 422 | type VMDestroyIn struct { | 573 | type VMDestroyIn struct { |
internal/mcpserver/tools_test.go
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,9 @@ type fakeToolsAPI struct { | |||
| 24 | // number of leading ListVMs calls that fail (control-plane blip); after | 24 | // number of leading ListVMs calls that fail (control-plane blip); after |
| 25 | // they are exhausted the phases sequence takes over. | 25 | // they are exhausted the phases sequence takes over. |
| 26 | listErrs int | 26 | listErrs int |
| 27 | // exposures the fake serves per VM id, and the exposure ids revoked. | ||
| 28 | exposures map[string][]client.Exposure | ||
| 29 | revoked []string | ||
| 27 | } | 30 | } |
| 28 | 31 | ||
| 29 | func (f *fakeToolsAPI) ListVMs(ctx context.Context) ([]client.VM, error) { | 32 | func (f *fakeToolsAPI) ListVMs(ctx context.Context) ([]client.VM, error) { |
| @@ -57,6 +60,31 @@ func (f *fakeToolsAPI) FirstOnlineHost(ctx context.Context) (client.Host, error) | |||
| 57 | return client.Host{ID: "h1", Name: "mewtwo", Online: true}, nil | 60 | return client.Host{ID: "h1", Name: "mewtwo", Online: true}, nil |
| 58 | } | 61 | } |
| 59 | 62 | ||
| 63 | // CreateExposure mirrors the control plane: host port 0 is allocated from the | ||
| 64 | // reserved range, and the host address comes back with the grant. | ||
| 65 | func (f *fakeToolsAPI) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) { | ||
| 66 | if hostPort == 0 { | ||
| 67 | hostPort = 30000 + int64(len(f.exposures[vmID])) | ||
| 68 | } | ||
| 69 | e := client.Exposure{ | ||
| 70 | ID: fmt.Sprintf("x-%d", len(f.exposures[vmID])+1), VMID: vmID, HostID: "h1", | ||
| 71 | GuestPort: guestPort, HostPort: hostPort, HostAddr: "10.0.0.4", | ||
| 72 | Protocol: "tcp", Scope: "host", State: "pending", | ||
| 73 | } | ||
| 74 | if f.exposures == nil { | ||
| 75 | f.exposures = map[string][]client.Exposure{} | ||
| 76 | } | ||
| 77 | f.exposures[vmID] = append(f.exposures[vmID], e) | ||
| 78 | return e, nil | ||
| 79 | } | ||
| 80 | func (f *fakeToolsAPI) ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error) { | ||
| 81 | return f.exposures[vmID], nil | ||
| 82 | } | ||
| 83 | func (f *fakeToolsAPI) DeleteExposure(ctx context.Context, id string) error { | ||
| 84 | f.revoked = append(f.revoked, id) | ||
| 85 | return nil | ||
| 86 | } | ||
| 87 | |||
| 60 | type fakeRunner struct { | 88 | type fakeRunner struct { |
| 61 | execs []string | 89 | execs []string |
| 62 | out ExecResult | 90 | out ExecResult |
| @@ -347,6 +375,119 @@ func TestDestroyRequiresExactMatch(t *testing.T) { | |||
| 347 | assert.Equal(t, []string{"abc123"}, api.deleted) | 375 | assert.Equal(t, []string{"abc123"}, api.deleted) |
| 348 | } | 376 | } |
| 349 | 377 | ||
| 378 | func TestExposePublishesGuestPortWithDialAddress(t *testing.T) { | ||
| 379 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}} | ||
| 380 | tl := newTestTools(api, &fakeRunner{}) | ||
| 381 | |||
| 382 | out, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 8080}) | ||
| 383 | require.NoError(t, err) | ||
| 384 | assert.Equal(t, int64(8080), out.Exposure.GuestPort) | ||
| 385 | assert.Equal(t, int64(30000), out.Exposure.HostPort, "an omitted host port is allocated from the reserved range") | ||
| 386 | assert.Equal(t, "10.0.0.4:30000", out.Exposure.Address, "the address joins the host's own address with the bound port") | ||
| 387 | assert.Equal(t, "pending", out.Exposure.State) | ||
| 388 | |||
| 389 | // A named host port is passed through to the control plane unchanged. | ||
| 390 | out, err = tl.VMExpose(t.Context(), VMExposeIn{VM: "abc123", GuestPort: 5432, HostPort: 31500}) | ||
| 391 | require.NoError(t, err) | ||
| 392 | assert.Equal(t, int64(31500), out.Exposure.HostPort) | ||
| 393 | assert.Equal(t, "10.0.0.4:31500", out.Exposure.Address) | ||
| 394 | } | ||
| 395 | |||
| 396 | func TestExposeUnknownVM(t *testing.T) { | ||
| 397 | tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{}) | ||
| 398 | _, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "nope", GuestPort: 80}) | ||
| 399 | assert.ErrorContains(t, err, "no VM with id or name") | ||
| 400 | } | ||
| 401 | |||
| 402 | func TestExposuresListsPublishedPorts(t *testing.T) { | ||
| 403 | api := &fakeToolsAPI{ | ||
| 404 | vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}, | ||
| 405 | // A host that has not reported its uplink yields no address: the grant | ||
| 406 | // exists before there is anywhere to name. | ||
| 407 | exposures: map[string][]client.Exposure{"abc123": { | ||
| 408 | {ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4", State: "active"}, | ||
| 409 | {ID: "x-2", GuestPort: 443, HostPort: 30001, State: "failed", Reason: "address already in use"}, | ||
| 410 | }}, | ||
| 411 | } | ||
| 412 | tl := newTestTools(api, &fakeRunner{}) | ||
| 413 | |||
| 414 | out, err := tl.VMExposures(t.Context(), VMExposuresIn{VM: "web-1"}) | ||
| 415 | require.NoError(t, err) | ||
| 416 | require.Len(t, out.Exposures, 2) | ||
| 417 | assert.Equal(t, "10.0.0.4:30000", out.Exposures[0].Address) | ||
| 418 | assert.Equal(t, "active", out.Exposures[0].State) | ||
| 419 | assert.Empty(t, out.Exposures[1].Address, "no host address yet means no address to dial") | ||
| 420 | assert.Equal(t, "address already in use", out.Exposures[1].Reason) | ||
| 421 | |||
| 422 | // A VM that publishes nothing lists nothing, not an error. | ||
| 423 | api.vms = append(api.vms, client.VM{ID: "def456", Name: "quiet-1", Lifecycle: "ready"}) | ||
| 424 | out, err = tl.VMExposures(t.Context(), VMExposuresIn{VM: "quiet-1"}) | ||
| 425 | require.NoError(t, err) | ||
| 426 | assert.Empty(t, out.Exposures) | ||
| 427 | } | ||
| 428 | |||
| 429 | func TestUnexposeResolvesTheExposureByGuestPort(t *testing.T) { | ||
| 430 | api := &fakeToolsAPI{ | ||
| 431 | vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}, | ||
| 432 | exposures: map[string][]client.Exposure{"abc123": { | ||
| 433 | {ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4"}, | ||
| 434 | {ID: "x-2", GuestPort: 443, HostPort: 30001, HostAddr: "10.0.0.4"}, | ||
| 435 | }}, | ||
| 436 | } | ||
| 437 | tl := newTestTools(api, &fakeRunner{}) | ||
| 438 | |||
| 439 | out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 443}) | ||
| 440 | require.NoError(t, err) | ||
| 441 | assert.Equal(t, "x-2", out.ID, "the caller names a guest port; the id is resolved for it") | ||
| 442 | assert.Equal(t, int64(443), out.GuestPort) | ||
| 443 | assert.Equal(t, int64(30001), out.HostPort) | ||
| 444 | assert.Equal(t, []string{"x-2"}, api.revoked) | ||
| 445 | } | ||
| 446 | |||
| 447 | func TestUnexposeUnpublishedPortNamesWhatIsPublished(t *testing.T) { | ||
| 448 | api := &fakeToolsAPI{ | ||
| 449 | vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}, | ||
| 450 | exposures: map[string][]client.Exposure{"abc123": { | ||
| 451 | {ID: "x-1", GuestPort: 80, HostPort: 30000}, | ||
| 452 | }}, | ||
| 453 | } | ||
| 454 | tl := newTestTools(api, &fakeRunner{}) | ||
| 455 | |||
| 456 | _, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 8080}) | ||
| 457 | require.Error(t, err) | ||
| 458 | assert.ErrorContains(t, err, "publishes no guest port 8080") | ||
| 459 | assert.ErrorContains(t, err, "80:30000", "the error names what IS published") | ||
| 460 | assert.Empty(t, api.revoked, "a miss revokes nothing") | ||
| 461 | |||
| 462 | // A VM publishing nothing at all says so rather than naming an empty set. | ||
| 463 | api.exposures = nil | ||
| 464 | _, err = tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80}) | ||
| 465 | assert.ErrorContains(t, err, "none") | ||
| 466 | } | ||
| 467 | |||
| 468 | func TestUnexposeAmbiguousGuestPortRefusesUntilHostPortNamed(t *testing.T) { | ||
| 469 | // Nothing stops one guest port being published on two host ports. Closing | ||
| 470 | // one of them is a guess, so refuse and say how to choose. | ||
| 471 | api := &fakeToolsAPI{ | ||
| 472 | vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}, | ||
| 473 | exposures: map[string][]client.Exposure{"abc123": { | ||
| 474 | {ID: "x-1", GuestPort: 80, HostPort: 30000}, | ||
| 475 | {ID: "x-2", GuestPort: 80, HostPort: 31000}, | ||
| 476 | }}, | ||
| 477 | } | ||
| 478 | tl := newTestTools(api, &fakeRunner{}) | ||
| 479 | |||
| 480 | _, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80}) | ||
| 481 | require.Error(t, err) | ||
| 482 | assert.ErrorContains(t, err, "name host_port") | ||
| 483 | assert.Empty(t, api.revoked, "an ambiguous match closes no listener") | ||
| 484 | |||
| 485 | out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80, HostPort: 31000}) | ||
| 486 | require.NoError(t, err) | ||
| 487 | assert.Equal(t, "x-2", out.ID) | ||
| 488 | assert.Equal(t, []string{"x-2"}, api.revoked) | ||
| 489 | } | ||
| 490 | |||
| 350 | func TestWriteAndReadFileTools(t *testing.T) { | 491 | func TestWriteAndReadFileTools(t *testing.T) { |
| 351 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}} | 492 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}} |
| 352 | run := &fakeRunner{} | 493 | run := &fakeRunner{} |