Skip to content

Commit 71d4820

Browse files
authored
Cluster scoped admissions (#114)
* Admission: Enforce namespace scoped resources * Implement ClusterAdmission
1 parent f20e1ae commit 71d4820

File tree

14 files changed

+1174
-48
lines changed

14 files changed

+1174
-48
lines changed

admission/handler.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,31 @@ func (h *handler) handle(ctx context.Context, admissionKey types.NamespacedName,
106106
return admission.Errored(http.StatusBadRequest, errors.New("missing namespace and name in context"))
107107
}
108108

109-
var adm espejotev1alpha1.Admission
110-
if err := h.Client.Get(ctx, admissionKey, &adm); err != nil {
111-
if apierrors.IsNotFound(err) {
112-
return admission.Allowed("Admission not found")
109+
var admNamespace, admTemplate string
110+
if admissionKey.Namespace != "" {
111+
var adm espejotev1alpha1.Admission
112+
if err := h.Client.Get(ctx, admissionKey, &adm); err != nil {
113+
if apierrors.IsNotFound(err) {
114+
return admission.Allowed("Admission not found")
115+
}
116+
return admission.Errored(http.StatusInternalServerError, err)
117+
}
118+
admNamespace = adm.Namespace
119+
admTemplate = adm.Spec.Template
120+
} else {
121+
var admCluster espejotev1alpha1.ClusterAdmission
122+
if err := h.Client.Get(ctx, types.NamespacedName{Name: admissionKey.Name}, &admCluster); err != nil {
123+
if apierrors.IsNotFound(err) {
124+
return admission.Allowed("ClusterAdmission not found")
125+
}
126+
return admission.Errored(http.StatusInternalServerError, err)
113127
}
114-
return admission.Errored(http.StatusInternalServerError, err)
128+
admNamespace = ""
129+
admTemplate = admCluster.Spec.Template
115130
}
116131

117132
jvm := jsonnet.MakeVM()
118-
jvm.Importer(controllers.FromClientImporter(h.Client, adm.GetNamespace(), h.JsonnetLibraryNamespace))
133+
jvm.Importer(controllers.FromClientImporter(h.Client, admNamespace, h.JsonnetLibraryNamespace))
119134
jvm.NativeFunction(applyPatchNativeFunction)
120135

121136
reqJson, err := json.Marshal(req)
@@ -124,7 +139,7 @@ func (h *handler) handle(ctx context.Context, admissionKey types.NamespacedName,
124139
}
125140
jvm.ExtCode("__internal_use_espejote_lib_admissionrequest", string(reqJson))
126141

127-
ret, err := jvm.EvaluateAnonymousSnippet("admission", adm.Spec.Template)
142+
ret, err := jvm.EvaluateAnonymousSnippet("admission", admTemplate)
128143
if err != nil {
129144
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to evaluate jsonnet: %w", err))
130145
}

admission/handler_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ func Test_Handler_AdmissionNotFound(t *testing.T) {
5353
assert.Equal(t, "Admission not found", admres.Response.Result.Message)
5454
}
5555

56+
func Test_Handler_ClusterAdmissionNotFound(t *testing.T) {
57+
t.Parallel()
58+
59+
c := buildFakeClient(t)
60+
61+
subject := espadmission.NewHandler(c, "default")
62+
require.NotNil(t, subject)
63+
64+
w := httptest.NewRecorder()
65+
req := newClusterScopedAdmissionRequest(t, "test", admissionv1.AdmissionRequest{
66+
UID: "test",
67+
})
68+
subject.ServeHTTP(w, req)
69+
res := w.Result()
70+
require.Equal(t, http.StatusOK, res.StatusCode)
71+
72+
require.NotNil(t, res.Body)
73+
defer res.Body.Close()
74+
var admres admissionv1.AdmissionReview
75+
require.NoError(t, json.NewDecoder(res.Body).Decode(&admres))
76+
require.NotNil(t, admres.Response)
77+
require.NotNil(t, admres.Response.Result)
78+
assert.Equal(t, http.StatusOK, int(admres.Response.Result.Code))
79+
assert.Equal(t, "ClusterAdmission not found", admres.Response.Result.Message)
80+
}
81+
5682
func Test_Handler_Allowed(t *testing.T) {
5783
t.Parallel()
5884

@@ -91,6 +117,43 @@ func Test_Handler_Allowed(t *testing.T) {
91117
assert.Equal(t, "Nice job!", admres.Response.Result.Message)
92118
}
93119

120+
func Test_Handler_Allowed_ClusterScoped(t *testing.T) {
121+
t.Parallel()
122+
123+
c := buildFakeClient(t, &espejotev1alpha1.ClusterAdmission{
124+
ObjectMeta: metav1.ObjectMeta{
125+
Name: "test",
126+
},
127+
Spec: espejotev1alpha1.ClusterAdmissionSpec{
128+
Template: `
129+
local esp = import 'espejote.libsonnet';
130+
131+
esp.ALPHA.admission.allowed("Nice job!")
132+
`,
133+
},
134+
})
135+
136+
subject := espadmission.NewHandler(c, "default")
137+
require.NotNil(t, subject)
138+
139+
w := httptest.NewRecorder()
140+
req := newClusterScopedAdmissionRequest(t, "test", admissionv1.AdmissionRequest{
141+
UID: "test",
142+
})
143+
subject.ServeHTTP(w, req)
144+
res := w.Result()
145+
require.Equal(t, http.StatusOK, res.StatusCode)
146+
147+
require.NotNil(t, res.Body)
148+
defer res.Body.Close()
149+
var admres admissionv1.AdmissionReview
150+
require.NoError(t, json.NewDecoder(res.Body).Decode(&admres))
151+
require.NotNil(t, admres.Response)
152+
require.NotNil(t, admres.Response.Result)
153+
assert.Equal(t, http.StatusOK, int(admres.Response.Result.Code))
154+
assert.Equal(t, "Nice job!", admres.Response.Result.Message)
155+
}
156+
94157
func Test_Handler_Denied(t *testing.T) {
95158
t.Parallel()
96159

@@ -266,6 +329,23 @@ func newAdmissionRequest(t *testing.T, name, namespace string, admreq admissionv
266329
return req
267330
}
268331

332+
func newClusterScopedAdmissionRequest(t *testing.T, name string, admreq admissionv1.AdmissionRequest) *http.Request {
333+
t.Helper()
334+
335+
b := admissionv1.AdmissionReview{
336+
Request: &admreq,
337+
}
338+
b.SetGroupVersionKind(admissionv1.SchemeGroupVersion.WithKind("AdmissionReview"))
339+
340+
body := new(bytes.Buffer)
341+
require.NoError(t, json.NewEncoder(body).Encode(b))
342+
req := httptest.NewRequest("GET", path.Join("/dynamic-cluster", name), body)
343+
req = req.WithContext(log.IntoContext(req.Context(), testr.New(t)))
344+
req.Header.Set("Content-Type", "application/json")
345+
req.SetPathValue("name", name)
346+
return req
347+
}
348+
269349
func objectToRaw(t *testing.T, obj client.Object) runtime.RawExtension {
270350
t.Helper()
271351

api/v1alpha1/admission_types.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ type AdmissionSpec struct {
1010
// WebhookConfiguration defines the configuration for the Admission webhook.
1111
// Allows fine grained control over what is forwarded to the webhook.
1212
// Note that Admission enforces namespace isolation. The namespaceSelector field is set to the namespace of the Admission and can't be overridden.
13-
// There will be a ClusterAdmission in the future to allow for cluster wide admission control.
13+
// The rules are enforced to only match namespaced resources.
14+
// Use ClusterAdmission for cluster scoped webhooks and resources.
1415
WebhookConfiguration WebhookConfiguration `json:"webhookConfiguration,omitempty"`
1516

1617
// Mutating defines if the Admission should create a MutatingWebhookConfiguration or a ValidatingWebhookConfiguration.
@@ -28,6 +29,77 @@ type AdmissionSpec struct {
2829
Template string `json:"template,omitempty"`
2930
}
3031

32+
// ClusterAdmissionSpec defines the desired state of ClusterAdmission.
33+
type ClusterAdmissionSpec struct {
34+
// WebhookConfiguration defines the configuration for the Admission webhook.
35+
// Allows fine grained control over what is forwarded to the webhook.
36+
WebhookConfiguration WebhookConfigurationWithNamespaceSelector `json:"webhookConfiguration,omitempty"`
37+
38+
// Mutating defines if the Admission should create a MutatingWebhookConfiguration or a ValidatingWebhookConfiguration.
39+
Mutating bool `json:"mutating,omitempty"`
40+
41+
// Template contains the Jsonnet code to decide the admission result.
42+
// Admission responses should be created using the `espejote.libsonnet` library.
43+
// `esp.ALPHA.admission.allowed("Nice job!")`, `esp.ALPHA.admission.denied("Bad job!")`, `esp.ALPHA.admission.patched("added user annotation", [jsonPatchOp("add", "/metadata/annotations/user", "tom")])` are examples of valid responses.
44+
// The template can reference JsonnetLibrary objects by importing them.
45+
// JsonnetLibrary objects have the following structure:
46+
// - "espejote.libsonnet": The built in library for accessing the context and trigger information.
47+
// - "lib/<NAME>/<KEY>" libraries in the shared library namespace. The name corresponds to the name of the JsonnetLibrary object and the key to the key in the data field.
48+
// The namespace is configured at controller startup and normally points to the namespace of the controller.
49+
// Note that ClusterAdmission cannot reference non-library JsonnetLibrary objects.
50+
Template string `json:"template,omitempty"`
51+
}
52+
53+
type WebhookConfigurationWithNamespaceSelector struct {
54+
// NamespaceSelector decides whether to run the webhook on an object based
55+
// on whether the namespace for that object matches the selector. If the
56+
// object itself is a namespace, the matching is performed on
57+
// object.metadata.labels. If the object is another cluster scoped resource,
58+
// it never skips the webhook.
59+
//
60+
// For example, to run the webhook on any objects whose namespace is not
61+
// associated with "runlevel" of "0" or "1"; you will set the selector as
62+
// follows:
63+
// "namespaceSelector": {
64+
// "matchExpressions": [
65+
// {
66+
// "key": "runlevel",
67+
// "operator": "NotIn",
68+
// "values": [
69+
// "0",
70+
// "1"
71+
// ]
72+
// }
73+
// ]
74+
// }
75+
//
76+
// If instead you want to only run the webhook on any objects whose
77+
// namespace is associated with the "environment" of "prod" or "staging";
78+
// you will set the selector as follows:
79+
// "namespaceSelector": {
80+
// "matchExpressions": [
81+
// {
82+
// "key": "environment",
83+
// "operator": "In",
84+
// "values": [
85+
// "prod",
86+
// "staging"
87+
// ]
88+
// }
89+
// ]
90+
// }
91+
//
92+
// See
93+
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
94+
// for more examples of label selectors.
95+
//
96+
// Default to the empty LabelSelector, which matches everything.
97+
// +optional
98+
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty" protobuf:"bytes,5,opt,name=namespaceSelector"`
99+
100+
WebhookConfiguration `json:",inline"`
101+
}
102+
31103
type WebhookConfiguration struct {
32104
// Rules describes what operations on what resources/subresources the webhook cares about.
33105
// The webhook cares about an operation if it matches _any_ Rule.
@@ -134,6 +206,29 @@ type AdmissionList struct {
134206
Items []Admission `json:"items"`
135207
}
136208

209+
//+kubebuilder:object:root=true
210+
211+
// ClusterAdmission is the Schema for the ClusterAdmissions API.
212+
// ClusterAdmission currently fully relies on cert-manager for certificate management and webhook certificate injection.
213+
// See the kustomize overlays for more information.
214+
// +kubebuilder:resource:scope=Cluster
215+
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status`
216+
type ClusterAdmission struct {
217+
metav1.TypeMeta `json:",inline"`
218+
metav1.ObjectMeta `json:"metadata,omitempty"`
219+
220+
Spec ClusterAdmissionSpec `json:"spec,omitempty"`
221+
}
222+
223+
//+kubebuilder:object:root=true
224+
225+
// ClusterAdmissionList contains a list of ClusterAdmission
226+
type ClusterAdmissionList struct {
227+
metav1.TypeMeta `json:",inline"`
228+
metav1.ListMeta `json:"metadata,omitempty"`
229+
Items []ClusterAdmission `json:"items"`
230+
}
231+
137232
func init() {
138-
SchemeBuilder.Register(&Admission{}, &AdmissionList{})
233+
SchemeBuilder.Register(&Admission{}, &AdmissionList{}, &ClusterAdmission{}, &ClusterAdmissionList{})
139234
}

0 commit comments

Comments
 (0)