diff --git a/cmd/fj-bellows/main.go b/cmd/fj-bellows/main.go index e400b99..f326424 100644 --- a/cmd/fj-bellows/main.go +++ b/cmd/fj-bellows/main.go @@ -513,12 +513,23 @@ func bootWGStack(ctx context.Context, cfg *config.Config, prov provider.Provider return nil, func() {}, nil } cache := cacheRenderInputs(ctx, cfg, prov) + // Only run the FJB-99 bootstrap poll when the operator hasn't + // statically configured a peer. Skipping the 3-minute poll under + // static config keeps daemon startup fast and matches the + // back-compat path the FJB-91 e2e harness exercises against the + // persistent test cache. + var cachePubkey, cacheEndpoint string + if cfg.Transport.WG != nil && (cfg.Transport.WG.Peer.PublicKey == "" || cfg.Transport.WG.Peer.Endpoint == "") { + cachePubkey, cacheEndpoint = discoverCachePeer(ctx, prov, log) + } stack, err := wgboot.Boot(ctx, wgboot.Config{ - Transport: cfg.Transport, - ForgejoURL: cfg.Forgejo.URL, - ACLSink: aclSinkFor(prov), - Cache: cache, - Logger: log, + Transport: cfg.Transport, + ForgejoURL: cfg.Forgejo.URL, + ACLSink: aclSinkFor(prov), + Cache: cache, + Logger: log, + CachePubkey: cachePubkey, + CacheEndpoint: cacheEndpoint, }) if err != nil { return nil, func() {}, fmt.Errorf("transport bootstrap: %w", err) @@ -530,6 +541,44 @@ func bootWGStack(ctx context.Context, cfg *config.Config, prov provider.Provider }, nil } +// discoverCachePeer runs the orchestrator side of the FJB-99 bootstrap +// loop: poll the managed cache's Object Storage bucket for the cache's +// WG pubkey (published by cache cloud-init's fjb-wg-bootstrap.sh) and +// read the cache's public IPv4 from the Linode API to build the WG +// peer endpoint. Returns empty strings (no error) when the provider +// can't supply these — wgboot then falls back to the static +// transport.wg.peer.{public_key,endpoint} config knobs and errors at +// boot if those are also empty. +// +// Bounded at 3 minutes — the cache cloud-init steady-state (package +// install + wireguard + pubkey upload) typically completes well under +// 2 minutes, so the bound is generous but not unbounded. +func discoverCachePeer(ctx context.Context, prov provider.Provider, log *slog.Logger) (pubkey, endpoint string) { + type pubkeyWaiter interface { + WaitForCacheWGPubkey(ctx context.Context, timeout time.Duration) (string, error) + } + type endpointReader interface { + CachePublicEndpoint(ctx context.Context) (string, error) + } + if pw, ok := prov.(pubkeyWaiter); ok { + start := time.Now() + if pk, err := pw.WaitForCacheWGPubkey(ctx, 3*time.Minute); err == nil { + log.Info("wgboot: cache wg-pubkey discovered", "elapsed", time.Since(start)) + pubkey = pk + } else { + log.Warn("wgboot: cache wg-pubkey discovery failed; falling back to static config", "err", err) + } + } + if er, ok := prov.(endpointReader); ok { + if ep, err := er.CachePublicEndpoint(ctx); err == nil { + endpoint = ep + } else { + log.Warn("wgboot: cache public endpoint discovery failed; falling back to static config", "err", err) + } + } + return pubkey, endpoint +} + // cacheRenderInputs pulls the cache-side facts the renderer needs out // of whatever live provider state is available. Today only the Linode // provider carries a managed cache; the function returns whatever it diff --git a/internal/config/transport.go b/internal/config/transport.go index 808cf9c..48e57c3 100644 --- a/internal/config/transport.go +++ b/internal/config/transport.go @@ -254,14 +254,16 @@ func (w *WG) validate() error { } func (p *WGPeer) validate() error { - if p.PublicKey == "" { - return errors.New("public_key is required") - } - if p.Endpoint == "" { - return errors.New("endpoint is required") - } - if _, _, err := net.SplitHostPort(p.Endpoint); err != nil { - return fmt.Errorf("endpoint %q is not host:port: %w", p.Endpoint, err) + // public_key + endpoint are runtime-discoverable from FJB-99's + // cache wg-bootstrap loop (the cache writes its pubkey to S3; the + // orchestrator reads the cache's public IPv4 from the Linode API). + // Either may be empty at config-validate time; wgboot's planBoot + // is the load-bearing check that both end up populated by the + // time the tunnel is brought up. + if p.Endpoint != "" { + if _, _, err := net.SplitHostPort(p.Endpoint); err != nil { + return fmt.Errorf("endpoint %q is not host:port: %w", p.Endpoint, err) + } } if len(p.AllowedIPs) == 0 { return errors.New("allowed_ips must list at least one CIDR") diff --git a/internal/config/transport_wg_test.go b/internal/config/transport_wg_test.go index 8300fc8..20919b3 100644 --- a/internal/config/transport_wg_test.go +++ b/internal/config/transport_wg_test.go @@ -156,7 +156,6 @@ func TestWG_DefaultKeepaliveOneSecond(t *testing.T) { } } -//nolint:funlen // table-driven: 11 small validation cases inline for readability. func TestWG_Validation(t *testing.T) { cases := []struct { name string @@ -200,30 +199,6 @@ func TestWG_Validation(t *testing.T) { `, wantSub: `local_addr = "not-a-cidr"`, }, - { - name: "missing peer public key", - wgBlock: ` - wg: - private_key_file: /tmp/k - local_addr: 10.99.0.1/32 - peer: - endpoint: 172.234.203.50:51820 - allowed_ips: [10.99.0.2/32] -`, - wantSub: "public_key is required", - }, - { - name: "missing peer endpoint", - wgBlock: ` - wg: - private_key_file: /tmp/k - local_addr: 10.99.0.1/32 - peer: - public_key: AbcDefGhiJklMnoPqrStuVwxYzAbcDefGhiJklMnoPqs= - allowed_ips: [10.99.0.2/32] -`, - wantSub: "endpoint is required", - }, { name: "bad peer endpoint shape", wgBlock: ` diff --git a/internal/provider/linode/cache-cloud-init.yaml.tmpl b/internal/provider/linode/cache-cloud-init.yaml.tmpl index 5cead87..06b535b 100644 --- a/internal/provider/linode/cache-cloud-init.yaml.tmpl +++ b/internal/provider/linode/cache-cloud-init.yaml.tmpl @@ -105,14 +105,17 @@ write_files: EOC chmod 0600 /etc/wireguard/wg0.conf systemctl enable --now wg-quick@wg0 - # Publish own pubkey so the orchestrator can read it from S3 and - # complete the wg peer config on its side (FJB-99 Phase B). + # Publish own pubkey so the orchestrator can read it via plain + # HTTPS GET and complete the wg peer config on its side (FJB-99 + # Phase B). WG public keys are designed to be world-readable — + # exposing one over public-read costs nothing. AWS_ACCESS_KEY_ID='{{.AccessKey}}' \ AWS_SECRET_ACCESS_KEY='{{.SecretKey}}' \ AWS_DEFAULT_REGION='{{.Region}}' \ aws --endpoint-url '{{.Endpoint}}' \ s3 cp /etc/wireguard/publickey \ - s3://{{.Bucket}}/wg-pubkey.txt + s3://{{.Bucket}}/wg-pubkey.txt \ + --acl public-read {{- end}} - path: /etc/systemd/system/zot.service permissions: '0644' diff --git a/internal/provider/linode/cache.go b/internal/provider/linode/cache.go index ccbbe26..b9d9b6a 100644 --- a/internal/provider/linode/cache.go +++ b/internal/provider/linode/cache.go @@ -7,13 +7,16 @@ import ( "encoding/base64" "errors" "fmt" + "io" "log/slog" + "net/http" "net/netip" "os" "path/filepath" "slices" "strings" "text/template" + "time" "github.com/linode/linodego" @@ -454,6 +457,123 @@ func (m *managedCache) renderIPTablesForCacheGateway() (string, error) { }) } +// WaitForWGPubkey blocks until the cache's first-boot cloud-init has +// published its WG public key to the Object Storage bucket, or until +// ctx is canceled / timeout elapses (whichever comes first). Returns +// the trimmed public-key string ready to feed into wgboot.Config. +// FJB-99 Phase B — completes the bootstrap loop that Phase A started +// from the cache side. +// +// The cache writes the object as `wg-pubkey.txt` with `--acl +// public-read`, so the orchestrator reaches it via plain HTTPS GET — +// no S3 SDK required, no signed requests. WG public keys are designed +// to be world-readable; the threat model isn't worse than what's +// already on the wire during a key exchange. +// +// Polls at 5s intervals with exponential backoff up to 30s. First +// boot typically takes 60-120s (cloud-init + package install + +// wireguard install). +func (m *managedCache) WaitForWGPubkey(ctx context.Context, timeout time.Duration) (string, error) { + if m.bucket == nil { + return "", errors.New("cache: bucket not initialised (no managed cache)") + } + if m.bucket.endpoint == "" { + return "", errors.New("cache: bucket endpoint unset (call Configure first)") + } + url := fmt.Sprintf("%s/%s/wg-pubkey.txt", strings.TrimRight(m.bucket.endpoint, "/"), m.bucket.label) + deadline := time.Now().Add(timeout) + delay := 5 * time.Second + const maxDelay = 30 * time.Second + httpClient := &http.Client{Timeout: 10 * time.Second} + for { + pub, ready, err := pollWGPubkey(ctx, httpClient, url, m.log) + if err != nil { + return "", err + } + if ready { + return pub, nil + } + if time.Now().Add(delay).After(deadline) { + return "", fmt.Errorf("cache wg-pubkey poll: timed out after %s waiting for %s", timeout, url) + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("cache wg-pubkey poll: %w", ctx.Err()) + case <-time.After(delay): + } + if delay < maxDelay { + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } +} + +// pollWGPubkey does one HTTPS GET against the bucket URL. Returns +// (key, true, nil) on 200-with-non-empty-body; (_, false, nil) when the +// object isn't there yet (404 / connection error — debug-logged for +// the operator); (_, _, err) when the response is structurally bad +// (200 with empty body, read failure, request build failure). +func pollWGPubkey(ctx context.Context, httpClient *http.Client, url string, log *slog.Logger) (string, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", false, fmt.Errorf("cache wg-pubkey poll: build request: %w", err) + } + resp, err := httpClient.Do(req) + if err != nil { + log.Debug("cache wg-pubkey poll: request error", "url", url, "err", err) + return "", false, nil + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + log.Debug("cache wg-pubkey poll: not ready", "url", url, "status", resp.StatusCode) + return "", false, nil + } + body, rerr := io.ReadAll(resp.Body) + if rerr != nil { + return "", false, fmt.Errorf("cache wg-pubkey poll: read body: %w", rerr) + } + pub := strings.TrimSpace(string(body)) + if pub == "" { + return "", false, errors.New("cache wg-pubkey poll: 200 OK but body was empty") + } + return pub, true, nil +} + +// PublicEndpoint returns the cache's WG peer endpoint as host:port +// (e.g. "172.234.203.50:51820"). The IP comes from the Linode API +// (the public NIC's assigned IPv4); the port is supplied by the +// caller — typically transport.wg.listen_port from config or the +// default 51820 baked into the cache cloud-init. FJB-99 Phase B. +func (m *managedCache) PublicEndpoint(ctx context.Context, port int) (string, error) { + if m.linodeID == 0 { + return "", errors.New("cache: linode not provisioned yet") + } + if port <= 0 { + port = defaultCacheWGListenPort + } + inst, err := m.client.GetInstance(ctx, m.linodeID) + if err != nil { + return "", fmt.Errorf("cache public endpoint: get instance %d: %w", m.linodeID, err) + } + for _, ip := range inst.IPv4 { + if ip == nil { + continue + } + v4 := ip.To4() + if v4 == nil { + continue + } + // Skip the VPC interface IP (RFC1918) — pick the public IPv4. + if v4.IsPrivate() { + continue + } + return fmt.Sprintf("%s:%d", v4.String(), port), nil + } + return "", fmt.Errorf("cache public endpoint: linode %d has no public IPv4 yet", m.linodeID) +} + // buildCreateOpts assembles the InstanceCreateOptions payload for the // cache VM. Public stays primary so outbound (upstream sync, package // mirrors, GitHub-zot download) takes the default route; the VPC NIC diff --git a/internal/provider/linode/cache_test.go b/internal/provider/linode/cache_test.go index 68edfdb..7c91309 100644 --- a/internal/provider/linode/cache_test.go +++ b/internal/provider/linode/cache_test.go @@ -5,10 +5,15 @@ import ( "errors" "fmt" "log/slog" + "net" + "net/http" + "net/http/httptest" "slices" "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/linode/linodego" ) @@ -22,7 +27,10 @@ const ( // Shared cloud-init renderer fixtures used across cache_test.go // and cache_tunnel_test.go — extracted so goconst doesn't flag // repeated literals. - testStubEndpoint = "https://x" + testStubEndpoint = "https://x" + // testFJBCacheLabel is the placeholder bucket label used across the + // renderer + Phase-B WG-bootstrap polling tests. + testFJBCacheLabel = "fjb-cache-test" testStubZotVersion = "1.0.0" ) @@ -393,7 +401,7 @@ func TestRenderCacheCloudInitProducesValidCloudInit(t *testing.T) { const stubCertPEM = "-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n" const stubKeyPEM = "-----BEGIN EC PRIVATE KEY-----\nMOCK\n-----END EC PRIVATE KEY-----\n" //nolint:gosec // G101: test fixture, not a real key out, err := renderCacheCloudInit(cacheCloudInitParams{ - Bucket: "fjb-cache-test", + Bucket: testFJBCacheLabel, Region: testBucketRegion, Endpoint: testBucketEndpoint, AccessKey: "AK", @@ -407,7 +415,7 @@ func TestRenderCacheCloudInitProducesValidCloudInit(t *testing.T) { } for _, want := range []string{ "#cloud-config", - "fjb-cache-test", + testFJBCacheLabel, testBucketEndpoint, "\"accesskey\": \"AK\"", "\"secretkey\": \"SK\"", @@ -517,7 +525,7 @@ func TestRenderCacheCloudInitIPTablesBakeIn(t *testing.T) { func TestRenderCacheCloudInitWGBootstrap(t *testing.T) { const orchPubkey = "ZmFrZS1vcmNoZXN0cmF0b3ItcHViLWtleS0xMjM0NTY3OD0=" withWG, err := renderCacheCloudInit(cacheCloudInitParams{ - Bucket: "fjb-cache-test", + Bucket: testFJBCacheLabel, Region: testBucketRegion, Endpoint: "https://us-ord-1.linodeobjects.com", AccessKey: "AK", @@ -544,7 +552,7 @@ func TestRenderCacheCloudInitWGBootstrap(t *testing.T) { "AllowedIPs = 100.64.0.1/32", "PersistentKeepalive = 25", "systemctl enable --now wg-quick@wg0", - "s3://fjb-cache-test/wg-pubkey.txt", + "s3://" + testFJBCacheLabel + "/wg-pubkey.txt", "--endpoint-url 'https://us-ord-1.linodeobjects.com'", } { if !strings.Contains(withWG, want) { @@ -614,6 +622,79 @@ func TestRenderIPTablesForCacheGateway(t *testing.T) { } } +// FJB-99 Phase B: WaitForWGPubkey polls the bucket URL until the +// cache's first-boot cloud-init has uploaded the public key, then +// returns its trimmed contents. +func TestWaitForWGPubkey_PollsBucketUntilFound(t *testing.T) { + var hits atomic.Int32 + const wantPub = "ZmFrZS1jYWNoZS13Zy1wdWJrZXk=" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+testFJBCacheLabel+"/wg-pubkey.txt" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + n := hits.Add(1) + if n < 2 { + w.WriteHeader(http.StatusNotFound) + return + } + // Trailing whitespace must be trimmed by the caller. + _, _ = w.Write([]byte(wantPub + "\n")) + })) + t.Cleanup(srv.Close) + + bucket := &managedBucket{endpoint: srv.URL, label: testFJBCacheLabel} + m := &managedCache{bucket: bucket, log: slog.Default()} + + got, err := m.WaitForWGPubkey(t.Context(), 30*time.Second) + if err != nil { + t.Fatalf("WaitForWGPubkey: %v", err) + } + if got != wantPub { + t.Errorf("pubkey = %q, want %q", got, wantPub) + } + if hits.Load() < 2 { + t.Errorf("expected at least 2 polls, got %d", hits.Load()) + } +} + +// FJB-99 Phase B: WaitForWGPubkey honors the timeout argument. +func TestWaitForWGPubkey_TimeoutSurfacesAsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + bucket := &managedBucket{endpoint: srv.URL, label: testFJBCacheLabel} + m := &managedCache{bucket: bucket, log: slog.Default()} + + _, err := m.WaitForWGPubkey(t.Context(), 50*time.Millisecond) + if err == nil { + t.Fatal("WaitForWGPubkey: want timeout error; got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("err = %v, want substring 'timed out'", err) + } +} + +// FJB-99 Phase B: PublicEndpoint picks the public IPv4 (not the VPC +// /private one) and appends the supplied port. +func TestPublicEndpoint_PicksPublicIPv4(t *testing.T) { + fc := newFakeCacheClient() + publicIP := net.ParseIP("172.234.203.50").To4() + privateIP := net.ParseIP("10.0.0.2").To4() + fc.insts[55] = &linodego.Instance{ + ID: 55, + IPv4: []*net.IP{&publicIP, &privateIP}, + } + m := &managedCache{client: fc, log: slog.Default(), linodeID: 55} + got, err := m.PublicEndpoint(t.Context(), 51820) + if err != nil { + t.Fatalf("PublicEndpoint: %v", err) + } + if got != "172.234.203.50:51820" { + t.Errorf("endpoint = %q, want %q", got, "172.234.203.50:51820") + } +} + // findCacheLinode must not adopt instances whose tag doesn't match — // otherwise a cache from a different deployment could be hijacked. func TestFindCacheLinodeIgnoresOtherDeployments(t *testing.T) { diff --git a/internal/provider/linode/linode.go b/internal/provider/linode/linode.go index 22684ea..de12299 100644 --- a/internal/provider/linode/linode.go +++ b/internal/provider/linode/linode.go @@ -252,6 +252,31 @@ func (l *Linode) SetOrchestratorWGPubkey(pubkey string) { l.orchestratorWGPubkey = pubkey } +// WaitForCacheWGPubkey blocks until the cache's first-boot cloud-init +// publishes its WG pubkey to the Object Storage bucket, or until ctx +// is canceled / timeout elapses. Returns the trimmed pubkey for use +// as the wgboot WG peer config. FJB-99 Phase B — orchestrator side +// of the bootstrap loop. Returns ("", error) when no managed cache +// exists or it hasn't been ensured yet. +func (l *Linode) WaitForCacheWGPubkey(ctx context.Context, timeout time.Duration) (string, error) { + if l.cache == nil { + return "", errors.New("linode: no managed cache configured") + } + return l.cache.WaitForWGPubkey(ctx, timeout) +} + +// CachePublicEndpoint returns the cache's WG peer endpoint as +// host:port (e.g. "172.234.203.50:51820") for the wgboot peer config. +// The port comes from l.wgListenPort (operator-configured listen port, +// captured by SetWGListenPort before Configure) or defaults to 51820. +// FJB-99 Phase B. Returns ("", error) when no managed cache exists. +func (l *Linode) CachePublicEndpoint(ctx context.Context) (string, error) { + if l.cache == nil { + return "", errors.New("linode: no managed cache configured") + } + return l.cache.PublicEndpoint(ctx, l.wgListenPort) +} + // CacheStatus returns the managed-cache snapshot consumed by the control // plane's GetCache RPC. Returns nil when no `cache:` block is configured; // the control handler then reports Present=false to the wire. The Linode diff --git a/internal/transport/wgboot/wgboot.go b/internal/transport/wgboot/wgboot.go index ad574b6..2222683 100644 --- a/internal/transport/wgboot/wgboot.go +++ b/internal/transport/wgboot/wgboot.go @@ -111,6 +111,19 @@ type Config struct { // Logger receives boot + runtime events. Falls back to // slog.Default when nil. Logger *slog.Logger + + // CachePubkey + CacheEndpoint are the runtime-discovered peer + // values from the FJB-99 bootstrap loop. When non-empty they + // override Transport.WG.Peer.PublicKey + Transport.WG.Peer.Endpoint + // — the cache nanode generates its keypair at first boot and + // publishes its pubkey to S3; cmd/fj-bellows polls for it + // (managedCache.WaitForWGPubkey) and the public IP comes from + // the Linode API (managedCache.PublicEndpoint). Leaving the static + // transport.wg.peer.{public_key,endpoint} config knobs populated + // remains supported as a back-compat path; the override takes + // precedence when both are present. + CachePubkey string + CacheEndpoint string } // Stack is the running cache-gateway transport. Returned by Boot, @@ -245,11 +258,25 @@ func planBoot(cfg Config, log *slog.Logger) (*bootPlan, error) { if err != nil { return nil, fmt.Errorf("wgboot: parse local_addr %q: %w", cfg.Transport.WG.LocalAddr, err) } - endpoint, err := net.ResolveUDPAddr("udp", cfg.Transport.WG.Peer.Endpoint) + endpointStr := cfg.Transport.WG.Peer.Endpoint + if cfg.CacheEndpoint != "" { + endpointStr = cfg.CacheEndpoint + } + if endpointStr == "" { + return nil, errors.New("wgboot: cache endpoint unset — set transport.wg.peer.endpoint, or provide CacheEndpoint via the FJB-99 bootstrap loop") + } + endpoint, err := net.ResolveUDPAddr("udp", endpointStr) if err != nil { - return nil, fmt.Errorf("wgboot: resolve peer endpoint %q: %w", cfg.Transport.WG.Peer.Endpoint, err) + return nil, fmt.Errorf("wgboot: resolve peer endpoint %q: %w", endpointStr, err) + } + peerKeyStr := cfg.Transport.WG.Peer.PublicKey + if cfg.CachePubkey != "" { + peerKeyStr = cfg.CachePubkey + } + if peerKeyStr == "" { + return nil, errors.New("wgboot: cache pubkey unset — set transport.wg.peer.public_key, or provide CachePubkey via the FJB-99 bootstrap loop") } - peerKey, err := wg.DecodeKey(cfg.Transport.WG.Peer.PublicKey) + peerKey, err := wg.DecodeKey(peerKeyStr) if err != nil { return nil, fmt.Errorf("wgboot: parse peer public_key: %w", err) } diff --git a/internal/transport/wgboot/wgboot_test.go b/internal/transport/wgboot/wgboot_test.go index 7772c05..593e5ec 100644 --- a/internal/transport/wgboot/wgboot_test.go +++ b/internal/transport/wgboot/wgboot_test.go @@ -54,6 +54,41 @@ func TestBoot_RejectsMissingForgejoURL(t *testing.T) { } } +// FJB-99 Phase B: when transport.wg.peer.{public_key,endpoint} are +// empty in config (the new bootstrap-loop default) and the caller +// hasn't supplied CachePubkey/CacheEndpoint overrides either, +// Boot must surface a clear error pointing at the bootstrap loop. +func TestBoot_RejectsMissingPeerWithoutOverrides(t *testing.T) { + cfg := Config{ + Transport: config.Transport{ + Mode: config.TransportCacheGateway, + WG: &config.WG{ + PrivateKeyFile: "/tmp/k-fjb99-missing-peer", + LocalAddr: "100.64.0.1/32", + OverlayPrefix: "100.64.0.0/30", + Peer: config.WGPeer{ + // PublicKey + Endpoint deliberately empty. + AllowedIPs: []string{"100.64.0.2/32"}, + }, + }, + }, + ForgejoURL: "https://git.example.com", + Cache: CacheRenderInputs{ + CacheVPCIP: "10.0.0.2", + WorkerVPCSubnet: "10.0.0.0/24", + }, + } + _, err := Boot(t.Context(), cfg) + if err == nil { + t.Fatal("Boot(no peer + no override) should error; got nil") + } + for _, want := range []string{"FJB-99 bootstrap"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want substring %q", err, want) + } + } +} + func TestBoot_RejectsMissingCacheVPCIP(t *testing.T) { cfg := Config{ Transport: config.Transport{