Skip to content

Commit 3adb349

Browse files
mjudeikisclaude
andcommitted
test(infra): e2e also creates an instance of each template
The template e2e proved only that kro ACCEPTS each authored RGD (GraphAccepted=True). Extend it to also CREATE a sample instance per template and assert kro reconciles it WITHOUT an apply error. This is the exact failure that motivated the schema-default image convention ("apply results contain errors: ... image: Required value"): RGD acceptance alone never exercised it, because the empty-image fault only surfaces when kro applies a child Deployment. The test now catches it. Per template: build an instance from sampleValues when present, else a minimal valid spec (e2eMinimalSpecs); retry Create until the fresh CRD is servable; poll status conditions and fail on any apply-error marker (apply results contain errors / is invalid / Required value / failed to apply / admission webhook). Readiness is out of scope — images need not pull in CI; apply validates the spec. Validated end-to-end on a fresh standalone kro kind cluster (ok 20.7s, all five templates). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent afd91f6 commit 3adb349

1 file changed

Lines changed: 157 additions & 42 deletions

File tree

providers/infrastructure/backend/kro/e2e_test.go

Lines changed: 157 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,42 +16,69 @@ See the License for the specific language governing permissions and
1616
limitations under the License.
1717
*/
1818

19-
// E2E coverage for the seeded infrastructure Templates against a real kro
20-
// cluster. Build-tagged `e2e` so it never runs in the normal `go test ./...`
21-
// unit pass — it needs a running kro controller (see `make e2e-infrastructure`).
19+
// E2E coverage for the seeded infrastructure Templates against a real, CLEAN kro
20+
// cluster (one fresh cluster per run — see `make e2e-infrastructure`). Build-
21+
// tagged `e2e` so it never runs in the normal `go test ./...` unit pass.
2222
//
23-
// What it proves: every Template the provider ships authors a kro
24-
// ResourceGraphDefinition that kro ACCEPTS (status GraphAccepted=True). This is
25-
// the exact class of failure that unit tests (which only check buildRGD output)
26-
// miss — e.g. an integer field fed a string, an includeWhen that isn't a
27-
// standalone CEL expression, or a container image that resolved to "". kro's
28-
// graph/schema validation is independent of its watch source, so this works
29-
// against any kro cluster (standalone kind in CI, or a kcp-wired dev cluster).
23+
// For every Template the provider ships it proves two things unit tests can't
24+
// (they only check buildRGD's output):
25+
//
26+
// 1. kro ACCEPTS the authored ResourceGraphDefinition (status GraphAccepted=
27+
// True) and establishes the per-template instance CRD. Catches malformed
28+
// graphs — an integer field fed a string, an includeWhen that isn't a
29+
// standalone CEL expression, etc.
30+
// 2. kro can CREATE an instance's objects: a sample instance reconciles WITHOUT
31+
// an apply error. This is the exact failure that motivated the schema-default
32+
// image convention ("apply results contain errors: ... image: Required
33+
// value"). It does NOT require the images to pull — apply validates the spec.
3034
package kro
3135

3236
import (
3337
"context"
38+
"encoding/json"
3439
"os"
3540
"path/filepath"
41+
"strings"
3642
"testing"
3743
"time"
3844

3945
apierrors "k8s.io/apimachinery/pkg/api/errors"
4046
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4147
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
48+
"k8s.io/apimachinery/pkg/runtime/schema"
4249
"k8s.io/client-go/dynamic"
4350
"k8s.io/client-go/tools/clientcmd"
51+
52+
infrav1alpha1 "github.com/faroshq/provider-infrastructure/apis/v1alpha1"
4453
)
4554

4655
const (
47-
e2eRGDPrefix = "e2e-"
48-
e2eAcceptWait = 90 * time.Second
49-
e2ePollEvery = 2 * time.Second
56+
e2eGraphWait = 90 * time.Second
57+
e2eCRDWait = 60 * time.Second
58+
e2eInstanceWait = 120 * time.Second
59+
e2ePollEvery = 2 * time.Second
5060
)
5161

