Skip to content

Commit f2c59fc

Browse files
authored
Merge pull request #49 from uswitch/AIRSHIP-4287/expand_dcb
Extend CRD to support configuring `container.spec.lifecycle.preStop.command` in injected container
2 parents c3480a9 + e9a5a92 commit f2c59fc

File tree

7 files changed

+180
-14
lines changed

7 files changed

+180
-14
lines changed

crd.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ spec:
66
group: vaultwebhook.uswitch.com
77
versions:
88
- name: v1alpha1
9+
# Each version can be enabled/disabled by Served flag.
910
served: true
11+
# One and only one version must be marked as the storage version.
1012
storage: true
1113
schema:
1214
openAPIV3Schema:
15+
type: object
16+
description: |-
17+
A MutatingAdmissionController that will add the vault-creds container to your pod
18+
for you when your pod is created (assuming that vault webhook is enabled on your namespace
1319
properties:
1420
spec:
21+
type: object
1522
properties:
1623
database:
1724
type: string
@@ -20,7 +27,29 @@ spec:
2027
outputPath:
2128
type: string
2229
outputFile:
30+
type: string
31+
serviceAccount:
2332
type: string
33+
container:
34+
description: Specification of the container that will be created as part of this binding.
35+
type: object
36+
properties:
37+
lifecycle:
38+
description: Specification of the lifecycle hooks of the container. https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/
39+
type: object
40+
properties:
41+
preStop:
42+
description: This hook is called immediately before a container is terminated due to an API request or management event such as a liveness/startup probe failure, preemption, resource contention and others
43+
type: object
44+
properties:
45+
exec:
46+
description: Executes a specific command, inside the cgroups and namespaces of the Container.
47+
type: object
48+
properties:
49+
command:
50+
type: array
51+
items:
52+
type: string
2453
names:
2554
kind: DatabaseCredentialBinding
2655
plural: databasecredentialbindings

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@ require (
7171
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
7272
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
7373
sigs.k8s.io/yaml v1.3.0 // indirect
74-
)
74+
)

go.sum

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,4 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kF
274274
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
275275
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
276276
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
277-
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
277+
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=

pkg/apis/vaultwebhook.uswitch.com/v1alpha1/types.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package v1alpha1
22

33
import (
4+
corev1 "k8s.io/api/core/v1"
45
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
56
)
67

@@ -15,11 +16,12 @@ type DatabaseCredentialBinding struct {
1516
}
1617

1718
type DatabaseCredentialBindingSpec struct {
18-
Database string `json:"database"`
19-
Role string `json:"role"`
20-
OutputPath string `json:"outputPath"`
21-
OutputFile string `json:"outputFile"`
22-
ServiceAccount string `json:"serviceAccount"`
19+
Database string `json:"database"`
20+
Role string `json:"role"`
21+
OutputPath string `json:"outputPath"`
22+
OutputFile string `json:"outputFile"`
23+
ServiceAccount string `json:"serviceAccount"`
24+
Container Container `json:"container,omitempty"`
2325
}
2426

2527
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -30,3 +32,23 @@ type DatabaseCredentialBindingList struct {
3032

3133
Items []DatabaseCredentialBinding `json:"items"`
3234
}
35+
36+
type Container struct {
37+
Lifecycle corev1.Lifecycle `json:"lifecycle,omitempty"`
38+
}
39+
40+
/*
41+
https://pkg.go.dev/k8s.io/api/core/v1#LifecycleHandler
42+
Check if Container.Lifecycle.PreStop is valid. This is to avoid mishandling incomplete inputs like the below:
43+
44+
{ "Lifecycle": {
45+
"PostStart": null,
46+
"PreStop": {
47+
"Exec": null, # <----- Missing Command!!
48+
"HTTPGet": null,"TCPSocket": null}}}
49+
*/
50+
func (c Container) HasValidPreStop() bool {
51+
return c.Lifecycle.PreStop != nil &&
52+
c.Lifecycle.PreStop.Exec != nil &&
53+
len(c.Lifecycle.PreStop.Exec.Command) > 0
54+
}

