Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
f97255a
feat(router): per-worker circuit breaker in both data planes
jiejingzhangamd Aug 4, 2026
546663b
feat(tools): GPU-free fake worker for testing everything above the en…
jiejingzhangamd Aug 4, 2026
e6152b7
feat(tools): fake worker supports PD and DP-attention routing
jiejingzhangamd Aug 4, 2026
8af53f6
feat: graceful scale-down — drain in-flight generations before stopping
jiejingzhangamd Aug 4, 2026
05e5111
fix(drain): corrections found by running a real SGLang engine
jiejingzhangamd Aug 4, 2026
0b697f2
docs(scaling): how to add and remove workers, with measured numbers
jiejingzhangamd Aug 4, 2026
088af55
fix(router): fail over on a unary 5xx, so the breaker actually sees it
jiejingzhangamd Aug 4, 2026
ab05f4f
docs(scaling): multi-node — the two things that actually differ
jiejingzhangamd Aug 4, 2026
84f3c85
docs(scaling): separate "stops receiving" from "record disappears"
jiejingzhangamd Aug 4, 2026
6364a01
fix(operator): derive terminationGracePeriodSeconds from --drain-timeout
jiejingzhangamd Aug 4, 2026
50aa449
docs(scaling): measure the Kubernetes drain path on a live cluster
jiejingzhangamd Aug 4, 2026
5c7fe8c
docs(scaling): measure the drain with a real engine on the recipe path
jiejingzhangamd Aug 4, 2026
5cac722
style: strip trailing whitespace in the GLM-5.2 1P1D example
jiejingzhangamd Aug 4, 2026
7289dc1
fix(router): name the empty pool when half a PD deployment is left
jiejingzhangamd Aug 4, 2026
81ff397
feat(tools): fake worker supports the NATS request transport
jiejingzhangamd Aug 5, 2026
fc954a5
docs(scaling): the transport decides how good the drain can be
jiejingzhangamd Aug 5, 2026
b89b1fd
fix(kv): pull a newly registered snapshot target immediately
jiejingzhangamd Aug 5, 2026
f2d7f7a
docs(scaling): measure the NATS JetStream admission throttle
jiejingzhangamd Aug 5, 2026
cf6b448
docs(readme): say how scaling works, with the measured numbers
jiejingzhangamd Aug 5, 2026
6d5de0c
feat(operator): standard Kubernetes /scale surface via a scaling adapter
jiejingzhangamd Aug 5, 2026
cb5f913
fix(kvd): resolve a mount's devices through sysfs when lsblk cannot
llying-001 Aug 5, 2026
f794a9e
Merge pull request #86 from AMD-AGI/release/overlay-dual-publish
jiejingzhangamd Aug 4, 2026
8a9289e
fix(kvd): scope the sysfs FC check to the device being classified
llying-001 Aug 5, 2026
4dfe4fe
Merge pull request #85 from AMD-AGI/llying/dev/glm5p2_fp8_kvd_sglang
jiejingzhangamd Aug 5, 2026
b0a1799
build(tools): check gofmt in pre-commit
weilei0120 Aug 7, 2026
73ef1e6
fix(k8s): show a draining worker instead of dropping its record
weilei0120 Aug 7, 2026
8067f6c
fix(nats): don't strand queued work or leave the consumer behind
weilei0120 Aug 7, 2026
cfbf7c0
fix(operator): read the drain timeout from the environment too
weilei0120 Aug 7, 2026
24073a5
build(operator): regenerate what the release actually ships
weilei0120 Aug 5, 2026
9786541
revert(operator): drop the scaling adapter, keep one writer
weilei0120 Aug 7, 2026
df01c7c
build(operator): restart the version line at 0.1.0
weilei0120 Aug 5, 2026
1b4d33f
fix(router): stop the breaker wedging a worker that has recovered
weilei0120 Aug 8, 2026
21de36b
build(operator): publish to staging, as the engine images already do
weilei0120 Aug 5, 2026
1c49ed7
fix(server): forget a departed worker, and say when a drain metric is…
weilei0120 Aug 8, 2026
bb81c33
docs(recipes): install the operator from the published chart
weilei0120 Aug 5, 2026
da17856
fix(operator): report ready only when the requested replicas are there
weilei0120 Aug 8, 2026
ea60d7c
fix(operator): generate the RBAC the manager itself needs
weilei0120 Aug 5, 2026
66d52d7
fix(router): bring the Rust breaker's probe slot in line with Python
weilei0120 Aug 8, 2026
6f42a2d
fix(tests): a tier that cannot run must not report PASS
xiaobochen-amd Aug 6, 2026
11397e1
docs(recipes): add the GLM-5.2-FP8 gfx942 PD+kvd Kubernetes recipe
llying-001 Aug 6, 2026
6ffa715
ci: a failed squeue is not an empty queue — reclaim has to keep trying
xiaobochen-amd Aug 6, 2026
0be5dee
fix(recipes): the gfx942 recipe's io-mode rationale describes a fixed…
llying-001 Aug 6, 2026
331ab01
fix(tests): an unreachable controller is not a deleted reservation
xiaobochen-amd Aug 6, 2026
fea6d46
docs(examples): add the GLM-5.2-FP8 gfx942 PD bring-up recipe
llying-001 Aug 6, 2026
50d8dcc
fix(tests): finish separating a failed SLURM query from an empty result
xiaobochen-amd Aug 6, 2026
2ddb8b6
fix(recipes): carry the measured chunk and MTP shape into the gfx942 …
llying-001 Aug 6, 2026
d4b6b99
fix(tests): say why a SLURM query failed, not just that it did
xiaobochen-amd Aug 6, 2026
c6b26b9
fix(examples): refuse an unresolved node address instead of defaultin…
llying-001 Aug 7, 2026
fb7fa66
fix(router): render chat templates the way transformers does, or kv-a…
llying-001 Aug 6, 2026
8b6f982
ci: one event per state of the code, not two per commit
xiaobochen-amd Aug 7, 2026
e1a8892
fix(router): index only the attention KV-cache groups
jiejingzhangamd Aug 7, 2026
b20484e
Merge pull request #91 from AMD-AGI/llying/fix/kvd-storage-classify-s…
xiaobochen-amd Aug 5, 2026
3ebad45
docs(recipes): the validation-status row contradicted the numbers it …
llying-001 Aug 7, 2026
6aab24d
fix(kvd,sglang): return sglang's StorageMetrics from the adapter's ge…
llying-001 Aug 6, 2026
221701c
ci: key the concurrency group on the PR, not the branch name
xiaobochen-amd Aug 7, 2026
421a5f4
test(router): replay a real Kimi-K3 event stream, not just the filter…
jiejingzhangamd Aug 7, 2026
de65a9d
chore(examples): drop the GLM-5.2 results/ directory
dorado269 Aug 6, 2026
1d98911
Merge pull request #96 from AMD-AGI/llying/dev/glm5p2_fp8_docs
jiejingzhangamd Aug 7, 2026
871ca00
ci: run the DCO check once per pull request, not twice
xiaobochen-amd Aug 7, 2026
14b866a
test(router): drive the hybrid pipeline over real ZMQ, in both event …
jiejingzhangamd Aug 7, 2026
d443cd5
Potential fix for pull request finding
jiejingzhangamd Aug 7, 2026
851bd0c
Merge pull request #94 from AMD-AGI/yihou/chore/drop-glm52-example-re…
jiejingzhangamd Aug 7, 2026
85b8470
ci: pin the last three actions to a commit
xiaobochen-amd Aug 7, 2026
bb22008
test(router): pin routing outcomes that are decidable by hand
jiejingzhangamd Aug 7, 2026
e69acf8
Merge pull request #90 from AMD-AGI/feature/weilei/build_operator
jiejingzhangamd Aug 7, 2026
a4bf10c
fix(tests): the engine tier must not pass on zero collected tests
xiaobochen-amd Aug 7, 2026
fd7f30f
Merge pull request #98 from AMD-AGI/fix/kv-event-group-filter
jiejingzhangamd Aug 7, 2026
fa4a7ef
ci(release): stop the pypi job racing the Release into existence
jiejingzhangamd Aug 7, 2026
6d49738
Merge pull request #97 from AMD-AGI/dev/xiaobo/test
jiejingzhangamd Aug 7, 2026
e10a969
fix(router): publish policy_active_blocks where the refcounts change
jiejingzhangamd Aug 7, 2026
eacda25
Merge pull request #100 from AMD-AGI/fix/release-workflow-release-race
jiejingzhangamd Aug 8, 2026
a7d5a43
build(docker): drop the Go toolchain Mooncake's dependencies.sh installs
jiejingzhangamd Aug 8, 2026
f5aae0d
build(docker): drop pip/setuptools/wheel from the kvd and server images
jiejingzhangamd Aug 8, 2026
5cc0c9f
build(docker): remove the Rust toolchain from the engine images
jiejingzhangamd Aug 6, 2026
fff2709
Merge pull request #99 from AMD-AGI/fix/policy-active-blocks-gauge
jiejingzhangamd Aug 8, 2026
6ac2a18
build(docker): trim the comments on the Go removal
jiejingzhangamd Aug 8, 2026
0d04d32
Merge pull request #102 from AMD-AGI/fix/drop-build-tooling-from-kvd-…
jiejingzhangamd Aug 8, 2026
3a7c68c
build(docker): move setuptools past its vendored-dependency advisories
jiejingzhangamd Aug 8, 2026
f6caed8
Merge pull request #105 from AMD-AGI/fix/drop-rust-toolchain-from-eng…
jiejingzhangamd Aug 8, 2026
82cdd72
fix(router): keep a load signal between requests in kv-aware routing
jiejingzhangamd Aug 8, 2026
a3c1c21
Merge pull request #101 from AMD-AGI/fix/drop-go-toolchain-from-moonc…
jiejingzhangamd Aug 8, 2026
0db300c
fix(router): charge for picks the hasher produced no blocks for
jiejingzhangamd Aug 8, 2026
bc60603
Merge pull request #106 from AMD-AGI/fix/upgrade-setuptools-in-engine…
jiejingzhangamd Aug 8, 2026
ac9a111
Merge pull request #103 from AMD-AGI/fix/kv-aware-load-term-survives-…
jiejingzhangamd Aug 8, 2026
9edaca9
Merge pull request #107 from AMD-AGI/fix/kv-aware-charge-zero-block-p…
jiejingzhangamd Aug 8, 2026
c64a02e
Merge branch 'main' into fix/router-circuit-breaker
weilei0120 Aug 8, 2026
ae26aa3
fix(router): score each PD leg from its own response
weilei0120 Aug 8, 2026
9866a53
fix(drain): let each backend own the shutdown signal it can actually …
weilei0120 Aug 9, 2026
31b2bb5
feat(operator): refuse the external etcd backend for in-cluster deplo…
weilei0120 Aug 9, 2026
97fb9f1
docs(scaling): say which backend knows a worker is leaving, and why
weilei0120 Aug 9, 2026
3ed3424
fix(drain): drop the DRAINING announcement; the backend decides the o…
weilei0120 Aug 10, 2026
84f0046
docs(features): add a graceful shutdown page, scoped to Kubernetes
weilei0120 Aug 10, 2026
9f05eb2
docs(features): trim the graceful shutdown page to the shape of the f…
weilei0120 Aug 10, 2026
f679f7b
fix(router): close three gaps in how PD legs are scored
weilei0120 Aug 10, 2026
5f91365
fix(operator): let a removed spec field be removed from the child
weilei0120 Aug 10, 2026
a086e9b
fix: harden the breaker's edges -- poisoned lock, trip count, label e…
weilei0120 Aug 10, 2026
efb7afa
fix(tools): stop the fake worker forging metrics, double-shutting-dow…
weilei0120 Aug 10, 2026
2e5a44b
docs(quickstart): note what the dev path gives up on shutdown
weilei0120 Aug 10, 2026
06659e9
Merge branch 'main' into fix/router-circuit-breaker
weilei0120 Aug 10, 2026
07a3893
style: let ruff format an assertion message
weilei0120 Aug 10, 2026
2d25697
fix(drain): deregister before draining on every backend
weilei0120 Aug 10, 2026
fc9b0bd
fix(operator): make the etcd refusal terminal instead of retried forever
weilei0120 Aug 10, 2026
ed5fa6c
docs: both backends deregister before draining now
weilei0120 Aug 10, 2026
27fa444
fix(drain): report an etcd revoke the server refused, and pin the order
weilei0120 Aug 10, 2026
24bc508
test(discovery): cover the kill path that has no deletionTimestamp
weilei0120 Aug 10, 2026
e663bf9
Merge branch 'main' into fix/router-circuit-breaker
weilei0120 Aug 10, 2026
0efc8e7
revert(tools): drop the fake worker from the release package
weilei0120 Aug 10, 2026
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
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ repos:
files: ^rust/.*\.rs$
pass_filenames: false

