a73x

internal/server/api/client/client.go

Ref:   Size: 13.0 KiB   History

// Package client is THE Go client for the eitri control-plane HTTP API — the
// one consumer every in-repo caller (MCP server, smoke gate, CLI) goes
// through. Its method set is exactly what those consumers use, nothing more:
// a new endpoint call starts by adding a method here (an arch fitness rule
// enforces that no other package speaks the API's HTTP directly).
//
// The wire shapes come from internal/server/api/types; the ones consumers
// need are re-exported as aliases so callers import only this package.
package client

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"golang.org/x/crypto/ssh"

	"github.com/a73x/eitri/internal/server/api/types"
)

// Wire-contract aliases, so consumers don't import the types package.
type (
	Host                   = types.Host
	VM                     = types.VM
	CreateVMRequest        = types.CreateVMRequest
	CreateVMResponse       = types.CreateVMResponse
	PatchVMRequest         = types.PatchVMRequest
	Me                     = types.Me
	CreateAPITokenResponse = types.CreateAPITokenResponse
	UserCA                 = types.UserCA
	Exposure               = types.Exposure
	CreateExposureRequest  = types.CreateExposureRequest
	DelegationChallenge    = types.DelegationChallenge
	DelegationRequest      = types.DelegationRequest
	Delegation             = types.Delegation

	VolumeClaim              = types.VolumeClaim
	CreateVolumeClaimRequest = types.CreateVolumeClaimRequest
)

// DefaultDiskGB is the wire contract's default disk size, re-exported for the
// reason the aliases above exist: the client is the only door to the contract
// package (R11), so a consumer that needs one of the API's defaults reaches it
// here rather than keeping a copy of the number. Its siblings, DefaultVCPUs and
// DefaultMemMB, join it when something asks for them.
const DefaultDiskGB = types.DefaultDiskGB

// Client calls the eitri API at BaseURL, authenticating with Token (sent as a
// Bearer header when non-empty). The zero value plus a BaseURL is a working
// client; a nil HTTP falls back to a 30s-timeout http.Client. UserCALabel, if
// set, labels user-CA uploads.
type Client struct {
	BaseURL     string
	Token       string
	UserCALabel string
	HTTP        *http.Client
}

// Error is the typed failure for any non-2xx API response, carrying the
// request identity, the HTTP status, and the (truncated) response body.
type Error struct {
	Method string
	Path   string
	Status int
	Body   string
}

func (e *Error) Error() string {
	return fmt.Sprintf("client: %s %s: %d: %s", e.Method, e.Path, e.Status, e.Body)
}

// gateOffError is the 404-on-/api/v1/ssh-ca translation: its message says the
// gate is off (never "404"), while still unwrapping to the underlying *Error
// so errors.As callers can see the status.
type gateOffError struct{ cause *Error }

func (e *gateOffError) Error() string { return "ssh-ca gate is not enabled on this eitri server" }
func (e *gateOffError) Unwrap() error { return e.cause }

// do performs one API round trip: method+path against BaseURL, JSON-encoding
// in when non-nil, decoding the response into out when non-nil. Non-2xx
// responses become a *Error. The URL is built by plain concatenation — paths
// here are always well-formed absolute /api/... strings whose variable
// segments the callers have already PathEscaped.
func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
	var body io.Reader
	if in != nil {
		b, err := json.Marshal(in)
		if err != nil {
			return fmt.Errorf("client: %s %s: encoding request: %w", method, path, err)
		}
		body = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, body)
	if err != nil {
		return fmt.Errorf("client: %s %s: %w", method, path, err)
	}
	if in != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if c.Token != "" {
		req.Header.Set("Authorization", "Bearer "+c.Token)
	}
	httpc := c.HTTP
	if httpc == nil {
		httpc = &http.Client{Timeout: 30 * time.Second}
	}
	resp, err := httpc.Do(req)
	if err != nil {
		return fmt.Errorf("client: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return &Error{Method: method, Path: path, Status: resp.StatusCode, Body: strings.TrimSpace(string(msg))}
	}
	if out == nil {
		return nil
	}
	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
		return fmt.Errorf("client: %s %s: decoding response: %w", method, path, err)
	}
	return nil
}

// ListHosts returns the fleet's hosts.
func (c *Client) ListHosts(ctx context.Context) ([]Host, error) {
	var hosts []Host
	return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts)
}

