Skip to content

Commit 39d2cbe

Browse files
committed
feat(datalayer): extend file-discovery to support cluster endpoints
Signed-off-by: Liav Weiss <lweiss@redhat.com>
1 parent feafdf4 commit 39d2cbe

3 files changed

Lines changed: 305 additions & 26 deletions

File tree

pkg/epp/framework/plugins/datalayer/discovery/file/README.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ datastore on each change.
1818
## How It Works
1919

2020
- **Initial load.** On `Start`, the file is read once. Each entry is
21-
validated (address must be a parseable IP, port must be in `[1, 65535]`)
22-
and applied via `notifier.Upsert`. Per-entry validation errors are logged
23-
and the entry is skipped; file-level problems (open, parse, size > 1 MiB)
24-
abort startup.
21+
validated (address must be a valid IPv4 or RFC 1123 hostname, port must be
22+
in `[1, 65535]`) and applied via `notifier.Upsert`. Per-entry validation
23+
errors are logged and the entry is skipped; file-level problems (open,
24+
parse, size > 1 MiB) abort startup.
2525
- **Reload (optional).** When `watchFile: true`, fsnotify Write / Create /
2626
Remove events trigger a reload. After an atomic rename or ConfigMap-style
2727
symlink swap (which destroys the inode being watched), the watcher is
@@ -42,12 +42,27 @@ plugin's `path` parameter.
4242
endpoints:
4343
- name: <string> # required -- unique within the file
4444
namespace: <string> # optional -- defaults to "default"
45-
address: <IPv4> # required -- must be a valid IPv4 address
45+
address: <string> # required -- IPv4 address or RFC 1123 hostname
4646
port: <string> # required -- integer 1-65535 as a string
47+
metricsPort: <string> # optional -- metrics scrape port (defaults to port)
4748
labels: # optional -- arbitrary key/value labels
4849
<key>: <value>
4950
```
5051
52+
When `address` is an IPv4, the endpoint is treated as a pod (`PodName` is set
53+
to the entry name). When `address` is a hostname, the endpoint is treated as a
54+
cluster (`PodName` is empty).
55+
56+
### Endpoint Type Label
57+
58+
Each endpoint is tagged with the label `llm-d.ai/endpoint-type` so downstream
59+
plugins can distinguish pod endpoints from cluster endpoints explicitly. The
60+
label can be set in the endpoints file; when omitted, the plugin auto-detects
61+
it from the address format:
62+
63+
- IPv4 address → `llm-d.ai/endpoint-type: pod`
64+
- Hostname → `llm-d.ai/endpoint-type: cluster`
65+
5166
## Configuration
5267

5368
**Location:** `dataLayer.discovery.pluginRef` referencing a plugin entry of
@@ -75,7 +90,7 @@ dataLayer:
7590
pluginRef: file-discovery
7691
```
7792

78-
A two-endpoint file referenced by the config above:
93+
A two-endpoint file with pod IPs:
7994

8095
```yaml
8196
endpoints:
@@ -87,13 +102,32 @@ endpoints:
87102
port: "8000"
88103
```
89104

105+
A cluster endpoints file with host names instead of IP addresses:
106+
107+
```yaml
108+
endpoints:
109+
- name: cluster-us-east
110+
address: spoke-us-east.example.com
111+
port: "443"
112+
metricsPort: "9090"
113+
labels:
114+
region: us-east
115+
- name: cluster-eu-west
116+
address: spoke-eu-west.example.com
117+
port: "443"
118+
metricsPort: "9090"
119+
labels:
120+
region: eu-west
121+
```
122+
90123
## Limitations
91124

92125
- The endpoints file is capped at 1 MiB.
93-
- `address` must be a literal IPv4 address. Hostnames are not resolved;
94-
IPv6 is not supported.
95-
- Metrics are scraped from `address:port` (same host and port that serves
96-
inference); separate metrics endpoints are not supported.
126+
- `address` must be a valid IPv4 address or an RFC 1123 hostname. IPv6 is
127+
not supported. Hostnames are not resolved by the plugin; DNS resolution
128+
happens at scrape/connect time.
129+
- Metrics are scraped from `address:metricsPort` (or `address:port` when
130+
`metricsPort` is not set).
97131
- File-discovery mode runs the EPP without a Kubernetes controller manager,
98132
so several K8s-only features are inactive: the `InferenceModelRewrite`
99133
and `InferenceObjective` reconcilers do not run, and any

