first commit
This commit is contained in:
370
main.go
Normal file
370
main.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
const (
|
||||
roleArnAnnotation = "vultr.com/role-arn"
|
||||
tokenVolumeName = "vultr-irsa-token"
|
||||
tokenMountPath = "/var/run/secrets/vultr.com/serviceaccount"
|
||||
tokenFileName = "token"
|
||||
tokenAudience = "vultr"
|
||||
envAWSRoleArn = "AWS_ROLE_ARN"
|
||||
envAWSWebIdentityToken = "AWS_WEB_IDENTITY_TOKEN_FILE"
|
||||
envAWSSTSRegionalEndpoint = "AWS_STS_REGIONAL_ENDPOINTS"
|
||||
)
|
||||
|
||||
var (
|
||||
tokenExpirationSeconds = int64(86400) // 24 hours
|
||||
)
|
||||
|
||||
type WebhookServer struct {
|
||||
client *kubernetes.Clientset
|
||||
}
|
||||
|
||||
// JSONPatch represents a JSON Patch operation
|
||||
type JSONPatch struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Create in-cluster Kubernetes client
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create in-cluster config: %v", err)
|
||||
}
|
||||
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Kubernetes client: %v", err)
|
||||
}
|
||||
|
||||
server := &WebhookServer{
|
||||
client: clientset,
|
||||
}
|
||||
|
||||
// Setup HTTP handlers
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mutate", server.handleMutate)
|
||||
mux.HandleFunc("/health", handleHealth)
|
||||
|
||||
// TLS configuration
|
||||
tlsCertPath := getEnv("TLS_CERT_PATH", "/etc/webhook/certs/tls.crt")
|
||||
tlsKeyPath := getEnv("TLS_KEY_PATH", "/etc/webhook/certs/tls.key")
|
||||
port := getEnv("PORT", "8443")
|
||||
|
||||
// Load TLS certificates
|
||||
cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load TLS certificates: %v", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
TLSConfig: tlsConfig,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
log.Printf("Starting webhook server on port %s", port)
|
||||
if err := httpServer.ListenAndServeTLS("", ""); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebhookServer) handleMutate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read request body: %v", err)
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse AdmissionReview request
|
||||
admissionReview := &admissionv1.AdmissionReview{}
|
||||
if err := json.Unmarshal(body, admissionReview); err != nil {
|
||||
log.Printf("Failed to unmarshal admission review: %v", err)
|
||||
http.Error(w, "Failed to parse admission review", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if admissionReview.Request == nil {
|
||||
log.Printf("Admission review request is nil")
|
||||
http.Error(w, "Invalid admission review request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the request and create response
|
||||
admissionResponse := ws.mutate(admissionReview.Request)
|
||||
|
||||
// Create response AdmissionReview
|
||||
responseAdmissionReview := &admissionv1.AdmissionReview{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "admission.k8s.io/v1",
|
||||
Kind: "AdmissionReview",
|
||||
},
|
||||
Response: admissionResponse,
|
||||
}
|
||||
responseAdmissionReview.Response.UID = admissionReview.Request.UID
|
||||
|
||||
// Marshal and send response
|
||||
responseBytes, err := json.Marshal(responseAdmissionReview)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal admission response: %v", err)
|
||||
http.Error(w, "Failed to create response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(responseBytes)
|
||||
}
|
||||
|
||||
func (ws *WebhookServer) mutate(request *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse {
|
||||
// Create base response
|
||||
response := &admissionv1.AdmissionResponse{
|
||||
Allowed: true,
|
||||
}
|
||||
|
||||
// Only handle Pod objects
|
||||
if request.Kind.Kind != "Pod" {
|
||||
log.Printf("Skipping non-Pod object: %s", request.Kind.Kind)
|
||||
return response
|
||||
}
|
||||
|
||||
// Parse Pod from request
|
||||
pod := &corev1.Pod{}
|
||||
if err := json.Unmarshal(request.Object.Raw, pod); err != nil {
|
||||
log.Printf("Failed to unmarshal pod: %v", err)
|
||||
response.Allowed = false
|
||||
response.Result = &metav1.Status{
|
||||
Message: fmt.Sprintf("Failed to parse pod: %v", err),
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
log.Printf("Processing pod: %s/%s, ServiceAccount: %s",
|
||||
request.Namespace, pod.Name, pod.Spec.ServiceAccountName)
|
||||
|
||||
// Get ServiceAccount name (default to "default" if not specified)
|
||||
serviceAccountName := pod.Spec.ServiceAccountName
|
||||
if serviceAccountName == "" {
|
||||
serviceAccountName = "default"
|
||||
}
|
||||
|
||||
// Fetch ServiceAccount from Kubernetes API
|
||||
serviceAccount, err := ws.client.CoreV1().ServiceAccounts(request.Namespace).Get(
|
||||
context.Background(),
|
||||
serviceAccountName,
|
||||
metav1.GetOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch ServiceAccount %s/%s: %v",
|
||||
request.Namespace, serviceAccountName, err)
|
||||
// Don't block pod creation if we can't fetch the SA
|
||||
return response
|
||||
}
|
||||
|
||||
// Check for role ARN annotation
|
||||
roleArn, exists := serviceAccount.Annotations[roleArnAnnotation]
|
||||
if !exists || roleArn == "" {
|
||||
log.Printf("ServiceAccount %s/%s does not have %s annotation, skipping mutation",
|
||||
request.Namespace, serviceAccountName, roleArnAnnotation)
|
||||
return response
|
||||
}
|
||||
|
||||
log.Printf("Found role ARN annotation: %s", roleArn)
|
||||
|
||||
// Generate JSON patches to mutate the pod
|
||||
patches, err := ws.generatePatches(pod, roleArn)
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate patches: %v", err)
|
||||
response.Allowed = false
|
||||
response.Result = &metav1.Status{
|
||||
Message: fmt.Sprintf("Failed to generate patches: %v", err),
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Marshal patches to JSON
|
||||
patchBytes, err := json.Marshal(patches)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal patches: %v", err)
|
||||
response.Allowed = false
|
||||
response.Result = &metav1.Status{
|
||||
Message: fmt.Sprintf("Failed to marshal patches: %v", err),
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
response.Patch = patchBytes
|
||||
patchType := admissionv1.PatchTypeJSONPatch
|
||||
response.PatchType = &patchType
|
||||
|
||||
log.Printf("Successfully generated %d patches for pod %s/%s",
|
||||
len(patches), request.Namespace, pod.Name)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func (ws *WebhookServer) generatePatches(pod *corev1.Pod, roleArn string) ([]JSONPatch, error) {
|
||||
var patches []JSONPatch
|
||||
|
||||
// 1. Add projected volume for service account token
|
||||
tokenVolume := corev1.Volume{
|
||||
Name: tokenVolumeName,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Projected: &corev1.ProjectedVolumeSource{
|
||||
Sources: []corev1.VolumeProjection{
|
||||
{
|
||||
ServiceAccountToken: &corev1.ServiceAccountTokenProjection{
|
||||
Audience: tokenAudience,
|
||||
ExpirationSeconds: &tokenExpirationSeconds,
|
||||
Path: tokenFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Check if volumes array exists
|
||||
if pod.Spec.Volumes == nil {
|
||||
// Initialize volumes array
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: "/spec/volumes",
|
||||
Value: []corev1.Volume{tokenVolume},
|
||||
})
|
||||
} else {
|
||||
// Append to existing volumes
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: "/spec/volumes/-",
|
||||
Value: tokenVolume,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Process each container
|
||||
for i := range pod.Spec.Containers {
|
||||
containerPatches := ws.generateContainerPatches(i, roleArn, &pod.Spec.Containers[i])
|
||||
patches = append(patches, containerPatches...)
|
||||
}
|
||||
|
||||
// 3. Process init containers if they exist
|
||||
for i := range pod.Spec.InitContainers {
|
||||
containerPatches := ws.generateContainerPatches(i, roleArn, &pod.Spec.InitContainers[i])
|
||||
// Adjust paths for init containers
|
||||
for j := range containerPatches {
|
||||
containerPatches[j].Path = "/spec/initContainers" +
|
||||
containerPatches[j].Path[len("/spec/containers"):]
|
||||
}
|
||||
patches = append(patches, containerPatches...)
|
||||
}
|
||||
|
||||
return patches, nil
|
||||
}
|
||||
|
||||
func (ws *WebhookServer) generateContainerPatches(index int, roleArn string, container *corev1.Container) []JSONPatch {
|
||||
var patches []JSONPatch
|
||||
basePath := fmt.Sprintf("/spec/containers/%d", index)
|
||||
|
||||
// Add volume mount
|
||||
volumeMount := corev1.VolumeMount{
|
||||
Name: tokenVolumeName,
|
||||
MountPath: tokenMountPath,
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
if container.VolumeMounts == nil {
|
||||
// Initialize volume mounts array
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: basePath + "/volumeMounts",
|
||||
Value: []corev1.VolumeMount{volumeMount},
|
||||
})
|
||||
} else {
|
||||
// Append to existing volume mounts
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: basePath + "/volumeMounts/-",
|
||||
Value: volumeMount,
|
||||
})
|
||||
}
|
||||
|
||||
// Add environment variables
|
||||
envVars := []corev1.EnvVar{
|
||||
{
|
||||
Name: envAWSRoleArn,
|
||||
Value: roleArn,
|
||||
},
|
||||
{
|
||||
Name: envAWSWebIdentityToken,
|
||||
Value: fmt.Sprintf("%s/%s", tokenMountPath, tokenFileName),
|
||||
},
|
||||
{
|
||||
Name: envAWSSTSRegionalEndpoint,
|
||||
Value: "regional",
|
||||
},
|
||||
}
|
||||
|
||||
if container.Env == nil {
|
||||
// Initialize env array
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: basePath + "/env",
|
||||
Value: envVars,
|
||||
})
|
||||
} else {
|
||||
// Append to existing env vars
|
||||
for _, envVar := range envVars {
|
||||
patches = append(patches, JSONPatch{
|
||||
Op: "add",
|
||||
Path: basePath + "/env/-",
|
||||
Value: envVar,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return patches
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
Reference in New Issue
Block a user