@@ -16,42 +16,69 @@ See the License for the specific language governing permissions and
1616limitations 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.
3034package kro
3135
3236import (
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
4655const (
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.
143258func 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