Skip to content

Commit 37a7881

Browse files
committed
feat(api): discover orchestrators via nomad service with node-pool fallback
Discover orchestrators by listing registrations of the Nomad-native "orchestrator" service (GET /v1/service/<name>, name configurable via NOMAD_ORCHESTRATOR_SERVICE_NAME) instead of listing ready Nomad nodes in the hardcoded "default" pool. Service discovery is node-pool- and job-agnostic, which is required while orchestrators migrate to the health-gated service job on the dedicated "orchestrator" pool. ShortID stays the truncated Nomad node ID, so node identity is stable across the backend switch. Already-running orchestrators were deployed from a jobspec that registers the service with an empty Address, so service discovery alone would miss them until redeployed. To avoid that rollout ordering constraint, the old node-pool listing is kept as a fallback and unioned with service discovery: dedup is by ShortID with service entries winning (they carry the real bound port), and a failure of either backend fails the whole listing rather than silently degrading to a subset of nodes. The fallback also keeps draining nodes (still Status=="ready") discoverable after their services deregister. The fallback is on by default; set NOMAD_ORCHESTRATOR_LEGACY_DISCOVERY_ENABLED=false once no legacy jobs remain.
1 parent 7dae471 commit 37a7881

10 files changed

Lines changed: 575 additions & 47 deletions