# Go formatting gate (needs a local toolchain). Only the operator is Go.
# `go vet` and the tests are heavier, so they run in CI.
- id: gofmt
name: gofmt (go)
entry: scripts/check-gofmt.sh
language: script
files: ^deploy/operator/.*\.go$

# Refuse a commit whose git email is a machine-generated local hostname
# (git's fallback when user.email is unset), so an internal build-host
# name can't leak into permanent public history. Skipped in CI.
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Around them:

- **Multi-engine** — run vLLM, SGLang, or ATOM behind one common serving interface.
- **OpenAI- and Anthropic-compatible API** — `/v1/chat/completions`, `/v1/completions`, and `/v1/messages` (Anthropic Messages, translated in-process).
- **Self-registering fleet** — workers register into etcd and heartbeat, so the router works from a live view and never routes to a worker that is gone; run any number of stateless server replicas.
- **Self-registering fleet** — workers register into etcd (or their own Pod annotation on Kubernetes) and heartbeat, so the router works from a live view and never routes to a worker that is gone; run any number of stateless server replicas.
- **Scale without dropping requests** — a worker joins when it is ready and leaves by draining: it announces `DRAINING`, finishes the generations it already accepted, and only then deregisters. Measured on MI355X: a worker stops receiving new work **under a second** after `SIGTERM` while its in-flight 4000-token generations all complete, and adding or removing instances under continuous traffic costs **zero failed requests**. See [Scaling a fleet](https://rocm.docs.amd.com/projects/infera/en/latest/features/scaling.html).
- **Kubernetes-native** — an operator reconciles an `InferaDeployment` CRD (aggregated / PD / multi-node), with an optional Gateway API (GAIE) endpoint picker.

## Architecture
Expand Down Expand Up @@ -146,6 +147,24 @@ kubectl apply -f examples/k8s-deployments/single-node-aggregated.yaml
Ready-to-fill deployment templates (single-node, prefill/decode, multi-node TP, GAIE) and their
placeholders are in [`examples/k8s-deployments/`](examples/k8s-deployments/README.md).

**Scaling.** There is no scaling controller — workers self-register when ready and deregister when
they drain, and the router routes to whatever is registered at that instant. Scaling is therefore
`kubectl scale` on the CR, or starting and stopping workers; nothing has to be told about it.

The two directions cost very different things, and it shapes everything built on top:

| | measured |
|---|---|
| scale up: `docker run` → serving | **140 s**, almost all of it weight loading |
| scale down: `SIGTERM` → stops receiving | **< 1 s** |
| scale down: in-flight generations | run to completion, bounded by `--drain-timeout` |
| adding + removing under traffic | **260 requests, 0 failures** |

Because a cold start is minutes and the control loop is seconds, **a burst shorter than a cold
start cannot be answered by adding workers** — keep headroom, or steer traffic to instances that
are already running. Infera does not ship an autoscaler; [Scaling a
fleet](https://rocm.docs.amd.com/projects/infera/en/latest/features/scaling.html) documents what is in place for one, and what is not.

## Engine images

Prebuilt images are published to the `rocm/infera` repository on
Expand Down
2 changes: 1 addition & 1 deletion deploy/operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import (
"flag"
"os"

clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
Expand Down
243 changes: 243 additions & 0 deletions deploy/operator/internal/controller/apply_idempotence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved.

SPDX-License-Identifier: MIT
*/

package controller

import (
"context"
"testing"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

inferav1alpha1 "github.com/amd/infera/deploy/operator/api/v1alpha1"
)

// The operator writes three fields of a LeaderWorkerSet; the API server fills
// in the rest from the CRD's defaults -- startupPolicy, rolloutStrategy,
// leaderWorkerTemplate.restartPolicy and more, nine of them on LWS v1.
//
// Replacing the whole .spec strips every one of those on each pass, the API
// server puts them back, and the next pass strips them again. That was a
// wasted write every resync; it becomes a hot loop now that the reconciler
// watches LeaderWorkerSet, because the write it just made enqueues the
// request that makes the next one.
//
// So: reconciling an object that is already in the desired state must not
// write to it.
func TestApplyingTheSameLwsTwiceDoesNotWriteAgain(t *testing.T) {
s := testScheme(t)
if err := inferav1alpha1.AddToScheme(s); err != nil {
t.Fatalf("scheme: %v", err)
}
c := fake.NewClientBuilder().WithScheme(s).Build()
r := &InferaDeploymentReconciler{Client: c, Scheme: s}
ctx := context.Background()

idep := &inferav1alpha1.InferaDeployment{}
idep.Name = "qwen"
idep.Namespace = "default"
idep.UID = "uid-1"

desired := func() *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(lwsGVK())
u.SetName("qwen-worker")
u.SetNamespace("default")
_ = unstructured.SetNestedField(u.Object, int64(2), "spec", "replicas")
_ = unstructured.SetNestedField(u.Object, int64(2),
"spec", "leaderWorkerTemplate", "size")
return u
}

if err := r.applyUnstructured(ctx, idep, desired()); err != nil {
t.Fatalf("first apply: %v", err)
}

// Stand in for the API server defaulting the fields the operator omits.
live := &unstructured.Unstructured{}
live.SetGroupVersionKind(lwsGVK())
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-worker", Namespace: "default"}, live); err != nil {
t.Fatalf("get after create: %v", err)
}
_ = unstructured.SetNestedField(live.Object, "LeaderCreated", "spec", "startupPolicy")
_ = unstructured.SetNestedField(live.Object, "RollingUpdate", "spec", "rolloutStrategy", "type")
_ = unstructured.SetNestedField(live.Object, "RecreateGroupOnPodRestart",
"spec", "leaderWorkerTemplate", "restartPolicy")
if err := c.Update(ctx, live); err != nil {
t.Fatalf("apply defaults: %v", err)
}
before := live.GetResourceVersion()

if err := r.applyUnstructured(ctx, idep, desired()); err != nil {
t.Fatalf("second apply: %v", err)
}

after := &unstructured.Unstructured{}
after.SetGroupVersionKind(lwsGVK())
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-worker", Namespace: "default"}, after); err != nil {
t.Fatalf("get after reconcile: %v", err)
}

if got := after.GetResourceVersion(); got != before {
t.Errorf("reconcile rewrote an unchanged object (resourceVersion %s -> %s); "+
"with the LWS watch registered this is a write loop", before, got)
}
for _, f := range [][]string{
{"spec", "startupPolicy"},
{"spec", "rolloutStrategy", "type"},
{"spec", "leaderWorkerTemplate", "restartPolicy"},
} {
if v, ok, _ := unstructured.NestedString(after.Object, f...); !ok || v == "" {
t.Errorf("%v was stripped; the API server will re-default it and the "+
"next pass strips it again", f)
}
}
}

