Skip to content

Commit 9ea005a

Browse files
authored
fix(api): discover the local orchestrator as a template builder (#3386)
## Problem With `SERVICE_DISCOVERY_PROVIDER=local`, template builds are impossible: every build fails with `503 Error when getting available build client`, and the API logs ``` WARN No available template builder found with the specified machine info, falling back to any available template builder ERROR error when getting available build client failed to get any available template builder for cluster '00000000-0000-0000-0000-000000000000': available template builder not found ``` The cause is in `handlers/store.go`, in the `ServiceDiscoveryProviderLocal` branch: ```go nodeDiscovery = localND // No template builders in local dev — return an empty list so the // clusters pool initializes cleanly. templateBuilderDiscovery = clustersdiscovery.NewStaticDiscovery(nil) ``` `templateBuilderDiscovery` feeds the clusters registry, and `Cluster.GetAvailableTemplateBuilder` selects from exactly that registry. With a permanently empty discovery there is never a candidate, so `TemplateManager.GetAvailableBuildClient` fails both its primary lookup and its "fall back to any builder" retry. That assumption was correct when the only local orchestrator was the darwin dummy (`pkg/dummyserver`), which does not build templates. It is not correct for a local orchestrator started with `ORCHESTRATOR_SERVICES=orchestrator,template-manager`, which is a supported configuration and does serve builds. ### Why this is easy to misdiagnose The 503 is only visible if you are watching the build. `POST /v3/templates` succeeds (202) and inserts `env_builds` with `status=waiting` plus the `env_build_assignments` row for tag `default`; the follow-up `POST /v2/templates/{id}/builds/{buildID}` is what 503s. The build row is left in `waiting` forever. `GetTemplateWithBuildByTag` requires `env_builds.status_group = 'ready'`, so the tag lookup returns no rows, and `templateTagNotFoundError` renders as: ``` 404: tag 'default' does not exist for template '<team>/base' ``` The tag exists. The build under it is just not `ready`, and never will be. `GET /nodes` is also misleading here: it lists orchestrator nodes, which are discovered through the separate `nodeDiscovery` path and show up as `ready` normally, so the orchestrator looks perfectly healthy while no builder exists. ## Changes ### 1. Point local template-builder discovery at the local orchestrator `templateBuilderDiscovery` is now built from `LOCAL_ORCHESTRATOR_ADDRESS` via a new `discovery.NewStaticFromAddress`, which parses `host:port` (or bare `host`, defaulting to `consts.OrchestratorAPIPort`) and mirrors the orchestrator-side `orchestrator/discovery.NewLocal`. This is self-configuring rather than a new knob: whether the instance is usable as a builder is still decided by the roles it reports over the Info RPC during instance sync (`Instance.Sync` → `isBuilder`). Concretely: | local orchestrator | reported roles | `IsBuilder` | builds | | --- | --- | --- | --- | | darwin dummy | `[Orchestrator]` | `false` | rejected, as before | | `ORCHESTRATOR_SERVICES=orchestrator,template-manager` | `[Orchestrator, TemplateBuilder]` | `true` | works | So pointing discovery at an orchestrator that does not run `template-manager` is harmless — the instance registers with `IsBuilder=false` and `GetAvailableTemplateBuilder` skips it, which is the current behaviour. ### 2. Do not register local-cluster instances as orchestrator nodes `connectToClusterNode` now returns early for `consts.LocalClusterID`. Without this, change 1 introduces a duplicate node. The two registries identify the same machine differently: - node discovery → `nodemanager.New` → `ID: nodeInfo.GetNodeId()` (self-reported, e.g. `orchestrator-vm`) - clusters registry → `nodemanager.NewClusterNode` → `ID: i.NodeID` (discovery item ID, `local`) `registerNode` keys `o.nodes` by `scopedNodeID(clusterID, nodeID)`, so an instance reporting both roles registers twice under two different IDs, and its capacity, metrics and sandboxes are counted twice. Observed directly — `GET /nodes` returned both `local` and `orchestrator-vm` for a single VM. Local-cluster orchestrators are owned by the node discovery path; the local clusters registry exists only to find template builders. This is already the documented intent in `clusters/discovery/local.go`: > For now, we want to search only for template builders as local orchestrators > are still discovered old way via Nomad discovery directly inside node manager flow. Nomad and Kubernetes template builders run `template-manager` only and do not report the `Orchestrator` role, so `Cluster.GetOrchestrators()` is already empty for the local cluster in those setups and the early return is a no-op. Remote clusters are unaffected. ## Testing Verified end to end against a local stack (API + orchestrator with `ORCHESTRATOR_SERVICES=orchestrator,template-manager`, `SERVICE_DISCOVERY_PROVIDER=local`): - before: `POST /v2/templates/{id}/builds/{id}` → 503; build stuck at `status=waiting`; `e2b sbx cr` → `404: tag 'default' does not exist for template '<team>/base'` - after: base template build completes (`status=uploaded`, `status_group=ready`) and `e2b sbx cr` starts a sandbox - after change 2: `GET /nodes` reports one node again instead of two Added `TestNewStaticFromAddress` covering host:port, bare host defaulting to `consts.OrchestratorAPIPort`, IPv6, and the empty-host / bad-port error paths. `go test ./packages/api/...` passes (22 packages).
1 parent b87ce68 commit 9ea005a

6 files changed

Lines changed: 260 additions & 7 deletions

File tree

packages/api/internal/clusters/discovery/static.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
package discovery
22

3-
import "context"
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"strconv"
8+
9+
"github.com/e2b-dev/infra/packages/shared/pkg/consts"
10+
)
411

