Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ spec:
group: vaultwebhook.uswitch.com
versions:
- name: v1alpha1
# Each version can be enabled/disabled by Served flag.
served: true
# One and only one version must be marked as the storage version.
storage: true
schema:
openAPIV3Schema:
type: object
description: |-
A MutatingAdmissionController that will add the vault-creds container to your pod
for you when your pod is created (assuming that vault webhook is enabled on your namespace
properties:
spec:
type: object
properties:
database:
type: string
Expand All @@ -20,7 +27,29 @@ spec:
outputPath:
type: string
outputFile:
type: string
serviceAccount:
type: string
container:
description: Specification of the container that will be created as part of this binding.
type: object
properties:
lifecycle:
description: Specification of the lifecycle hooks of the container. https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/
type: object
properties:
preStop:
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
type: object
properties:
exec:
description: Executes a specific command, inside the cgroups and namespaces of the Container.
type: object
properties:
command:
type: array
items:
type: string
names:
kind: DatabaseCredentialBinding
plural: databasecredentialbindings
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
)
2 changes: 1 addition & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,4 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kF
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
32 changes: 27 additions & 5 deletions pkg/apis/vaultwebhook.uswitch.com/v1alpha1/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package v1alpha1

import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand All @@ -15,11 +16,12 @@ type DatabaseCredentialBinding struct {
}

type DatabaseCredentialBindingSpec struct {
Database string `json:"database"`
Role string `json:"role"`
OutputPath string `json:"outputPath"`
OutputFile string `json:"outputFile"`
ServiceAccount string `json:"serviceAccount"`
Database string `json:"database"`
Role string `json:"role"`
OutputPath string `json:"outputPath"`
OutputFile string `json:"outputFile"`
ServiceAccount string `json:"serviceAccount"`
Container Container `json:"container,omitempty"`
}

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

Items []DatabaseCredentialBinding `json:"items"`
}

type Container struct {
Lifecycle corev1.Lifecycle `json:"lifecycle,omitempty"`
}

/*
https://pkg.go.dev/k8s.io/api/core/v1#LifecycleHandler
Check if Container.Lifecycle.PreStop is valid. This is to avoid mishandling incomplete inputs like the below:

{ "Lifecycle": {
"PostStart": null,
"PreStop": {
"Exec": null, # <----- Missing Command!!
"HTTPGet": null,"TCPSocket": null}}}
*/
func (c Container) HasValidPreStop() bool {
return c.Lifecycle.PreStop != nil &&
c.Lifecycle.PreStop.Exec != nil &&
len(c.Lifecycle.PreStop.Exec.Command) > 0
}
23 changes: 23 additions & 0 deletions vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

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

vaultContainerSpec := databaseInfo.vaultContainer

database := databaseInfo.database
role := databaseInfo.role
serviceAccount := pod.Spec.ServiceAccountName
Expand Down Expand Up @@ -104,6 +107,9 @@ func addVault(pod *corev1.Pod, namespace string, databases []database) (patch []

initContainer := vaultContainer

// Configure Lifecycle Hooks if spec exists
vaultContainer = addLifecycleHook(vaultContainer, vaultContainerSpec)

jobLikeOwnerReferencesKinds := map[string]bool{"Job": true, "Workflow": true}
if len(pod.ObjectMeta.OwnerReferences) != 0 {
ownerKind := pod.ObjectMeta.OwnerReferences[0].Kind
Expand All @@ -112,6 +118,7 @@ func addVault(pod *corev1.Pod, namespace string, databases []database) (patch []
}
}

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

initContainer.Args = append(initContainer.Args, "--init")
Expand Down Expand Up @@ -197,3 +204,19 @@ func appendVolumeMountIfMissing(slice []corev1.VolumeMount, v corev1.VolumeMount
}
return append(slice, v)
}

// Conditionally set Lifecycle if it exists in containerSpec
func addLifecycleHook(container corev1.Container, containerSpec v1alpha1.Container) corev1.Container {

// Check DatabaseCredentialBindingSpec.Container.Lifecycle is not empty
emptyLifecycle := corev1.Lifecycle{}
if containerSpec.Lifecycle != emptyLifecycle {

// Check for a complete PreStop hook
if containerSpec.HasValidPreStop() {
container.Lifecycle = &containerSpec.Lifecycle
}

}
return container
}
69 changes: 68 additions & 1 deletion vault_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package main