// Merging must not turn into "never update": a genuine spec change still has
// to reach the child, or scaling through the CR would silently do nothing.
func TestApplyStillPushesAChangedField(t *testing.T) {
s := testScheme(t)
c := fake.NewClientBuilder().WithScheme(s).Build()
r := &InferaDeploymentReconciler{Client: c, Scheme: s}
ctx := context.Background()

idep := &inferav1alpha1.InferaDeployment{}
idep.Name = "qwen"
idep.Namespace = "default"
idep.UID = "uid-1"

build := func(replicas int64) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(lwsGVK())
u.SetName("qwen-worker")
u.SetNamespace("default")
_ = unstructured.SetNestedField(u.Object, replicas, "spec", "replicas")
return u
}

if err := r.applyUnstructured(ctx, idep, build(2)); err != nil {
t.Fatalf("create: %v", err)
}
if err := r.applyUnstructured(ctx, idep, build(5)); err != nil {
t.Fatalf("scale: %v", err)
}

got := &unstructured.Unstructured{}
got.SetGroupVersionKind(lwsGVK())
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-worker", Namespace: "default"}, got); err != nil {
t.Fatalf("get: %v", err)
}
if v, _, _ := unstructured.NestedInt64(got.Object, "spec", "replicas"); v != 5 {
t.Fatalf("replicas = %d, want 5 -- scaling through the CR did not land", v)
}
}