// ListVMs returns every VM the caller's tenant can see.
func (c *Client) ListVMs(ctx context.Context) ([]VM, error) {
	var vms []VM
	return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms)
}

// CreateVM asks the server to create a VM; the server fills one-click
// defaults for everything req leaves zero except host_id.
func (c *Client) CreateVM(ctx context.Context, req CreateVMRequest) (CreateVMResponse, error) {
	var out CreateVMResponse
	return out, c.do(ctx, http.MethodPost, "/api/v1/vms", req, &out)
}

// DeleteVM marks the VM for teardown; the agent reaps it asynchronously.
func (c *Client) DeleteVM(ctx context.Context, id string) error {
	return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil)
}

// PatchVM sets the VM's desired power state ("running" or "stopped"); the
// agent actuates it asynchronously — poll ActualPower for the outcome.
func (c *Client) PatchVM(ctx context.Context, id, powerState string) error {
	return c.do(ctx, http.MethodPatch, "/api/v1/vms/"+url.PathEscape(id), PatchVMRequest{PowerState: powerState}, nil)
}

// CreateExposure publishes guestPort of a VM on its host, for protocol "tcp"
// or "udp" (empty means tcp). hostPort 0 asks the control plane to allocate one
// from the reserved range; the returned exposure carries whichever port it
// ended up with.
func (c *Client) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (Exposure, error) {
	req := CreateExposureRequest{GuestPort: guestPort, HostPort: hostPort, Protocol: protocol}
	var out Exposure
	return out, c.do(ctx, http.MethodPost, "/api/v1/vms/"+url.PathEscape(vmID)+"/exposures", req, &out)
}

// ListExposures returns a VM's published ports, with the host address to dial
// and each listener's reported state.
func (c *Client) ListExposures(ctx context.Context, vmID string) ([]Exposure, error) {
	var out []Exposure
	return out, c.do(ctx, http.MethodGet, "/api/v1/vms/"+url.PathEscape(vmID)+"/exposures", nil, &out)
}

// DeleteExposure revokes an exposure by id; the host closes the listener on its
// next converge.
func (c *Client) DeleteExposure(ctx context.Context, id string) error {
	return c.do(ctx, http.MethodDelete, "/api/v1/exposures/"+url.PathEscape(id), nil, nil)
}

// CreateVolumeClaim claims sizeGB of durable storage under name. The claim is
// Pending — nothing is placed — until the first VM that names it is created,
// which is what decides the host its bytes live on.
func (c *Client) CreateVolumeClaim(ctx context.Context, name string, sizeGB int64) (VolumeClaim, error) {
	var out VolumeClaim
	return out, c.do(ctx, http.MethodPost, "/api/v1/volume-claims",
		CreateVolumeClaimRequest{Name: name, SizeGB: sizeGB}, &out)
}

// ListVolumeClaims returns the caller tenant's claims, oldest first.
func (c *Client) ListVolumeClaims(ctx context.Context) ([]VolumeClaim, error) {
	var out []VolumeClaim
	return out, c.do(ctx, http.MethodGet, "/api/v1/volume-claims", nil, &out)
}

// GetVolumeClaim reads one claim by id: where it is bound, which VM holds it,
// and whether its host has reported the file.
func (c *Client) GetVolumeClaim(ctx context.Context, id string) (VolumeClaim, error) {
	var out VolumeClaim
	return out, c.do(ctx, http.MethodGet, "/api/v1/volume-claims/"+url.PathEscape(id), nil, &out)
}

// DeleteVolumeClaim deletes a claim and the data behind it. Refused while a VM
// holds it — delete that VM first.
func (c *Client) DeleteVolumeClaim(ctx context.Context, id string) error {
	return c.do(ctx, http.MethodDelete, "/api/v1/volume-claims/"+url.PathEscape(id), nil, nil)
}

// Me returns the signed-in identity (tenant handle + bound email) for the
// credential this client carries. It takes no context — the consumers (smoke
// gate, CLI) call it as a quick synchronous probe; the do timeout bounds it.
func (c *Client) Me() (Me, error) {
	var out Me
	return out, c.do(context.Background(), http.MethodGet, "/api/v1/me", nil, &out)
}

