Skip to content

Commit 273592c

Browse files
authored
Merge pull request #3 from xrstf/waiter
Add initialization.kcp.io/wait-for-ready annotation
2 parents 1cba14d + b7a0e16 commit 273592c

File tree

7 files changed

+624
-9
lines changed

7 files changed

+624
-9
lines changed

docs/content/.pages

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ nav:
22
- Documentation:
33
- README.md
44
- setup.md
5+
- readiness.md
56
- faq.md
67
- Init Sources: init-sources
78
- Reference: reference

docs/content/readiness.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Waiting for Readiness
2+
3+
In some scenarios, simply creating objects during initialization is not enough. You may need to wait for
4+
certain resources to become ready before the init-agent marks the workspace as initialized. For example,
5+
a CRD must be `Established` before custom resources using it can be created by other initializers
6+
further down the chain.
7+
8+
The `initialization.kcp.io/wait-for-ready` annotation allows you to express this requirement on
9+
individual manifests.
10+
11+
## How It Works
12+
13+
When the init-agent encounters a manifest (from any of its [init sources](./init-sources/)) with
14+
the `initialization.kcp.io/wait-for-ready` annotation, it will:
15+
16+
1. Create (or confirm the existence of) the object as usual.
17+
2. Re-fetch the object's current state from the API server.
18+
3. Check whether the condition type specified in the annotation's value has `status: "True"` in the
19+
object's `status.conditions` list.
20+
4. If the condition is not yet `True`, the agent requeues reconciliation and tries again after a few
21+
seconds.
22+
5. Only once **all** annotated objects across **all** sources have their required conditions met will
23+
the agent remove the initializer from the workspace, completing initialization.
24+
25+
The annotation value must be the **name of a condition type** (e.g. `Established`, `Ready`,
26+
`Available`). This condition must appear in the standard Kubernetes `status.conditions` array of
27+
the resource.
28+
29+
!!! warning "Important"
30+
Waiting for a condition to become `True` inherently means that **some process must set that
31+
condition**. In some cases this happens automatically (e.g. the Kubernetes API server sets
32+
`Established` on CRDs), but in other cases you may need a dedicated controller or operator to
33+
act on the resource and update its status.
34+
35+
Due to the nature of kcp's workspace initialization, the workspace is not accessible through
36+
the regular API while it still has initializers. Only processes that work through the same
37+
`initializingworkspaces` virtual workspace – i.e. processes that are registered for the **same
38+
initializer** as the init-agent – can see and modify objects in the workspace during
39+
initialization.
40+
41+
This means that if you need an external controller to make a resource "ready", that controller
42+
must also operate on the same initializer's `initializingworkspaces` view. Without this, the
43+
controller will not be able to access the workspace and therefore cannot set the condition the
44+
init-agent is waiting for. The initialization would be stuck indefinitely.
45+
46+
In practice, this is most relevant for custom operators that need to reconcile resources created
47+
by the init-agent. Make sure these operators have the appropriate kcp permissions and are
48+
configured to watch the same initializing workspaces.
49+
50+
## Usage
51+
52+
Add the annotation to any manifest inside an `InitTemplate`'s `spec.template`. The following example
53+
creates a CRD and waits for it to become `Established` before initialization is considered complete:
54+
55+
```yaml
56+
apiVersion: initialization.kcp.io/v1alpha1
57+
kind: InitTemplate
58+
metadata:
59+
name: widgets-crd
60+
spec:
61+
template: |
62+
apiVersion: apiextensions.k8s.io/v1
63+
kind: CustomResourceDefinition
64+
metadata:
65+
name: widgets.example.com
66+
annotations:
67+
initialization.kcp.io/wait-for-ready: "Established"
68+
spec:
69+
group: example.com
70+
names:
71+
kind: Widget
72+
listKind: WidgetList
73+
plural: widgets
74+
singular: widget
75+
scope: Cluster
76+
versions:
77+
- name: v1alpha1
78+
served: true
79+
storage: true
80+
schema:
81+
openAPIV3Schema:
82+
type: object
83+
```
84+
85+
In this example the init-agent will create the CRD and then wait until its `Established` condition
86+
is `True` before considering this source complete. CRDs are just a nice example of a Kube-native
87+
resource that on its own becomes ready.
88+
89+
The agent will keep retrying indefinitely. If a condition is never set, the workspace will remain
90+
in the initializing state. Use kcp's workspace lifecycle management to handle stuck workspaces if
91+
necessary.
92+
93+
The annotation works with **any Kubernetes resource** that follows the standard conditions pattern
94+
in its status. Common examples include:
95+
96+
| Resource | Typical Condition |
97+
| -------- | ----------------- |
98+
| `CustomResourceDefinition` | `Established` |
99+
| `Deployment` | `Available` |
100+
| `APIBinding` (kcp) | `Ready` |

