a73x

1c7faf37

feat(mcp): an LLM publishes a VM's port

a73x   2026-08-06 10:55

Commit message
feat(mcp): an LLM publishes a VM's port

An LLM driving eitri can create a VM and run software in it, and now it can
make that software reachable. Three tools sit on the exposure API: vm_expose
publishes a guest TCP port on the VM's host and hands back the address to
dial, vm_exposures lists what a VM already publishes, and vm_unexpose closes
one again. All three speak in VM names and guest ports — the caller never
holds an exposure id, so vm_unexpose resolves one from the VM's own list, and
refuses rather than guesses when a guest port is published twice.

The tool descriptions tell the model the operational truth: nothing
authenticates a published port. Whoever can reach the host on that port
reaches the service, so publishing is a deliberate act, not a convenience.

The client's self-registered user CA now uploads under a per-install label
(eitri-mcp@<hostname>), so the tenant's CA list shows which install holds
signing power and revocation stays per-install.

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{}