File tree

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+
// NomadOrchestratorServiceName is the Nomad-native service name whose
52+
// registrations enumerate orchestrator instances
53+
// (GET /v1/service/<name>). Every orchestrator jobspec registers this
54+
// service, regardless of job type or node pool. Used when
55+
// ServiceDiscoveryProvider=nomad.
56+
NomadOrchestratorServiceName string `env:"NOMAD_ORCHESTRATOR_SERVICE_NAME" envDefault:"orchestrator"`
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.NomadOrchestratorServiceName)
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, "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: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
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+
// service via the local Nomad agent's HTTP /v1/service/<name> endpoint.
8+
// Used by the Nomad-based deploy; node-pool- and job-agnostic.
99
//
10-
// - KubernetesDiscovery: lists pods of the orchestrator DaemonSet via the
10+
// - NewNomadNodePool: the legacy implementation that lists ready Nomad
11+
// NODES in a node pool and assumes each runs an orchestrator on the
12+
// well-known port. Kept as a migration fallback for orchestrator jobs
13+
// whose service registrations are broken (empty Address); see
14+
// nomad_node_pool.go.
15+
//
16+
// - NewMerged: unions a primary and a fallback Discovery, deduplicated by
17+
// ShortID with primary winning. Used to combine the two Nomad backends
18+
// during the migration.
19+
//
20+
// - NewKubernetes: lists pods of the orchestrator DaemonSet via the
1121
// in-cluster K8s API. Used by the K8s deploy.
1222
//
1323
// The shape of the returned []Node mirrors what the existing
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package discovery
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// mergedDiscovery unions the results of a primary and a fallback Discovery,
9+
// deduplicated by ShortID with the PRIMARY entry winning on conflict. It
10+
// bridges the migration from node-pool-based to service-based discovery: the
11+
// service registration carries the real bound port, so it must win when both
12+
// backends report the same node.
13+
//
14+
// ListNodes fails if EITHER backend fails: failing loudly beats silently
15+
// degrading to a subset of orchestrators, and the caller
16+
// (Orchestrator.syncNodes) treats a discovery error as "skip this cycle,
17+
// keep current state".
18+
type mergedDiscovery struct {
19+
primary Discovery
20+
fallback Discovery
21+
}
22+
23+
// NewMerged creates a Discovery that returns the union of primary's and
24+
// fallback's nodes, deduplicated by ShortID with primary's entries taking
25+
// precedence. See mergedDiscovery for the exact semantics.
26+
func NewMerged(primary, fallback Discovery) Discovery {
27+
return &mergedDiscovery{
28+
primary: primary,
29+
fallback: fallback,
30+
}
31+
}
32+
33+
func (d *mergedDiscovery) ListNodes(ctx context.Context) ([]Node, error) {
34+
ctx, span := tracer.Start(ctx, "list-merged-nodes")
35+
defer span.End()
36+
37+
primaryNodes, err := d.primary.ListNodes(ctx)
38+
if err != nil {
39+
return nil, fmt.Errorf("primary discovery failed: %w", err)
40+
}
41+
42+
fallbackNodes, err := d.fallback.ListNodes(ctx)
43+
if err != nil {
44+
return nil, fmt.Errorf("fallback discovery failed: %w", err)
45+
}
46+
47+
out := make([]Node, 0, len(primaryNodes)+len(fallbackNodes))
48+
seen := make(map[string]struct{}, len(primaryNodes))
49+
50+
// Backends dedupe their own results, so only cross-backend conflicts
51+
// need resolving: primary entries are taken as-is and shadow fallback
52+
// entries with the same ShortID.
53+
for _, n := range primaryNodes {
54+
seen[n.ShortID] = struct{}{}
55+
out = append(out, n)
56+
}
57+
58+
for _, n := range fallbackNodes {
59+
if _, ok := seen[n.ShortID]; ok {
60+
continue
61+
}
62+
out = append(out, n)
63+
}
64+
65+
return out, nil
66+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
// TestMergedDiscovery_DisjointUnion verifies that nodes known only to one
27+
// backend are all present in the merged result.
28+
func TestMergedDiscovery_DisjointUnion(t *testing.T) {
29+
t.Parallel()
30+
31+
primary := &staticDiscovery{nodes: []Node{
32+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:5008"},
33+
}}
34+
fallback := &staticDiscovery{nodes: []Node{
35+
{ShortID: "bbbbbbbb", IPAddress: "10.0.0.2", OrchestratorAddress: "10.0.0.2:5008"},
36+
}}
37+
38+
d := NewMerged(primary, fallback)
39+
nodes, err := d.ListNodes(t.Context())
40+
require.NoError(t, err)
41+
require.Len(t, nodes, 2)
42+
43+
shortIDs := []string{nodes[0].ShortID, nodes[1].ShortID}
44+
assert.ElementsMatch(t, []string{"aaaaaaaa", "bbbbbbbb"}, shortIDs)
45+
}
46+
47+
// TestMergedDiscovery_PrimaryWinsOnConflict verifies dedup by ShortID with the
48+
// primary's entry winning: the service registration carries the orchestrator's
49+
// real bound port, while the node-listing fallback assumes the well-known one.
50+
func TestMergedDiscovery_PrimaryWinsOnConflict(t *testing.T) {
51+
t.Parallel()
52+
53+
primary := &staticDiscovery{nodes: []Node{
54+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:6123"},
55+
}}
56+
fallback := &staticDiscovery{nodes: []Node{
57+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:5008"},
58+
}}
59+
60+
d := NewMerged(primary, fallback)
61+
nodes, err := d.ListNodes(t.Context())
62+
require.NoError(t, err)
63+
require.Len(t, nodes, 1)
64+
65+
assert.Equal(t, "aaaaaaaa", nodes[0].ShortID)
66+
assert.Equal(t, "10.0.0.1:6123", nodes[0].OrchestratorAddress, "primary (service-based) entry must win on conflict")
67+
}
68+
69+
// TestMergedDiscovery_PrimaryError verifies that a primary failure fails the
70+
// whole listing (no silent degradation to fallback-only results).
71+
func TestMergedDiscovery_PrimaryError(t *testing.T) {
72+
t.Parallel()
73+
74+
primaryErr := errors.New("nomad agent unreachable")
75+
primary := &staticDiscovery{err: primaryErr}
76+
fallback := &staticDiscovery{nodes: []Node{
77+
{ShortID: "bbbbbbbb", IPAddress: "10.0.0.2", OrchestratorAddress: "10.0.0.2:5008"},
78+
}}
79+
80+
d := NewMerged(primary, fallback)
81+
nodes, err := d.ListNodes(t.Context())
82+
require.ErrorIs(t, err, primaryErr)
83+
assert.Nil(t, nodes)
84+
}
85+
86+
// TestMergedDiscovery_FallbackError verifies that a fallback failure also
87+
// fails the whole listing: both backends hit the same local Nomad agent, so a
88+
// partial result is more likely a bug than a real partial outage, and the
89+
// sync loop treats a discovery error as "skip this cycle".
90+
func TestMergedDiscovery_FallbackError(t *testing.T) {
91+
t.Parallel()
92+
93+
fallbackErr := errors.New("nomad agent unreachable")
94+
primary := &staticDiscovery{nodes: []Node{
95+
{ShortID: "aaaaaaaa", IPAddress: "10.0.0.1", OrchestratorAddress: "10.0.0.1:5008"},
96+
}}
97+
fallback := &staticDiscovery{err: fallbackErr}
98+
99+
d := NewMerged(primary, fallback)
100+
nodes, err := d.ListNodes(t.Context())
101+
require.ErrorIs(t, err, fallbackErr)
102+
assert.Nil(t, nodes)
103+
}
104+
105+
// TestMergedDiscovery_BothEmpty verifies the union of two empty results is an
106+
// empty, non-error result.
107+
func TestMergedDiscovery_BothEmpty(t *testing.T) {
108+
t.Parallel()
109+
110+
d := NewMerged(&staticDiscovery{}, &staticDiscovery{})
111+
nodes, err := d.ListNodes(t.Context())
112+
require.NoError(t, err)
113+
assert.Empty(t, nodes)
114+
}

0 commit comments

Comments
 (0)