vault.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/uswitch/vault-webhook/pkg/apis/vaultwebhook.uswitch.com/v1alpha1"
89
corev1 "k8s.io/api/core/v1"
910
"k8s.io/apimachinery/pkg/api/resource"
1011
)
@@ -24,6 +25,8 @@ func addVault(pod *corev1.Pod, namespace string, databases []database) (patch []
2425
initContainers := []corev1.Container{}
2526
for _, databaseInfo := range databases {
2627

28+
vaultContainerSpec := databaseInfo.vaultContainer
29+
2730
database := databaseInfo.database
2831
role := databaseInfo.role
2932
serviceAccount := pod.Spec.ServiceAccountName
@@ -104,6 +107,9 @@ func addVault(pod *corev1.Pod, namespace string, databases []database) (patch []
104107

105108
initContainer := vaultContainer
106109

110+
// Configure Lifecycle Hooks if spec exists
111+
vaultContainer = addLifecycleHook(vaultContainer, vaultContainerSpec)
112+
107113
jobLikeOwnerReferencesKinds := map[string]bool{"Job": true, "Workflow": true}
108114
if len(pod.ObjectMeta.OwnerReferences) != 0 {
109115
ownerKind := pod.ObjectMeta.OwnerReferences[0].Kind
@@ -112,6 +118,7 @@ func addVault(pod *corev1.Pod, namespace string, databases []database) (patch []
112118
}
113119
}
114120

121+
// Append the new Vault container spec into the Pod Spec generated by the client Deployment/Daemonset/etc
115122
pod.Spec.Containers = append(pod.Spec.Containers, vaultContainer)
116123

117124
initContainer.Args = append(initContainer.Args, "--init")
@@ -197,3 +204,19 @@ func appendVolumeMountIfMissing(slice []corev1.VolumeMount, v corev1.VolumeMount
197204
}
198205
return append(slice, v)
199206
}
207+
208+
// Conditionally set Lifecycle if it exists in containerSpec
209+
func addLifecycleHook(container corev1.Container, containerSpec v1alpha1.Container) corev1.Container {
210+
211+
// Check DatabaseCredentialBindingSpec.Container.Lifecycle is not empty
212+
emptyLifecycle := corev1.Lifecycle{}
213+
if containerSpec.Lifecycle != emptyLifecycle {
214+
215+
// Check for a complete PreStop hook
216+
if containerSpec.HasValidPreStop() {
217+
container.Lifecycle = &containerSpec.Lifecycle
218+
}
219+
220+
}
221+
return container
222+
}

vault_test.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package main
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67

7-
"k8s.io/api/core/v1"
8+
"github.com/uswitch/vault-webhook/pkg/apis/vaultwebhook.uswitch.com/v1alpha1"
9+
v1 "k8s.io/api/core/v1"
810
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
911
)
1012

@@ -183,3 +185,68 @@ func TestVaultJobMode(t *testing.T) {
183185
})
184186
}
185187
}
188+
189+
// Can we add a preStop hook to the vault container?
190+
func TestAddLifecyclePreStopHook(t *testing.T) {
191+
192+
// Define test cases
193+
var tests = []struct {
194+
scenario string
195+
lifecycleObj v1alpha1.Container
196+
answer bool
197+
}{
198+
{
199+
scenario: "Test passing a complete lifecyle config",
200+
lifecycleObj: v1alpha1.Container{
201+
Lifecycle: v1.Lifecycle{
202+
PreStop: &v1.LifecycleHandler{
203+
Exec: &v1.ExecAction{
204+
Command: []string{"echo", "hello"},
205+
},
206+
},
207+
},
208+
},
209+
answer: true,
210+
},
211+
{
212+
scenario: "Test passing an incomplete lifecycle config",
213+
lifecycleObj: v1alpha1.Container{
214+
Lifecycle: v1.Lifecycle{
215+
PreStop: &v1.LifecycleHandler{
216+
Exec: nil,
217+
},
218+
},
219+
},
220+
answer: false,
221+
},
222+
{
223+
// v1alpha1.Container{}, comes from corev1.Container{} and this ALWAYS have a c.Lifecycle object. The latter, always has pointers to PostStart and PreStop handlers ( but no further down the struct since they are pointers )
224+
// if our dcb input does not specify a container object, the received input will look like this: {Lifecycle:{PostStart:nil PreStop:nil}}
225+
scenario: "Test passing no lifecycle config",
226+
lifecycleObj: v1alpha1.Container{
227+
Lifecycle: v1.Lifecycle{
228+
PreStop: nil,
229+
},
230+
},
231+
answer: false,
232+
},
233+
}
234+
235+
// Run tests
236+
for _, tt := range tests {
237+
// t.Run enables running "subtests", one for each table entry. These are shown separately when executing `go test -v`.
238+
vaultContainer := v1.Container{} // Define a Vault sidecar Container
239+
testname := fmt.Sprintf("%v", tt.scenario)
240+
t.Run(testname, func(t *testing.T) {
241+
ans := addLifecycleHook(vaultContainer, tt.lifecycleObj)
242+
243+
//log.Printf("%+v", ans)
244+
isValid := ans.Lifecycle != nil && ans.Lifecycle.PreStop != nil && ans.Lifecycle.PreStop.Exec != nil && len(ans.Lifecycle.PreStop.Exec.Command) > 0
245+
246+
if isValid != tt.answer {
247+
t.Errorf("got %v, want %v", isValid, tt.answer)
248+
}
249+
})
250+
}
251+
252+
}

