Skip to content

Commit 4c073c2

Browse files
committed
feat(api): discover orchestrators via nomad service with node-pool fallback
Discover orchestrators by listing registrations of Nomad-native services (GET /v1/service/<name> per configured name, unioned and deduped by node; names configurable via NOMAD_ORCHESTRATOR_SERVICE_NAMES, default "orchestrator") 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 4c073c2

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)