Skip to content

Commit 32af250

Browse files
authored
feat(api): discover orchestrators via nomad service (#3176)
Switch discovery to use nomad's service discovery instead of blindly checking every node in the default nodepool. Lets us use multiple nodepools for cutting over orchestrator from system jobs to service jobs. Existing orchestrator-ee system job already registers to the service.
1 parent 7a738b7 commit 32af250

12 files changed

Lines changed: 588 additions & 54 deletions

File tree

packages/api/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ require (
4343
github.com/oapi-codegen/runtime v1.4.0
4444
github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69
4545
github.com/redis/go-redis/v9 v9.17.3
46+
github.com/samber/lo v1.53.0
4647
github.com/stretchr/testify v1.11.1
4748
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
4849
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0

packages/api/go.sum

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/internal/cfg/model.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ type Config struct {
4848
NomadAddress string `env:"NOMAD_ADDRESS" envDefault:"http://localhost:4646"`
4949
NomadToken string `env:"NOMAD_TOKEN"`
5050

51+
// NomadOrchestratorServiceNames is the comma-separated list of
52+
// Nomad-native service names whose registrations enumerate orchestrator
53+
// instances (GET /v1/service/<name> per name, results unioned). Every
54+
// orchestrator jobspec registers one of these services, regardless of
55+
// job type or node pool. Used when ServiceDiscoveryProvider=nomad.
56+
NomadOrchestratorServiceNames []string `env:"NOMAD_ORCHESTRATOR_SERVICE_NAMES" envDefault:"orchestrator" envSeparator:","`
57+
58+
// NomadOrchestratorLegacyDiscoveryEnabled enables a node-pool-based
59+
// discovery FALLBACK unioned with the service-based discovery above: all
60+
// ready Nomad nodes in the "default" pool are assumed to run an
61+
// orchestrator on the well-known port. It covers orchestrator jobs
62+
// deployed from jobspecs that register their service with an empty
63+
// Address (pre-port-label-fix), which service discovery skips as
64+
// unroutable, and removes any rollout ordering constraint between the
65+
// API and orchestrator releases. Set to false once no legacy orchestrator
66+
// jobs remain. Used when ServiceDiscoveryProvider=nomad.
67+
NomadOrchestratorLegacyDiscoveryEnabled bool `env:"NOMAD_ORCHESTRATOR_LEGACY_DISCOVERY_ENABLED" envDefault:"true"`
68+
5169
// LocalOrchestratorAddress is the "host:port" address of a statically
5270
// configured orchestrator instance. Required when
5371
// ServiceDiscoveryProvider=local. Used for local dev against the darwin

packages/api/internal/handlers/store.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,21 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, redisClient redis.U
171171
if nomadErr != nil {
172172
logger.L().Fatal(ctx, "Initializing Nomad client", zap.Error(nomadErr))
173173
}
174-
nodeDiscovery = orchdiscovery.NewNomad(nomadClient, "default")
174+
nodeDiscovery = orchdiscovery.NewNomad(nomadClient, config.NomadOrchestratorServiceNames)
175+
// Migration fallback: orchestrator jobs deployed from jobspecs that
176+
// predate the service port-label fix register their service with an
177+
// empty Address, so service discovery alone would miss them until
178+
// they are redeployed. Union in the legacy node-pool listing (service
179+
// entries win on conflict) so the API flip has no rollout ordering
180+
// constraint. Disable via NOMAD_ORCHESTRATOR_LEGACY_DISCOVERY_ENABLED
181+
// once no legacy jobs remain. The pool is hardcoded: legacy jobs only
182+
// ever ran on the "default" pool.
183+
if config.NomadOrchestratorLegacyDiscoveryEnabled {
184+
nodeDiscovery = orchdiscovery.NewMerged(
185+
nodeDiscovery,
186+
orchdiscovery.NewNomadNodePool(nomadClient, "default"),
187+
)
188+
}
175189
templateBuilderDiscovery = clustersdiscovery.NewLocalDiscovery(consts.LocalClusterID, nomadClient)
176190
}
177191

packages/api/internal/orchestrator/client_test.go

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func newTestOrchestrator(t *testing.T, nomad *nomadapi.Client) *Orchestrator {
4242

4343
return &Orchestrator{
4444
nodes: smap.New[*nodemanager.Node](),
45-
nodeDiscovery: discovery.NewNomad(nomad, "default"),
45+
nodeDiscovery: discovery.NewNomad(nomad, []string{"orchestrator"}),
4646
tel: telemetry.NewNoopClient(),
4747
}
4848
}
@@ -139,18 +139,19 @@ func TestGetOrConnectNode_CacheMiss_TriggersNomadDiscovery(t *testing.T) {
139139
var discoveryAttempts atomic.Int32
140140

141141
nomadClient := newNomadMock(t, func(w http.ResponseWriter, r *http.Request) {
142-
if r.URL.Path == "/v1/nodes" {
142+
if r.URL.Path == "/v1/service/orchestrator" {
143143
discoveryAttempts.Add(1)
144144

145-
// Return a node stub. connectToNode will fail at the gRPC level
146-
// (nodemanager.New dials the fake address), but the important thing
147-
// is that discovery WAS attempted.
148-
resp := []*nomadapi.NodeListStub{
145+
// Return a service registration. connectToNode will fail at the
146+
// gRPC level (nodemanager.New dials the fake address), but the
147+
// important thing is that discovery WAS attempted.
148+
resp := []*nomadapi.ServiceRegistration{
149149
{
150-
ID: "abcdef1234567890abcdef1234567890abcdef12",
151-
Address: "127.0.0.1",
152-
Status: "ready",
153-
NodePool: "default",
150+
ID: "_nomad-task-abc-orchestrator",
151+
ServiceName: "orchestrator",
152+
NodeID: "abcdef1234567890abcdef1234567890abcdef12",
153+
Address: "127.0.0.1",
154+
Port: int(consts.OrchestratorAPIPort),
154155
},
155156
}
156157
w.Header().Set("Content-Type", "application/json")
@@ -184,13 +185,13 @@ func TestGetOrConnectNode_ConcurrentCacheMiss_SharesDiscovery(t *testing.T) {
184185
var discoveryAttempts atomic.Int32
185186

186187
nomadClient := newNomadMock(t, func(w http.ResponseWriter, r *http.Request) {
187-
if r.URL.Path == "/v1/nodes" {
188+
if r.URL.Path == "/v1/service/orchestrator" {
188189
discoveryAttempts.Add(1)
189190
// Slow response to ensure concurrent callers overlap.
190191
time.Sleep(100 * time.Millisecond)
191192

192193
w.Header().Set("Content-Type", "application/json")
193-
json.NewEncoder(w).Encode([]*nomadapi.NodeListStub{})
194+
json.NewEncoder(w).Encode([]*nomadapi.ServiceRegistration{})
194195

195196
return
196197
}
@@ -266,8 +267,8 @@ func TestConnectToNode_SingleflightDedup(t *testing.T) {
266267
// 3. This API instance has NOT yet synced (node is absent from o.nodes).
267268
// 4. A handler calls getOrConnectNode for a sandbox on that node.
268269
//
269-
// The fake gRPC server listens on consts.OrchestratorAPIPort so that
270-
// listNomadNodes builds the correct address (ip:OrchestratorAPIPort).
270+
// The fake gRPC server listens on consts.OrchestratorAPIPort, matching the
271+
// port carried by the mocked service registration.
271272
func TestGetOrConnectNode_CacheMiss_DiscoversAndConnects(t *testing.T) {
272273
t.Parallel()
273274

@@ -279,15 +280,17 @@ func TestGetOrConnectNode_CacheMiss_DiscoversAndConnects(t *testing.T) {
279280
listenAddr := fmt.Sprintf("127.0.0.1:%d", consts.OrchestratorAPIPort)
280281
startFakeOrchestratorGRPC(t, orchestratorNodeID, listenAddr)
281282

282-
// 2. Mock Nomad HTTP API returning a single ready node at 127.0.0.1.
283+
// 2. Mock Nomad HTTP API returning a single service registration at
284+
// 127.0.0.1.
283285
nomadClient := newNomadMock(t, func(w http.ResponseWriter, r *http.Request) {
284-
if r.URL.Path == "/v1/nodes" {
285-
resp := []*nomadapi.NodeListStub{
286+
if r.URL.Path == "/v1/service/orchestrator" {
287+
resp := []*nomadapi.ServiceRegistration{
286288
{
287-
ID: nomadFullID,
288-
Address: "127.0.0.1",
289-
Status: "ready",
290-
NodePool: "default",
289+
ID: "_nomad-task-def-orchestrator",
290+
ServiceName: "orchestrator",
291+
NodeID: nomadFullID,
292+
Address: "127.0.0.1",
293+
Port: int(consts.OrchestratorAPIPort),
291294
},
292295
}
293296
w.Header().Set("Content-Type", "application/json")

packages/api/internal/orchestrator/discovery/discovery.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
// Package discovery enumerates running orchestrator (Firecracker host) instances
22
// for the API to route sandbox calls to.
33
//
4-
// The Discovery interface has two implementations:
4+
// The Discovery interface has several implementations:
55
//
6-
// - NomadDiscovery: queries the local Nomad agent's HTTP /v1/nodes endpoint.
7-
// Used by the original Nomad-based deploy where every Nomad client node
8-
// also runs an orchestrator process.
6+
// - NewNomad: lists registrations of the orchestrator's Nomad-native
7+
// services via the local Nomad agent's HTTP /v1/service/<name> endpoint
8+
// (one query per configured name, unioned). Used by the Nomad-based
9+
// deploy; node-pool- and job-agnostic.
910
//
10-
// - KubernetesDiscovery: lists pods of the orchestrator DaemonSet via the
11+
// - NewNomadNodePool: the legacy implementation that lists ready Nomad
12+
// NODES in a node pool and assumes each runs an orchestrator on the
13+
// well-known port. Kept as a migration fallback for orchestrator jobs
14+
// whose service registrations are broken (empty Address); see
15+
// nomad_node_pool.go.
16+
//
17+
// - NewMerged: unions a primary and a fallback Discovery, deduplicated by
18+
// ShortID with primary winning. Used to combine the two Nomad backends
19+
// during the migration.
20+
//
21+
// - NewKubernetes: lists pods of the orchestrator DaemonSet via the
1122
// in-cluster K8s API. Used by the K8s deploy.
1223
//
1324
// The shape of the returned []Node mirrors what the existing
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package discovery
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
8+
"github.com/samber/lo"
9+
)
10+
11+
// mergedDiscovery unions a primary and a fallback Discovery, deduplicated by
12+
// ShortID with the primary entry winning on conflict. It bridges the migration
13+
// from node-pool-based to service-based discovery: the service registration
14+
// carries the real bound port, so it wins when both backends report the same
15+
// node.
16+
//
17+
// ListNodes fails if either backend fails: the caller treats a discovery
18+
// error as "skip this cycle", which beats silently acting on a partial node
19+
// list.
20+
type mergedDiscovery struct {
21+
primary Discovery
22+
fallback Discovery
23+
}
24+
25+
// NewMerged creates a Discovery that unions primary's and fallback's nodes,
26+
// deduplicated by ShortID with primary taking precedence.
27+
func NewMerged(primary, fallback Discovery) Discovery {
28+
return &mergedDiscovery{
29+
primary: primary,
30+
fallback: fallback,
31+
}
32+
}
33+
34+
func (d *mergedDiscovery) ListNodes(ctx context.Context) ([]Node, error) {
35+
ctx, span := tracer.Start(ctx, "list-merged-nodes")
36+
defer span.End()
37+
38+
primaryNodes, err := d.primary.ListNodes(ctx)
39+
if err != nil {
40+
return nil, fmt.Errorf("primary discovery failed: %w", err)
41+
}
42+
43+
fallbackNodes, err := d.fallback.ListNodes(ctx)
44+
if err != nil {
45+
return nil, fmt.Errorf("fallback discovery failed: %w", err)
46+
}
47+
48+
// UniqBy keeps the first occurrence per key, so primary entries shadow
49+
// fallback entries with the same ShortID.
50+
return lo.UniqBy(slices.Concat(primaryNodes, fallbackNodes), func(n Node) string {
51+
return n.ShortID
52+
}), nil
53+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package discovery
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// staticDiscovery is a Discovery stub returning canned nodes or an error.
13+
type staticDiscovery struct {
14+
nodes []Node
15+
err error
16+
}
17+
18+
func (d *staticDiscovery) ListNodes(context.Context) ([]Node, error) {
19+
if d.err != nil {
20+
return nil, d.err
21+
}
22+
23+
return d.nodes, nil
24+
}
25+
26+
// The primary (service-based) entry must win on conflict: it carries the real
27+
// bound port. Dedupe itself is lo.UniqBy's job; this pins the concatenation
28+
// order that makes primary win.
29+
func TestMergedDiscovery_PrimaryWinsOnConflict(t *testing.T) {
30+
t.Parallel()
31+
32+
primary := &staticDiscovery{nodes: []Node{
33+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:6123"},
34+
}}
35+
fallback := &staticDiscovery{nodes: []Node{
36+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:5008"},
37+
}}
38+
39+
d := NewMerged(primary, fallback)
40+
nodes, err := d.ListNodes(t.Context())
41+
require.NoError(t, err)
42+
require.Len(t, nodes, 1)
43+
44+
assert.Equal(t, "aaaaaaaa", nodes[0].ShortID)
45+
assert.Equal(t, "10.0.0.1:6123", nodes[0].OrchestratorAddress, "primary (service-based) entry must win on conflict")
46+
}
47+
48+
// A primary failure fails the whole listing.
49+
func TestMergedDiscovery_PrimaryError(t *testing.T) {
50+
t.Parallel()
51+
52+
primaryErr := errors.New("nomad agent unreachable")
53+
primary := &staticDiscovery{err: primaryErr}
54+
fallback := &staticDiscovery{nodes: []Node{
55+
{ShortID: "bbbbbbbb", IPAddress: "10.0.0.2", OrchestratorAddress: "10.0.0.2:5008"},
56+
}}
57+
58+
d := NewMerged(primary, fallback)
59+
nodes, err := d.ListNodes(t.Context())
60+
require.ErrorIs(t, err, primaryErr)
61+
assert.Nil(t, nodes)
62+
}
63+
64+
// A fallback failure also fails the whole listing: no silent degradation to a
65+
// partial node list.
66+
func TestMergedDiscovery_FallbackError(t *testing.T) {
67+
t.Parallel()
68+
69+
fallbackErr := errors.New("nomad agent unreachable")
70+
primary := &staticDiscovery{nodes: []Node{
71+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:5008"},
72+
}}
73+
fallback := &staticDiscovery{err: fallbackErr}
74+
75+
d := NewMerged(primary, fallback)
76+
nodes, err := d.ListNodes(t.Context())
77+
require.ErrorIs(t, err, fallbackErr)
78+
assert.Nil(t, nodes)
79+
}

0 commit comments

Comments
 (0)