pkg/epp/framework/plugins/datalayer/discovery/file/plugin.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,36 @@ import (
2828
"os"
2929
"sort"
3030
"strconv"
31+
"strings"
3132
"sync"
3233

3334
"github.com/fsnotify/fsnotify"
3435
"k8s.io/apimachinery/pkg/types"
36+
"k8s.io/apimachinery/pkg/util/validation"
3537
"sigs.k8s.io/controller-runtime/pkg/log"
3638
"sigs.k8s.io/yaml"
3739

3840
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
3941
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
4042
)
4143

42-
const PluginType = "file-discovery"
44+
const (
45+
PluginType = "file-discovery"
46+
47+
// EndpointTypeLabel distinguishes pod from cluster endpoints.
48+
EndpointTypeLabel = "llm-d.ai/endpoint-type"
49+
EndpointTypePod = "pod"
50+
EndpointTypeCluster = "cluster"
51+
)
4352

4453
// EndpointEntry is the YAML/JSON representation of a single endpoint.
4554
type EndpointEntry struct {
46-
Name string `json:"name" yaml:"name"`
47-
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
48-
Address string `json:"address" yaml:"address"`
49-
Port string `json:"port" yaml:"port"`
50-
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
55+
Name string `json:"name" yaml:"name"`
56+
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
57+
Address string `json:"address" yaml:"address"`
58+
Port string `json:"port" yaml:"port"`
59+
MetricsPort string `json:"metricsPort,omitempty" yaml:"metricsPort,omitempty"`
60+
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
5161
}
5262

5363
// EndpointsFile is the top-level structure of the endpoints YAML/JSON file.
@@ -238,9 +248,12 @@ func (f *FileDiscovery) load(notifier fwkdl.DiscoveryNotifier) error {
238248
incoming := make(map[types.NamespacedName]struct{}, len(ef.Endpoints))
239249
var errs []error
240250
for _, e := range ef.Endpoints {
241-
if ip := net.ParseIP(e.Address); ip == nil || ip.To4() == nil {
242-
errs = append(errs, fmt.Errorf("endpoint %q: invalid IPv4 address %q", e.Name, e.Address))
243-
continue
251+
ip := net.ParseIP(e.Address)
252+
if ip == nil || ip.To4() == nil {
253+
if len(validation.IsDNS1123Subdomain(strings.ToLower(e.Address))) > 0 {
254+
errs = append(errs, fmt.Errorf("endpoint %q: address %q is not a valid IPv4 or hostname", e.Name, e.Address))
255+
continue
256+
}
244257
}
245258
if portNum, err := strconv.Atoi(e.Port); err != nil || portNum < 1 || portNum > 65535 {
246259
errs = append(errs, fmt.Errorf("endpoint %q: invalid port %q", e.Name, e.Port))
@@ -250,13 +263,32 @@ func (f *FileDiscovery) load(notifier fwkdl.DiscoveryNotifier) error {
250263
if ns == "" {
251264
ns = "default"
252265
}
266+
podName := e.Name
267+
if ip == nil {
268+
podName = ""
269+
}
270+
mPort := e.Port
271+
if e.MetricsPort != "" {
272+
mPort = e.MetricsPort
273+
}
274+
labels := e.Labels
275+
if labels == nil {
276+
labels = make(map[string]string)
277+
}
278+
if v := labels[EndpointTypeLabel]; v != EndpointTypePod && v != EndpointTypeCluster {
279+
if ip == nil {
280+
labels[EndpointTypeLabel] = EndpointTypeCluster
281+
} else {
282+
labels[EndpointTypeLabel] = EndpointTypePod
283+
}
284+
}
253285
meta := &fwkdl.EndpointMetadata{
254286
NamespacedName: types.NamespacedName{Name: e.Name, Namespace: ns},
255-
PodName: e.Name,
287+
PodName: podName,
256288
Address: e.Address,
257289
Port: e.Port,
258-
MetricsHost: net.JoinHostPort(e.Address, e.Port),
259-
Labels: e.Labels,
290+
MetricsHost: net.JoinHostPort(e.Address, mPort),
291+
Labels: labels,
260292
}
261293
incoming[meta.NamespacedName] = struct{}{}
262294
notifier.Upsert(meta)

0 commit comments

Comments
 (0)