// CreateAPIToken mints a personal access token named name with the given TTL
// (0 = non-expiring); the returned secret is shown exactly once. The CI gate
// signs in with a session and mints a short-lived PAT through this method to
// authenticate the rest of its run.
func (c *Client) CreateAPIToken(name string, ttl time.Duration) (CreateAPITokenResponse, error) {
	var out CreateAPITokenResponse
	req := types.CreateAPITokenRequest{Name: name, TTLSeconds: int64(ttl / time.Second)}
	return out, c.do(context.Background(), http.MethodPost, "/api/v1/tokens", req, &out)
}

// FetchSSHCALine retrieves the eitri host-CA public key as the VERBATIM
// authorized_keys line the server serves — trailing comment and all — after
// parse-validating it (never hand back a line ssh can't read). A 404 means
// the SSH-CA jump gate isn't enabled; that case is surfaced as a clear,
// gate-specific error rather than a raw HTTP status.
func (c *Client) FetchSSHCALine(ctx context.Context) (string, error) {
	var out types.SSHCAResponse
	if err := c.do(ctx, http.MethodGet, "/api/v1/ssh-ca", nil, &out); err != nil {
		var apiErr *Error
		if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
			return "", &gateOffError{cause: apiErr}
		}
		return "", err
	}
	if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.CA)); err != nil {
		return "", fmt.Errorf("client: parsing ssh CA key: %w", err)
	}
	return out.CA, nil
}

// FetchSSHCA is FetchSSHCALine, parsed: the host CA as an ssh.PublicKey, for
// callers that verify host certs (it satisfies gateclient.CertAuthority
// together with UploadUserCA).
func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
	line, err := c.FetchSSHCALine(ctx)
	if err != nil {
		return nil, err
	}
	pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
	if err != nil {
		return nil, fmt.Errorf("client: parsing ssh CA key: %w", err)
	}
	return pub, nil
}

// UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with a
// tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA
// signs. An empty tenant targets the tenant-less endpoint, which registers on
// the CALLER'S OWN tenant (the credential names it); a non-empty tenant pins
// one explicitly. Idempotent server-side.
func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error {
	req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel}
	path := "/api/v1/user-cas"
	if tenant != "" {
		path = "/api/v1/tenants/" + url.PathEscape(tenant) + "/user-cas"
	}
	return c.do(ctx, http.MethodPost, path, req, nil)
}

// ListUserCAs returns a tenant's registered user CAs (pubkey + label +
// fingerprint). An empty tenant targets the tenant-less endpoint, which lists
// the CALLER'S OWN tenant (the credential names it); a non-empty tenant pins one
// explicitly. It mirrors UploadUserCA's routing so a caller can check-then-upload
// idempotently against its own tenant with an empty tenant throughout.
func (c *Client) ListUserCAs(ctx context.Context, tenant string) ([]UserCA, error) {
	path := "/api/v1/user-cas"
	if tenant != "" {
		path = "/api/v1/tenants/" + url.PathEscape(tenant) + "/user-cas"
	}
	var out []UserCA
	return out, c.do(ctx, http.MethodGet, path, nil, &out)
}

// BeginDelegation asks for the public key the caller is to sign, along with the
// principal it must carry and the command that signs it. The key is eitri's
// ephemeral half, held in memory only.
func (c *Client) BeginDelegation(ctx context.Context) (DelegationChallenge, error) {
	var out DelegationChallenge
	return out, c.do(ctx, http.MethodPost, "/api/v1/delegations", nil, &out)
}

// CompleteDelegation hands back the signed certificate. eitri can then reach
// the caller's VMs until it expires, and holds nothing else.
func (c *Client) CompleteDelegation(ctx context.Context, certificate string) (Delegation, error) {
	var out Delegation
	return out, c.do(ctx, http.MethodPut, "/api/v1/delegations", DelegationRequest{Certificate: certificate}, &out)
}

// Delegation describes the caller tenant's live delegation, so its expiry is
// never a surprise.
func (c *Client) Delegation(ctx context.Context) (Delegation, error) {
	var out Delegation
	return out, c.do(ctx, http.MethodGet, "/api/v1/delegations", nil, &out)
}

// RevokeDelegation ends the delegation now.
func (c *Client) RevokeDelegation(ctx context.Context) error {
	return c.do(ctx, http.MethodDelete, "/api/v1/delegations", nil, nil)
}