Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ var envMapping = map[string]map[string]string{
"base_url": "SIGNOZ_BASE_URL",
},
"nomad": {
"address": "NOMAD_ADDR",
"token": "NOMAD_TOKEN",
"address": "NOMAD_ADDR",
"addresses": "NOMAD_ADDRS",
"token": "NOMAD_TOKEN",
},
"agents": {
"base_url": "AGENTS_BASE_URL",
Expand Down Expand Up @@ -368,7 +369,7 @@ func defaultConfig() *mcp.Config {
},
"nomad": {
Enabled: false,
Credentials: mcp.Credentials{"address": "", "token": ""},
Credentials: mcp.Credentials{"address": "", "addresses": "", "token": ""},
},
"agents": {
Enabled: false,
Expand Down
3 changes: 2 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func TestDefaultConfig(t *testing.T) {
"vercel": {"api_token", "team_id", "team_slug", "base_url"},
"web": {},
"signoz": {"api_key", "base_url", "skip_verify"},
"nomad": {"address", "token"},
"nomad": {"address", "addresses", "token"},
}

for name, keys := range expected {
Expand Down Expand Up @@ -511,6 +511,7 @@ func TestEnvMapping_ReturnsMapping(t *testing.T) {
assert.Equal(t, "X_BEARER_TOKEN", m["x"]["bearer_token"])
assert.Equal(t, "SIGNOZ_API_KEY", m["signoz"]["api_key"])
assert.Equal(t, "NOMAD_ADDR", m["nomad"]["address"])
assert.Equal(t, "NOMAD_ADDRS", m["nomad"]["addresses"])
assert.Equal(t, "NOMAD_TOKEN", m["nomad"]["token"])
assert.Equal(t, "STRIPE_API_KEY", m["stripe"]["api_key"])
assert.Equal(t, "STRIPE_ACCOUNT", m["stripe"]["account"])
Expand Down
134 changes: 122 additions & 12 deletions integrations/nomad/nomad.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
"unicode"

mcp "github.com/daltoniam/switchboard"
"github.com/daltoniam/switchboard/compact"
Expand All @@ -28,12 +31,19 @@ var (
_ mcp.Integration = (*nomad)(nil)
_ mcp.FieldCompactionIntegration = (*nomad)(nil)
_ mcp.ToolMaxBytesIntegration = (*nomad)(nil)
_ mcp.PlaceholderHints = (*nomad)(nil)
_ mcp.OptionalCredentials = (*nomad)(nil)
)

type nomad struct {
address string
token string
client *http.Client
addresses []string
token string
client *http.Client

// preferred is the index of the server address to try first. It advances to
// the last server that answered so a downed leader is not retried on every
// call (sticky failover). Safe for concurrent use.
preferred atomic.Uint32
}

func New() mcp.Integration {
Expand All @@ -45,15 +55,31 @@ func New() mcp.Integration {
func (n *nomad) Name() string { return "nomad" }

func (n *nomad) Configure(_ context.Context, creds mcp.Credentials) error {
n.address = creds["address"]
if n.address == "" {
n.addresses = parseAddresses(creds["address"], creds["addresses"])
if len(n.addresses) == 0 {
return fmt.Errorf("nomad: address is required")
}
n.address = strings.TrimRight(n.address, "/")
n.token = creds["token"]
n.preferred.Store(0)
return nil
}

// Placeholders provides web-UI hints for the credential fields.
func (n *nomad) Placeholders() map[string]string {
return map[string]string{
"address": "http://localhost:4646",
"addresses": "HA: comma- or newline-separated, e.g. http://server1:4646,http://server2:4646",
"token": "Nomad ACL token (optional)",
}
}

// OptionalKeys marks credentials that are not strictly required. Either address
// or addresses must be set; addresses (the HA server list) and the ACL token are
// optional on their own.
func (n *nomad) OptionalKeys() []string {
return []string{"addresses", "token"}
}

func (n *nomad) Healthy(ctx context.Context) bool {
if n.client == nil {
return false
Expand Down Expand Up @@ -86,30 +112,64 @@ func (n *nomad) Execute(ctx context.Context, toolName mcp.ToolName, args map[str

// --- HTTP helpers ---

// doRequest issues an HTTP request to the configured Nomad server(s). In HA
// deployments multiple server addresses may be configured; doRequest performs
// sticky failover: it starts at the last server that answered and walks the
// remaining servers in order. Definitive (non-retryable) responses are returned
// immediately, while transport failures and retryable statuses (429/5xx) move on
// to the next server.
func (n *nomad) doRequest(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
var bodyReader io.Reader
var bodyData []byte
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
bodyData = data
}

if len(n.addresses) == 0 {
return nil, fmt.Errorf("nomad: no server address configured")
}

start := int(n.preferred.Load()) % len(n.addresses)
var lastErr error
for i := range n.addresses {
idx := (start + i) % len(n.addresses)
data, err := n.doRequestTo(ctx, n.addresses[idx], method, path, bodyData, body != nil)
if err == nil {
n.preferred.Store(uint32(idx))
return data, nil
}
lastErr = err
if ctx.Err() != nil || !shouldFailover(err) {
return nil, err
}
}
return nil, lastErr
}

req, err := http.NewRequestWithContext(ctx, method, n.address+path, bodyReader)
// doRequestTo issues a single request against one server address.
func (n *nomad) doRequestTo(ctx context.Context, address, method, path string, bodyData []byte, hasBody bool) (json.RawMessage, error) {
var bodyReader io.Reader
if hasBody {
bodyReader = bytes.NewReader(bodyData)
}

req, err := http.NewRequestWithContext(ctx, method, address+path, bodyReader)
if err != nil {
return nil, err
}
if n.token != "" {
req.Header.Set("X-Nomad-Token", n.token)
}
if body != nil {
if hasBody {
req.Header.Set("Content-Type", "application/json")
}

resp, err := n.client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("nomad: request to %s failed: %w", address, err)
}
defer func() { _ = resp.Body.Close() }()

Expand All @@ -124,14 +184,64 @@ func (n *nomad) doRequest(ctx context.Context, method, path string, body any) (j
return nil, re
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("nomad API error (%d): %s", resp.StatusCode, string(data))
return nil, &apiError{StatusCode: resp.StatusCode, Body: string(data)}
}
if resp.StatusCode == 204 || len(data) == 0 {
return json.RawMessage(`{"status":"success"}`), nil
}
return json.RawMessage(data), nil
}

// apiError is a definitive (non-retryable) HTTP error from a reachable Nomad
// server. Failover treats these as authoritative and does not retry the request
// against another server.
type apiError struct {
StatusCode int
Body string
}

func (e *apiError) Error() string {
return fmt.Sprintf("nomad API error (%d): %s", e.StatusCode, e.Body)
}

// shouldFailover reports whether a failed request should be retried against the
// next server. Definitive 4xx responses (apiError) are authoritative and are not
// retried; transport failures and retryable statuses (429/5xx) are.
func shouldFailover(err error) bool {
if err == nil {
return false
}
var ae *apiError
return !errors.As(err, &ae)
}

// parseAddresses splits one or more raw credential values into a deduplicated,
// ordered list of Nomad server base URLs. Values may be separated by commas or
// any whitespace (spaces, tabs, newlines), so a single multi-line credential
// field or a comma-separated NOMAD_ADDR both work for HA deployments. Trailing
// slashes are trimmed and duplicates are dropped while preserving order.
func parseAddresses(values ...string) []string {
var out []string
seen := make(map[string]struct{})
for _, v := range values {
fields := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || unicode.IsSpace(r)
})
for _, field := range fields {
addr := strings.TrimRight(field, "/")
if addr == "" {
continue
}
if _, dup := seen[addr]; dup {
continue
}
seen[addr] = struct{}{}
out = append(out, addr)
}
}
return out
}

func (n *nomad) get(ctx context.Context, pathFmt string, args ...any) (json.RawMessage, error) {
return n.doRequest(ctx, "GET", fmt.Sprintf(pathFmt, args...), nil)
}
Expand Down
Loading
Loading