internal/manifest/applier.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import (
2121
"errors"
2222
"strings"
2323

24+
"go.uber.org/zap"
25+
2426
"github.com/kcp-dev/init-agent/internal/log"
27+
"github.com/kcp-dev/init-agent/sdk/types"
2528

2629
apierrors "k8s.io/apimachinery/pkg/api/errors"
2730
"k8s.io/apimachinery/pkg/api/meta"
@@ -53,18 +56,38 @@ func (a *applier) Apply(ctx context.Context, client ctrlruntimeclient.Client, ob
5356
}
5457
}
5558

56-
return false, nil
57-
}
59+
// After creating objects, check readiness of annotated ones
60+
for _, object := range objs {
61+
conditionType := object.GetAnnotations()[types.WaitForReadyAnnotation]
62+
if conditionType == "" {
63+
continue
64+
}
5865

59-
func (a *applier) applyObject(ctx context.Context, client ctrlruntimeclient.Client, obj *unstructured.Unstructured) error {
60-
gvk := obj.GroupVersionKind()
66+
// Fetch current state
67+
current := &unstructured.Unstructured{}
68+
current.SetGroupVersionKind(object.GroupVersionKind())
6169

62-
key := ctrlruntimeclient.ObjectKeyFromObject(obj).String()
63-
// make key look prettier for cluster-scoped objects
64-
key = strings.TrimLeft(key, "/")
70+
if err := client.Get(ctx, ctrlruntimeclient.ObjectKeyFromObject(object), current); err != nil {
71+
if apierrors.IsNotFound(err) {
72+
requeue = true
73+
continue
74+
}
75+
return false, err
76+
}
77+
78+
if !HasCondition(current, conditionType) {
79+
logger := a.objectLogger(ctx, object)
80+
logger.Debugw("Waiting for condition", "condition", conditionType)
81+
requeue = true
82+
}
83+
}
6584

66-
logger := log.FromContext(ctx)
67-
logger.Debugw("Applying object", "obj-key", key, "obj-gvk", gvk)
85+
return requeue, nil
86+
}
87+
88+
func (a *applier) applyObject(ctx context.Context, client ctrlruntimeclient.Client, obj *unstructured.Unstructured) error {
89+
logger := a.objectLogger(ctx, obj)
90+
logger.Debugw("Applying object")
6891

6992
if err := client.Create(ctx, obj); err != nil {
7093
if !apierrors.IsAlreadyExists(err) {
@@ -74,3 +97,13 @@ func (a *applier) applyObject(ctx context.Context, client ctrlruntimeclient.Clie
7497

7598
return nil
7699
}
100+
101+
func (a *applier) objectLogger(ctx context.Context, obj *unstructured.Unstructured) *zap.SugaredLogger {
102+
gvk := obj.GroupVersionKind()
103+
104+
key := ctrlruntimeclient.ObjectKeyFromObject(obj).String()
105+
// make key look prettier for cluster-scoped objects
106+
key = strings.TrimLeft(key, "/")
107+
108+
return log.FromContext(ctx).With("obj-key", key, "obj-gvk", gvk)
109+
}

internal/manifest/readiness.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
Copyright 2026 The kcp Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package manifest
18+
19+
import (
20+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
21+
)
22+
23+
// HasCondition checks if an unstructured object has the specified condition
24+
// type with status "True".
25+
func HasCondition(obj *unstructured.Unstructured, conditionType string) bool {
26+
conditions, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions")
27+
if err != nil || !found {
28+
return false
29+
}
30+
31+
for _, c := range conditions {
32+
condition, ok := c.(map[string]any)
33+
if !ok {
34+
continue
35+
}
36+
37+
cType, _, _ := unstructured.NestedString(condition, "type")
38+
cStatus, _, _ := unstructured.NestedString(condition, "status")
39+
if cType == conditionType && cStatus == "True" {
40+
return true
41+
}
42+
}
43+
44+
return false
45+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
Copyright 2026 The kcp Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package manifest
18+
19+
import (
20+
"testing"
21+
22+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
23+
)
24+
25+
func TestHasCondition(t *testing.T) {
26+
testcases := []struct {
27+
name string
28+
obj *unstructured.Unstructured
29+
conditionType string
30+
expected bool
31+
}{
32+
{
33+
name: "no status",
34+
obj: newUnstructured("v1", "ConfigMap", "test"),
35+
conditionType: "Ready",
36+
expected: false,
37+
},
38+
{
39+
name: "no conditions",
40+
obj: newUnstructuredWithStatus("v1", "ConfigMap", "test", map[string]any{}),
41+
conditionType: "Ready",
42+
expected: false,
43+
},
44+
{
45+
name: "empty conditions",
46+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{}),
47+
conditionType: "Ready",
48+
expected: false,
49+
},
50+
{
51+
name: "condition type not found",
52+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
53+
map[string]any{"type": "Available", "status": "True"},
54+
}),
55+
conditionType: "Ready",
56+
expected: false,
57+
},
58+
{
59+
name: "condition found but status is False",
60+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
61+
map[string]any{"type": "Ready", "status": "False"},
62+
}),
63+
conditionType: "Ready",
64+
expected: false,
65+
},
66+
{
67+
name: "condition found but status is Unknown",
68+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
69+
map[string]any{"type": "Ready", "status": "Unknown"},
70+
}),
71+
conditionType: "Ready",
72+
expected: false,
73+
},
74+
{
75+
name: "condition found with status True",
76+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
77+
map[string]any{"type": "Ready", "status": "True"},
78+
}),
79+
conditionType: "Ready",
80+
expected: true,
81+
},
82+
{
83+
name: "multiple conditions - target is True",
84+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
85+
map[string]any{"type": "Available", "status": "True"},
86+
map[string]any{"type": "Ready", "status": "True"},
87+
map[string]any{"type": "Progressing", "status": "False"},
88+
}),
89+
conditionType: "Ready",
90+
expected: true,
91+
},
92+
{
93+
name: "multiple conditions - target is False",
94+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
95+
map[string]any{"type": "Available", "status": "True"},
96+
map[string]any{"type": "Ready", "status": "False"},
97+
map[string]any{"type": "Progressing", "status": "True"},
98+
}),
99+
conditionType: "Ready",
100+
expected: false,
101+
},
102+
{
103+
name: "CRD Established condition True",
104+
obj: newUnstructuredWithConditions("apiextensions.k8s.io/v1", "CustomResourceDefinition", "test", []any{
105+
map[string]any{"type": "NamesAccepted", "status": "True"},
106+
map[string]any{"type": "Established", "status": "True"},
107+
}),
108+
conditionType: "Established",
109+
expected: true,
110+
},
111+
{
112+
name: "CRD Established condition False",
113+
obj: newUnstructuredWithConditions("apiextensions.k8s.io/v1", "CustomResourceDefinition", "test", []any{
114+
map[string]any{"type": "NamesAccepted", "status": "True"},
115+
map[string]any{"type": "Established", "status": "False"},
116+
}),
117+
conditionType: "Established",
118+
expected: false,
119+
},
120+
{
121+
name: "malformed condition entry (not a map)",
122+
obj: newUnstructuredWithConditions("v1", "ConfigMap", "test", []any{
123+
"not a map",
124+
map[string]any{"type": "Ready", "status": "True"},
125+
}),
126+
conditionType: "Ready",
127+
expected: true,
128+
},
129+
}
130+
131+
for _, tt := range testcases {
132+
t.Run(tt.name, func(t *testing.T) {
133+
result := HasCondition(tt.obj, tt.conditionType)
134+
if result != tt.expected {
135+
t.Fatalf("Expected %v.", tt.expected)
136+
}
137+
})
138+
}
139+
}
140+
141+
func newUnstructuredWithStatus(apiVersion, kind, name string, status map[string]any) *unstructured.Unstructured {
142+
obj := newUnstructured(apiVersion, kind, name)
143+
obj.Object["status"] = status
144+
return obj
145+
}
146+
147+
func newUnstructuredWithConditions(apiVersion, kind, name string, conditions []any) *unstructured.Unstructured {
148+
return newUnstructuredWithStatus(apiVersion, kind, name, map[string]any{
149+
"conditions": conditions,
150+
})
151+
}

0 commit comments

Comments
 (0)