webhook.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ type patchOperation struct {
3838
}
3939

4040
type database struct {
41-
database string
42-
role string
43-
outputPath string
44-
outputFile string
41+
database string
42+
role string
43+
outputPath string
44+
outputFile string
45+
vaultContainer v1alpha1.Container
4546
}
4647

4748
func (srv webHookServer) serve(w http.ResponseWriter, r *http.Request) {
@@ -99,6 +100,7 @@ func (srv webHookServer) serve(w http.ResponseWriter, r *http.Request) {
99100

100101
}
101102

103+
// This handles the admission review sent by k8s and mutates the pod
102104
func (srv webHookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionResponse {
103105
req := ar.Request
104106

@@ -121,7 +123,9 @@ func (srv webHookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionR
121123
log.Infof("AdmissionReview for Kind=%v, Namespace=%v Name=%v UID=%v patchOperation=%v UserInfo=%v",
122124
ownerKind, req.Namespace, ownerName, req.UID, req.Operation, req.UserInfo)
123125

126+
// A list of ALL the bindings.
124127
binds, err := srv.bindings.List()
128+
log.Infof("[mutate] List of all bindings: %+v", binds)
125129
if err != nil {
126130
return &v1beta1.AdmissionResponse{
127131
Result: &metav1.Status{
@@ -130,6 +134,7 @@ func (srv webHookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionR
130134
}
131135
}
132136

137+
// Filter out the bindings that are not in the target namespace
133138
filteredBindings := filterBindings(binds, req.Namespace)
134139
if len(filteredBindings) == 0 {
135140
log.Infof("Skipping mutation for %s/%s, no database credential bindings in namespace", req.Namespace, ownerName)
@@ -138,6 +143,7 @@ func (srv webHookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionR
138143
}
139144
}
140145

146+
// Identify bindings with ServiceAccount field matching the pod's ServiceAccountName
141147
databases := matchBindings(filteredBindings, pod.Spec.ServiceAccountName)
142148
if len(databases) == 0 {
143149
log.Infof("Skipping mutation for %s/%s due to policy check", req.Namespace, ownerName)
@@ -166,6 +172,7 @@ func (srv webHookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionR
166172
}
167173
}
168174

175+
// For all the bindings, we need to find the ones in the target namespace
169176
func filterBindings(bindings []v1alpha1.DatabaseCredentialBinding, namespace string) []v1alpha1.DatabaseCredentialBinding {
170177
filteredBindings := []v1alpha1.DatabaseCredentialBinding{}
171178
for _, binding := range bindings {
@@ -176,6 +183,12 @@ func filterBindings(bindings []v1alpha1.DatabaseCredentialBinding, namespace str
176183
return filteredBindings
177184
}
178185

186+
/*
187+
For all the bindings in the namespace, check which one has a ServiceeAccount that matches the pod's ServiceAccount
188+
- We could have multiple database specifications to be attached to a single pod.
189+
- This means that we could also have different VaultContainer specs for each DatabaseCredentialBinding.
190+
- As a consequence, to keep things consistent and easy to follow, we are appending into the `database` slice.
191+
*/
179192
func matchBindings(bindings []v1alpha1.DatabaseCredentialBinding, serviceAccount string) []database {
180193
matchedBindings := []database{}
181194
for _, binding := range bindings {
@@ -184,15 +197,27 @@ func matchBindings(bindings []v1alpha1.DatabaseCredentialBinding, serviceAccount
184197
if output == "" {
185198
output = "/etc/database"
186199
}
187-
matchedBindings = appendIfMissing(matchedBindings, database{role: binding.Spec.Role, database: binding.Spec.Database, outputPath: output, outputFile: binding.Spec.OutputFile})
200+
log.Infof("[matchBindings] Printing content of Container: %+v", binding.Spec.Container)
201+
202+
matchedBindings = appendIfMissing(matchedBindings, database{
203+
role: binding.Spec.Role,
204+
database: binding.Spec.Database,
205+
outputPath: output,
206+
outputFile: binding.Spec.OutputFile,
207+
vaultContainer: binding.Spec.Container,
208+
})
188209
}
189210
}
190211
return matchedBindings
191212
}
192213

193214
func appendIfMissing(slice []database, d database) []database {
194215
for _, ele := range slice {
195-
if ele == d {
216+
// No need to compare Container fields.
217+
if ele.role == d.role &&
218+
ele.database == d.database &&
219+
ele.outputPath == d.outputPath &&
220+
ele.outputFile == d.outputFile {
196221
return slice
197222
}
198223
}

0 commit comments

Comments
 (0)