// A field the builder emits only when the CR asks for it -- HTTPRoute's
// hostnames -- has to disappear from the child when it disappears from the CR.
// Merging alone cannot do that: an absent field looks the same as one the
// operator does not manage, so the old value would survive and the route would
// keep matching a host the user deleted.
func TestRemovingAConditionalFieldClearsItOnTheChild(t *testing.T) {
s := testScheme(t)
c := fake.NewClientBuilder().WithScheme(s).Build()
r := &InferaDeploymentReconciler{Client: c, Scheme: s}
ctx := context.Background()

idep := &inferav1alpha1.InferaDeployment{}
idep.Name = "qwen"
idep.Namespace = "default"
idep.UID = "uid-1"

route := func(hostnames []any) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion(httpRouteAPIVersion)
u.SetKind(httpRouteKind)
u.SetName("qwen-route")
u.SetNamespace("default")
spec := map[string]any{"rules": []any{}}
if len(hostnames) > 0 {
spec["hostnames"] = hostnames
}
_ = unstructured.SetNestedMap(u.Object, spec, "spec")
return u
}

if err := r.applyUnstructured(ctx, idep, route([]any{"a.example.com"})); err != nil {
t.Fatalf("create with hostnames: %v", err)
}
if err := r.applyUnstructured(ctx, idep, route(nil)); err != nil {
t.Fatalf("reapply without hostnames: %v", err)
}