512
// StaticServiceDiscovery returns a fixed (possibly empty) list of items on
613
// every Query. It is used for local development against the darwin dummy
@@ -15,6 +22,45 @@ func NewStaticDiscovery(items []Item) Discovery {
1522
return &StaticServiceDiscovery{items: items}
1623
}
1724

25+
// NewStaticFromAddress returns a Discovery holding a single instance reachable
26+
// at addr, which may be "host:port" or just "host" (defaulting to
27+
// consts.OrchestratorAPIPort). It mirrors the orchestrator-side
28+
// orchestrator/discovery.NewLocal.
29+
//
30+
// A local orchestrator can serve the template-builder role as well
31+
// (ORCHESTRATOR_SERVICES=orchestrator,template-manager). Whether it actually
32+
// does is decided by the roles it reports over the Info RPC during instance
33+
// sync, so pointing template-builder discovery at an orchestrator that does
34+
// not run template-manager (e.g. the darwin dummy) is harmless: the instance
35+
// registers with IsBuilder=false and is skipped when picking a builder.
36+
func NewStaticFromAddress(addr string) (Discovery, error) {
37+
host, portStr, err := net.SplitHostPort(addr)
38+
if err != nil {
39+
// Allow plain "host" without a port.
40+
host = addr
41+
portStr = strconv.FormatUint(uint64(consts.OrchestratorAPIPort), 10)
42+
}
43+
if host == "" {
44+
return nil, fmt.Errorf("static discovery: empty host in %q", addr)
45+
}
46+
47+
port, err := strconv.ParseUint(portStr, 10, 16)
48+
if err != nil {
49+
return nil, fmt.Errorf("static discovery: invalid port %q: %w", portStr, err)
50+
}
51+
52+
return NewStaticDiscovery([]Item{
53+
{
54+
UniqueIdentifier: "local",
55+
NodeID: "local",
56+
// Populated during instance sync.
57+
InstanceID: "unknown",
58+
LocalIPAddress: host,
59+
LocalInstanceApiPort: uint16(port),
60+
},
61+
}), nil
62+
}
63+
1864
func (sd *StaticServiceDiscovery) Query(_ context.Context) ([]Item, error) {
1965
if sd.items == nil {
2066
return []Item{}, nil
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package discovery
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/e2b-dev/infra/packages/shared/pkg/consts"
10+
)
11+
12+
func TestNewStaticFromAddress(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
addr string
18+
wantHost string
19+
wantPort uint16
20+
wantErr bool
21+
}{
22+
{
23+
name: "host with port",
24+
addr: "127.0.0.1:5108",
25+
wantHost: "127.0.0.1",
26+
wantPort: 5108,
27+
},
28+
{
29+
name: "dns host with port",
30+
addr: "orchestrator.internal:5008",
31+
wantHost: "orchestrator.internal",
32+
wantPort: 5008,
33+
},
34+
{
35+
name: "ipv6 host with port",
36+
addr: "[::1]:5008",
37+
wantHost: "::1",
38+
wantPort: 5008,
39+
},
40+
{
41+
name: "host without port defaults to the orchestrator API port",
42+
addr: "127.0.0.1",
43+
wantHost: "127.0.0.1",
44+
wantPort: consts.OrchestratorAPIPort,
45+
},
46+
{
47+
name: "empty address",
48+
addr: "",
49+
wantErr: true,
50+
},
51+
{
52+
name: "empty host",
53+
addr: ":5008",
54+
wantErr: true,
55+
},
56+
{
57+
name: "non-numeric port",
58+
addr: "127.0.0.1:grpc",
59+
wantErr: true,
60+
},
61+
{
62+
name: "port out of range",
63+
addr: "127.0.0.1:70000",
64+
wantErr: true,
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
t.Parallel()
71+
72+
sd, err := NewStaticFromAddress(tt.addr)
73+
if tt.wantErr {
74+
require.Error(t, err)
75+
76+
return
77+
}
78+
79+
require.NoError(t, err)
80+
81+
items, err := sd.Query(context.Background())
82+
require.NoError(t, err)
83+
require.Len(t, items, 1)
84+
require.Equal(t, tt.wantHost, items[0].LocalIPAddress)
85+
require.Equal(t, tt.wantPort, items[0].LocalInstanceApiPort)
86+
})
87+
}
88+
}