import (
"fmt"
"strings"
"testing"

"k8s.io/api/core/v1"
"github.com/uswitch/vault-webhook/pkg/apis/vaultwebhook.uswitch.com/v1alpha1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -183,3 +185,68 @@ func TestVaultJobMode(t *testing.T) {
})
}
}

// Can we add a preStop hook to the vault container?
func TestAddLifecyclePreStopHook(t *testing.T) {

// Define test cases
var tests = []struct {
scenario string
lifecycleObj v1alpha1.Container
answer bool
}{
{
scenario: "Test passing a complete lifecyle config",
lifecycleObj: v1alpha1.Container{
Lifecycle: v1.Lifecycle{
PreStop: &v1.LifecycleHandler{
Exec: &v1.ExecAction{
Command: []string{"echo", "hello"},
},
},
},
},
answer: true,
},
{
scenario: "Test passing an incomplete lifecycle config",
lifecycleObj: v1alpha1.Container{
Lifecycle: v1.Lifecycle{
PreStop: &v1.LifecycleHandler{
Exec: nil,
},
},
},
answer: false,
},
{
// 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 )
// if our dcb input does not specify a container object, the received input will look like this: {Lifecycle:{PostStart:nil PreStop:nil}}
scenario: "Test passing no lifecycle config",
lifecycleObj: v1alpha1.Container{
Lifecycle: v1.Lifecycle{
PreStop: nil,
},
},
answer: false,
},
}

// Run tests
for _, tt := range tests {
// t.Run enables running "subtests", one for each table entry. These are shown separately when executing `go test -v`.
vaultContainer := v1.Container{} // Define a Vault sidecar Container
testname := fmt.Sprintf("%v", tt.scenario)
t.Run(testname, func(t *testing.T) {
ans := addLifecycleHook(vaultContainer, tt.lifecycleObj)

//log.Printf("%+v", ans)
isValid := ans.Lifecycle != nil && ans.Lifecycle.PreStop != nil && ans.Lifecycle.PreStop.Exec != nil && len(ans.Lifecycle.PreStop.Exec.Command) > 0

if isValid != tt.answer {
t.Errorf("got %v, want %v", isValid, tt.answer)
}
})
}

}
37 changes: 31 additions & 6 deletions webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ type patchOperation struct {
}

type database struct {
database string
role string
outputPath string
outputFile string
database string
role string
outputPath string
outputFile string
vaultContainer v1alpha1.Container
}

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

}

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

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

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

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

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

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

/*
For all the bindings in the namespace, check which one has a ServiceeAccount that matches the pod's ServiceAccount
- We could have multiple database specifications to be attached to a single pod.
- This means that we could also have different VaultContainer specs for each DatabaseCredentialBinding.
- As a consequence, to keep things consistent and easy to follow, we are appending into the `database` slice.
*/
func matchBindings(bindings []v1alpha1.DatabaseCredentialBinding, serviceAccount string) []database {
matchedBindings := []database{}
for _, binding := range bindings {
Expand All @@ -184,15 +197,27 @@ func matchBindings(bindings []v1alpha1.DatabaseCredentialBinding, serviceAccount
if output == "" {
output = "/etc/database"
}
matchedBindings = appendIfMissing(matchedBindings, database{role: binding.Spec.Role, database: binding.Spec.Database, outputPath: output, outputFile: binding.Spec.OutputFile})
log.Infof("[matchBindings] Printing content of Container: %+v", binding.Spec.Container)

matchedBindings = appendIfMissing(matchedBindings, database{
role: binding.Spec.Role,
database: binding.Spec.Database,
outputPath: output,
outputFile: binding.Spec.OutputFile,
vaultContainer: binding.Spec.Container,
})
}
}
return matchedBindings
}

func appendIfMissing(slice []database, d database) []database {
for _, ele := range slice {
if ele == d {
// No need to compare Container fields.
if ele.role == d.role &&
ele.database == d.database &&
ele.outputPath == d.outputPath &&
ele.outputFile == d.outputFile {
return slice
}
}
Expand Down