52-
// TestE2ESeedTemplatesAcceptedByKro builds each seeded Template's RGD with the
53-
// real buildRGD path and asserts kro accepts the graph.
54-
func TestE2ESeedTemplatesAcceptedByKro(t *testing.T) {
62+
// e2eMinimalSpecs supplies a valid instance spec for templates that ship no
63+
// sampleValues (those that do — application, database — use them directly).
64+
var e2eMinimalSpecs = map[string]map[string]any{
65+
"redis-cache": {"name": "e2e-redis"},
66+
"simple-webapp": {"name": "e2e-web"},
67+
"sandbox-runner": {"name": "kedge-sandbox-0000111122223333", "projectRef": "e2e"},
68+
}
69+
70+
// e2eApplyErrorMarkers are substrings kro puts in an instance condition when it
71+
// FAILS to apply a child resource (the bug class we guard against). Readiness
72+
// waits (pods not up because an image can't pull in CI) do not contain these.
73+
var e2eApplyErrorMarkers = []string{
74+
"apply results contain errors",
75+
"is invalid",
76+
"Required value",
77+
"failed to apply",
78+
"admission webhook",
79+
}
80+
81+
func TestE2ESeedTemplates(t *testing.T) {
5582
dyn := e2eDynamicClient(t)
5683
dir := filepath.Join("..", "..", "install", "templates")
5784
entries, err := os.ReadDir(dir)
@@ -71,25 +98,34 @@ func TestE2ESeedTemplatesAcceptedByKro(t *testing.T) {
7198
t.Fatalf("read %s: %v", entry.Name(), err)
7299
}
73100
tmpl := decodeTemplate(t, raw)
101+
74102
rgd, err := buildRGD(tmpl, testTokens())
75103
if err != nil {
76104
t.Fatalf("buildRGD(%s): %v", tmpl.Name, err)
77105
}
78-
// Apply under a throwaway name so the test doesn't clobber any
79-
// RGDs a dev cluster already seeded; GraphAccepted is name-
80-
// independent. The per-template instance CRD it would create may
81-
// collide with a seeded one (KindReady), which is fine — we only
82-
// assert the graph is accepted.
83-
name := e2eRGDPrefix + tmpl.Name
84-
rgd.SetName(name)
85-
applyRGDForTest(t, dyn, rgd)
86-
t.Cleanup(func() { deleteRGDForTest(dyn, name) })
87-
88-
reason, msg := waitGraphAccepted(t, dyn, name)
89-
if reason != "True" {
90-
t.Fatalf("kro rejected RGD for template %q: GraphAccepted=%s: %s", tmpl.Name, reason, msg)
106+
applyRGD(t, dyn, rgd)
107+
t.Cleanup(func() { _ = dyn.Resource(rgdGVR).Delete(context.Background(), rgd.GetName(), metav1.DeleteOptions{}) })
108+
109+
// 1. kro accepts the graph.
110+
if status, msg := waitGraphAccepted(t, dyn, rgd.GetName()); status != "True" {
111+
t.Fatalf("kro rejected RGD for template %q: GraphAccepted=%s: %s", tmpl.Name, status, msg)
112+
}
113+
t.Logf("template %q: RGD accepted", tmpl.Name)
114+
115+
// 2. kro creates an instance's objects without an apply error.
116+
instGVR := schema.GroupVersionResource{
117+
Group: tmpl.Spec.InstanceCRD.Group,
118+
Version: tmpl.Spec.InstanceCRD.Version,
119+
Resource: tmpl.Spec.InstanceCRD.Resource,
91120
}
92-
t.Logf("template %q: kro accepted the RGD", tmpl.Name)
121+
inst := e2eInstance(t, tmpl)
122+
createInstance(t, dyn, instGVR, inst)
123+
t.Cleanup(func() {
124+
_ = dyn.Resource(instGVR).Delete(context.Background(), inst.GetName(), metav1.DeleteOptions{})
125+
})
126+
127+
waitInstanceApplied(t, dyn, instGVR, inst.GetName(), tmpl.Name)
128+
t.Logf("template %q: instance reconciled (objects applied)", tmpl.Name)
93129
})
94130
}
95131
if seen == 0 {
@@ -101,7 +137,7 @@ func e2eDynamicClient(t *testing.T) dynamic.Interface {
101137
t.Helper()
102138
kubeconfig := os.Getenv("KUBECONFIG")
103139
if kubeconfig == "" {
104-
t.Skip("KUBECONFIG not set; this e2e needs a kro cluster (see make e2e-infrastructure)")
140+
t.Skip("KUBECONFIG not set; this e2e needs a clean kro cluster (see make e2e-infrastructure)")
105141
}
106142
cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
107143
if err != nil {
@@ -114,7 +150,7 @@ func e2eDynamicClient(t *testing.T) dynamic.Interface {
114150
return dyn
115151
}
116152

117-
func applyRGDForTest(t *testing.T, dyn dynamic.Interface, rgd *unstructured.Unstructured) {
153+
func applyRGD(t *testing.T, dyn dynamic.Interface, rgd *unstructured.Unstructured) {
118154
t.Helper()
119155
ctx := context.Background()
120156
existing, err := dyn.Resource(rgdGVR).Get(ctx, rgd.GetName(), metav1.GetOptions{})
@@ -133,39 +169,118 @@ func applyRGDForTest(t *testing.T, dyn dynamic.Interface, rgd *unstructured.Unst
133169
}
134170
}
135171

136-
func deleteRGDForTest(dyn dynamic.Interface, name string) {
137-
_ = dyn.Resource(rgdGVR).Delete(context.Background(), name, metav1.DeleteOptions{})
172+
// e2eInstance builds a sample instance: the template's sampleValues when present
173+
// (the curated working example), otherwise a minimal valid spec.
174+
func e2eInstance(t *testing.T, tmpl *infrav1alpha1.Template) *unstructured.Unstructured {
175+
t.Helper()
176+
spec := map[string]any{}
177+
if sv := tmpl.Spec.SampleValues; sv != nil && len(sv.Raw) > 0 {
178+
if err := json.Unmarshal(sv.Raw, &spec); err != nil {
179+
t.Fatalf("template %q: decode sampleValues: %v", tmpl.Name, err)
180+
}
181+
} else if min, ok := e2eMinimalSpecs[tmpl.Name]; ok {
182+
spec = min
183+
} else {
184+
t.Fatalf("template %q has no sampleValues and no e2eMinimalSpecs entry — add one", tmpl.Name)
185+
}
186+
name, _ := spec["name"].(string)
187+
if name == "" {
188+
name = "e2e-" + tmpl.Name
189+
spec["name"] = name
190+
}
191+
return &unstructured.Unstructured{Object: map[string]any{
192+
"apiVersion": tmpl.Spec.InstanceCRD.Group + "/" + tmpl.Spec.InstanceCRD.Version,
193+
"kind": tmpl.Spec.InstanceCRD.Kind,
194+
"metadata": map[string]any{"name": name},
195+
"spec": spec,
196+
}}
197+
}
198+
199+
// createInstance retries Create until kro has established + served the
200+
// per-template CRD (a fresh RGD takes a few seconds to register the kind).
201+
func createInstance(t *testing.T, dyn dynamic.Interface, gvr schema.GroupVersionResource, inst *unstructured.Unstructured) {
202+
t.Helper()
203+
deadline := time.Now().Add(e2eCRDWait)
204+
for {
205+
_, err := dyn.Resource(gvr).Create(context.Background(), inst, metav1.CreateOptions{})
206+
if err == nil || apierrors.IsAlreadyExists(err) {
207+
return
208+
}
209+
// "no matches for kind" / NotFound while the CRD is still registering.
210+
if time.Now().After(deadline) {
211+
t.Fatalf("create %s instance %q: CRD never became servable: %v", gvr.Resource, inst.GetName(), err)
212+
}
213+
time.Sleep(e2ePollEvery)
214+
}
215+
}
216+
217+
// waitInstanceApplied waits until kro has reconciled the instance and asserts it
218+
// applied its objects without an apply error. A readiness wait (images not
219+
// pulled in CI) is success — apply succeeded. An apply-error marker is failure.
220+
func waitInstanceApplied(t *testing.T, dyn dynamic.Interface, gvr schema.GroupVersionResource, name, tmplName string) {
221+
t.Helper()
222+
deadline := time.Now().Add(e2eInstanceWait)
223+
var sawConditions bool
224+
for time.Now().Before(deadline) {
225+
obj, err := dyn.Resource(gvr).Get(context.Background(), name, metav1.GetOptions{})
226+
if err != nil {
227+
time.Sleep(e2ePollEvery)
228+
continue
229+
}
230+
conds, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions")
231+
for _, c := range conds {
232+
cond, ok := c.(map[string]any)
233+
if !ok {
234+
continue
235+
}
236+
sawConditions = true
237+
msg, _, _ := unstructured.NestedString(cond, "message")
238+
for _, marker := range e2eApplyErrorMarkers {
239+
if strings.Contains(msg, marker) {
240+
ctype, _, _ := unstructured.NestedString(cond, "type")
241+
t.Fatalf("template %q: kro failed to apply instance objects (%s): %s", tmplName, ctype, msg)
242+
}
243+
}
244+
}
245+
// kro reconciled it and recorded conditions, none of which are apply
246+
// errors → the objects were applied. (Readiness is out of scope: the
247+
// images may be unpullable in CI.)
248+
if sawConditions {
249+
return
250+
}
251+
time.Sleep(e2ePollEvery)
252+
}
253+
if !sawConditions {
254+
t.Fatalf("template %q: kro never reconciled instance %q within %s", tmplName, name, e2eInstanceWait)
255+
}
138256
}
139257

140-
// waitGraphAccepted polls the RGD until its GraphAccepted condition resolves to
141-
// True or False (Unknown/AwaitingReconciliation keeps waiting), returning the
142-
// final status + message.
143258
func waitGraphAccepted(t *testing.T, dyn dynamic.Interface, name string) (string, string) {
144259
t.Helper()
145-
deadline := time.Now().Add(e2eAcceptWait)
260+
deadline := time.Now().Add(e2eGraphWait)
146261
var lastStatus, lastMsg string
147262
for time.Now().Before(deadline) {
148263
obj, err := dyn.Resource(rgdGVR).Get(context.Background(), name, metav1.GetOptions{})
149264
if err == nil {
150-
lastStatus, lastMsg = graphAcceptedCondition(obj)
265+
lastStatus, lastMsg = conditionByType(obj, "GraphAccepted")
151266
if lastStatus == "True" || lastStatus == "False" {
152267
return lastStatus, lastMsg
153268
}
154269
}
155270
time.Sleep(e2ePollEvery)
156271
}
157-
t.Fatalf("timed out after %s waiting for RGD %q GraphAccepted (last=%q: %s)", e2eAcceptWait, name, lastStatus, lastMsg)
272+
t.Fatalf("timed out after %s waiting for RGD %q GraphAccepted (last=%q: %s)", e2eGraphWait, name, lastStatus, lastMsg)
158273
return lastStatus, lastMsg
159274
}
160275

161-
func graphAcceptedCondition(obj *unstructured.Unstructured) (string, string) {
276+
func conditionByType(obj *unstructured.Unstructured, condType string) (string, string) {
162277
conds, _, _ := unstructured.NestedSlice(obj.Object, "status", "conditions")
163278
for _, c := range conds {
164279
cond, ok := c.(map[string]any)
165280
if !ok {
166281
continue
167282
}
168-
if t, _, _ := unstructured.NestedString(cond, "type"); t != "GraphAccepted" {
283+
if tp, _, _ := unstructured.NestedString(cond, "type"); tp != condType {
169284
continue
170285
}
171286
status, _, _ := unstructured.NestedString(cond, "status")

0 commit comments

Comments
 (0)