got := &unstructured.Unstructured{}
got.SetAPIVersion(httpRouteAPIVersion)
got.SetKind(httpRouteKind)
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-route", Namespace: "default"}, got); err != nil {
t.Fatalf("get: %v", err)
}
if v, ok, _ := unstructured.NestedSlice(got.Object, "spec", "hostnames"); ok {
t.Fatalf("hostnames still %v after removal from the CR; the route keeps "+
"matching a host the user deleted", v)
}
}

// Pruning owned fields must not start pruning the server's defaults again --
// that is the write loop this merge exists to stop.
func TestPruningLeavesServerDefaultsAlone(t *testing.T) {
s := testScheme(t)
c := fake.NewClientBuilder().WithScheme(s).Build()
r := &InferaDeploymentReconciler{Client: c, Scheme: s}
ctx := context.Background()

idep := &inferav1alpha1.InferaDeployment{}
idep.Name = "qwen"
idep.Namespace = "default"
idep.UID = "uid-1"

desired := func() *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(lwsGVK())
u.SetName("qwen-worker")
u.SetNamespace("default")
_ = unstructured.SetNestedField(u.Object, int64(2), "spec", "replicas")
_ = unstructured.SetNestedField(u.Object, int64(2), "spec", "leaderWorkerTemplate", "size")
return u
}