packages/api/internal/handlers/store.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,16 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, redisClient redis.U
179179
logger.L().Fatal(ctx, "Initializing local orchestrator discovery", zap.Error(localErr))
180180
}
181181
nodeDiscovery = localND
182-
// No template builders in local dev — return an empty list so the
183-
// clusters pool initializes cleanly.
184-
templateBuilderDiscovery = clustersdiscovery.NewStaticDiscovery(nil)
182+
// The local orchestrator doubles as the template builder when it is
183+
// started with ORCHESTRATOR_SERVICES=orchestrator,template-manager, so
184+
// point builder discovery at the same address. Instances that do not
185+
// report the TemplateBuilder role (the darwin dummy orchestrator) are
186+
// registered with IsBuilder=false and never selected for builds, which
187+
// keeps the dummy setup behaving as before.
188+
templateBuilderDiscovery, err = clustersdiscovery.NewStaticFromAddress(config.LocalOrchestratorAddress)
189+
if err != nil {
190+
logger.L().Fatal(ctx, "Initializing local template builder discovery", zap.Error(err))
191+
}
185192
default: // ServiceDiscoveryProviderNomad
186193
nomadClient, nomadErr := nomadapi.NewClient(&nomadapi.Config{
187194
Address: config.NomadAddress,

packages/api/internal/orchestrator/client.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,33 @@ func (o *Orchestrator) connectToNode(ctx context.Context, discovered nodemanager
4343
return err
4444
}
4545

46+
// registersClusterOrchestrators reports whether instances discovered through
47+
// the clusters registry of the given cluster may be registered as orchestrator
48+
// nodes.
49+
//
50+
// Unless the node discovery loop is disabled (see
51+
// localClusterOwnsOrchestrators), local-cluster orchestrators are owned by the
52+
// node discovery path (connectToNode), which identifies nodes by the ID they
53+
// report over the Info RPC. The local clusters registry only exists to find
54+
// template builders and identifies instances by their discovery item ID, so an
55+
// instance serving both roles — a single process started with
56+
// ORCHESTRATOR_SERVICES=orchestrator,template-manager, as in local dev — would
57+
// otherwise register twice under two different node IDs and have its capacity
58+
// and sandboxes counted twice.
59+
//
60+
// Remote clusters are always registered from their own registry.
61+
func (o *Orchestrator) registersClusterOrchestrators(clusterID uuid.UUID) bool {
62+
return clusterID != consts.LocalClusterID || o.localClusterOwnsOrchestrators
63+
}
64+
4665
func (o *Orchestrator) connectToClusterNode(ctx context.Context, cluster *clusters.Cluster, i *clusters.Instance) {
4766
ctx, span := tracer.Start(ctx, "connect-to-cluster-node")
4867
defer span.End()
4968

69+
if !o.registersClusterOrchestrators(cluster.ID) {
70+
return
71+
}
72+
5073
// connectGroup is keyed by scopedNodeID so that concurrent callers targeting
5174
// the same cluster instance share a single dial attempt.
5275
scopedKey := o.scopedNodeID(cluster.ID, i.NodeID)

packages/api/internal/orchestrator/client_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"google.golang.org/protobuf/types/known/emptypb"
2222

2323
"github.com/e2b-dev/infra/packages/api/internal/api"
24+
"github.com/e2b-dev/infra/packages/api/internal/clusters"
2425
"github.com/e2b-dev/infra/packages/api/internal/orchestrator/discovery"
2526
"github.com/e2b-dev/infra/packages/api/internal/orchestrator/nodemanager"
2627
"github.com/e2b-dev/infra/packages/shared/pkg/consts"
@@ -336,3 +337,71 @@ func TestRegisterNode_NoDuplicates(t *testing.T) {
336337
wg.Wait()
337338
assert.Equal(t, 5, o.nodes.Count())
338339
}
340+
341+
// TestRegistersClusterOrchestrators covers which discovery path owns
342+
// orchestrator nodes. Local-cluster instances are owned by the node discovery
343+
// path (connectToNode) and must not be registered a second time from the
344+
// clusters registry — except when the node discovery loop is disabled
345+
// (ENVIRONMENT=local), where the clusters registry is the only source of
346+
// orchestrator nodes and skipping it would leave the API with zero nodes.
347+
func TestRegistersClusterOrchestrators(t *testing.T) {
348+
t.Parallel()
349+
350+
tests := []struct {
351+
name string
352+
clusterID uuid.UUID
353+
localClusterOwnsOrchestrators bool
354+
want bool
355+
}{
356+
{
357+
name: "local cluster with node discovery running",
358+
clusterID: consts.LocalClusterID,
359+
localClusterOwnsOrchestrators: false,
360+
want: false,
361+
},
362+
{
363+
name: "local cluster without node discovery",
364+
clusterID: consts.LocalClusterID,
365+
localClusterOwnsOrchestrators: true,
366+
want: true,
367+
},
368+
{
369+
name: "remote cluster with node discovery running",
370+
clusterID: uuid.New(),
371+
localClusterOwnsOrchestrators: false,
372+
want: true,
373+
},
374+
{
375+
name: "remote cluster without node discovery",
376+
clusterID: uuid.New(),
377+
localClusterOwnsOrchestrators: true,
378+
want: true,
379+
},
380+
}
381+
382+
for _, tt := range tests {
383+
t.Run(tt.name, func(t *testing.T) {
384+
t.Parallel()
385+
386+
o := newTestOrchestrator(t, nil)
387+
o.localClusterOwnsOrchestrators = tt.localClusterOwnsOrchestrators
388+
389+
assert.Equal(t, tt.want, o.registersClusterOrchestrators(tt.clusterID))
390+
})
391+
}
392+
}
393+
394+
// TestConnectToClusterNode_SkipsLocalCluster verifies that the ownership check
395+
// short-circuits connectToClusterNode before it touches the instance. The nil
396+
// instance is intentional: it makes a regression to unconditional registration
397+
// fail loudly instead of silently duplicating the node.
398+
func TestConnectToClusterNode_SkipsLocalCluster(t *testing.T) {
399+
t.Parallel()
400+
401+
o := newTestOrchestrator(t, nil)
402+
o.localClusterOwnsOrchestrators = false
403+
404+
o.connectToClusterNode(t.Context(), &clusters.Cluster{ID: consts.LocalClusterID}, nil)
405+
406+
assert.Zero(t, o.nodes.Count())
407+
}

packages/api/internal/orchestrator/orchestrator.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ type Orchestrator struct {
6767
snapshotUpsertSem *utils.AdjustableSemaphore
6868
redisStorage *redisbackend.Storage
6969

70+
// localClusterOwnsOrchestrators makes connectToClusterNode register
71+
// local-cluster instances that report the Orchestrator role as nodes.
72+
//
73+
// It is only set when the node discovery loop is disabled (see
74+
// skipNomadSync in New), which is the case in the local environment: there
75+
// the local clusters registry is the single source of orchestrator nodes.
76+
//
77+
// Otherwise local-cluster orchestrators are owned by the node discovery
78+
// path (connectToNode), which identifies nodes by the ID they report over
79+
// the Info RPC, while the clusters registry identifies instances by their
80+
// discovery item ID. An instance serving both the orchestrator and the
81+
// template-builder role would then register twice under two different node
82+
// IDs and have its capacity and sandboxes counted twice.
83+
localClusterOwnsOrchestrators bool
84+
7085
// connectGroup deduplicates concurrent dial+register attempts for the same
7186
// physical node. It is keyed by NomadNodeShortID (Nomad-managed nodes) or
7287
// scopedNodeID(clusterID, instanceNodeID) (cluster nodes) and is held inside
@@ -133,6 +148,10 @@ func New(
133148
}
134149
go redisStorage.Start(ctx)
135150

151+
// For local development and testing, we skip the Nomad sync
152+
// Local cluster is used for single-node setups instead
153+
skipNomadSync := env.IsLocal()
154+
136155
o := Orchestrator{
137156
httpClient: httpClient,
138157
analytics: analyticsInstance,
@@ -152,6 +171,10 @@ func New(
152171
createdCounter: createdCounter,
153172

154173
snapshotUpsertSem: snapshotUpsertSem,
174+
175+
// Without the node discovery loop, the local clusters registry is the
176+
// only source of orchestrator nodes.
177+
localClusterOwnsOrchestrators: skipNomadSync,
155178
}
156179

157180
o.sandboxStore = sandbox.NewStore(
@@ -180,9 +203,6 @@ func New(
180203

181204
o.teamMetricsObserver = teamMetricsObserver
182205

183-
// For local development and testing, we skip the Nomad sync
184-
// Local cluster is used for single-node setups instead
185-
skipNomadSync := env.IsLocal()
186206
go o.keepInSync(ctx, o.sandboxStore, skipNomadSync)
187207

188208
if err := o.setupMetrics(tel.MeterProvider); err != nil {

0 commit comments

Comments
 (0)