if err := r.applyUnstructured(ctx, idep, desired()); err != nil {
t.Fatalf("create: %v", err)
}

live := &unstructured.Unstructured{}
live.SetGroupVersionKind(lwsGVK())
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-worker", Namespace: "default"}, live); err != nil {
t.Fatalf("get: %v", err)
}
_ = unstructured.SetNestedField(live.Object, "LeaderCreated", "spec", "startupPolicy")
if err := c.Update(ctx, live); err != nil {
t.Fatalf("apply defaults: %v", err)
}
before := live.GetResourceVersion()

if err := r.applyUnstructured(ctx, idep, desired()); err != nil {
t.Fatalf("second apply: %v", err)
}

after := &unstructured.Unstructured{}
after.SetGroupVersionKind(lwsGVK())
if err := c.Get(ctx, types.NamespacedName{Name: "qwen-worker", Namespace: "default"}, after); err != nil {
t.Fatalf("get: %v", err)
}
if v, ok, _ := unstructured.NestedString(after.Object, "spec", "startupPolicy"); !ok || v == "" {
t.Error("a server default the operator does not set was pruned")
}
if got := after.GetResourceVersion(); got != before {
t.Errorf("reconcile rewrote an unchanged object (%s -> %s)", before, got)
}
}
Loading
Loading