A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1 from evanjarrett/test

Test

authored by

Evan Jarrett and committed by
GitHub
9ad31a9d dc50c7a5

+489 -102
+23 -4
CLAUDE.md
··· 38 38 - Dynamically deployed pods for direct HSM communication 39 39 - gRPC server on port 9090 with HTTP health checks on port 8093 40 40 - Real PKCS#11 client for production or MockClient for testing 41 + - Manages pcscd daemon lifecycle internally via PCSCDManager (no shell scripts required) 41 42 42 43 **Test Utility:** 43 44 - **Test HSM Binary** (`cmd/test-hsm/main.go`): Standalone HSM operations testing and debugging ··· 60 61 61 62 **gRPC Communication Architecture:** 62 63 - Protocol definition in `api/proto/hsm/v1/hsm.proto` with 10 HSM operations 63 - - Manager ↔ Agent: gRPC for efficient, type-safe HSM operations 64 + - Manager ↔ Agent: gRPC for efficient, type-safe HSM operations 64 65 - Discovery → Manager: Pod annotations for race-free device reporting 65 66 - **External → Manager**: REST API proxy routes to ALL agents for multi-device operations 66 67 - Generated code: `api/proto/hsm/v1/hsm.pb.go` and `hsm_grpc.pb.go` 68 + 69 + **PCSCD Lifecycle Management:** 70 + - **PCSCDManager** (`internal/agent/pcscd_manager.go`): Go-native pcscd process management 71 + - Automatically started in agent mode when real PKCS#11 hardware is detected 72 + - Foreground mode with stdout/stderr piping for integrated logging 73 + - Graceful shutdown handling: SIGTERM → 5s timeout → SIGKILL fallback 74 + - Socket-based readiness detection (`/var/run/pcscd/pcscd.comm`) 75 + - Eliminates shell script dependency for maximum container security 67 76 68 77 **Controller Hierarchy:** 69 78 ``` ··· 133 142 134 143 ### Docker & Deployment 135 144 ```bash 136 - # Production image (agent has PKCS#11 support) 145 + # Production image (FROM scratch base - ultra-minimal ~15MB) 137 146 make docker-build IMG=hsm-secrets-operator:latest 138 147 139 - # Production build only (testing handled via build tags) 148 + # Multi-arch build (supports amd64 and arm64) 149 + docker buildx build --platform linux/amd64,linux/arm64 -t hsm-secrets-operator:latest . 140 150 141 151 # Deploy to cluster 142 152 make deploy IMG=hsm-secrets-operator:latest 143 153 144 - # Generate installer bundle 154 + # Generate installer bundle 145 155 make build-installer IMG=hsm-secrets-operator:latest 146 156 ``` 157 + 158 + **Container Architecture:** 159 + - **Base Image**: FROM scratch (no distro, no shell - maximum security) 160 + - **Size**: ~15MB (vs ~30MB with distroless:debug) 161 + - **Security**: Minimal attack surface - only essential binaries and libraries 162 + - **Dependencies**: Auto-discovered via iterative ldd/strings analysis in builder stage 163 + - **Multi-arch**: Supports x86_64 and arm64 via dynamic linker auto-detection 164 + - **Process Management**: Direct binary execution with Go-managed pcscd lifecycle 165 + - **No Shell**: All process management handled in Go code (internal/agent/pcscd_manager.go) 147 166 148 167 ## CRD Structure and Relationships 149 168
+91 -44
Dockerfile
··· 2 2 # Phase 2: Root + Distroless - compensates for root requirement with minimal attack surface 3 3 4 4 # Stage 1: Go builder (also serves as dependency source) 5 - FROM golang:1.24-bookworm AS builder 5 + FROM golang:1.24-trixie AS builder 6 6 ARG TARGETOS 7 7 ARG TARGETARCH 8 8 ··· 10 10 RUN apt-get update && apt-get install -y \ 11 11 opensc \ 12 12 pcscd \ 13 + libccid \ 13 14 libpcsclite1 \ 14 15 libusb-1.0-0 \ 15 16 udev \ ··· 20 21 && rm -rf /var/lib/apt/lists/* 21 22 22 23 # Create necessary runtime directories 23 - RUN mkdir -p /run/pcscd /var/lock/pcsc && \ 24 - chmod 755 /run/pcscd /var/lock/pcsc 24 + RUN mkdir -p /run/pcscd /var/run/pcscd /var/lock/pcsc && \ 25 + chmod 755 /run/pcscd /var/run/pcscd /var/lock/pcsc 26 + 27 + # Create minimal /etc/passwd and /etc/group for nonroot user (65532:65532) 28 + RUN echo "nonroot:x:65532:65532:nonroot:/:" > /tmp/passwd && \ 29 + echo "nonroot:x:65532:" > /tmp/group 25 30 26 31 WORKDIR /workspace 27 32 # Copy the Go Modules manifests ··· 40 45 # Build with CGO enabled for PKCS#11 support 41 46 RUN CGO_ENABLED=1 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o hsm-operator cmd/hsm-operator/main.go 42 47 43 - # Stage 2: Base runtime stage with all files 44 - FROM gcr.io/distroless/cc-debian12:debug AS runtime 48 + # Collect all runtime dependencies using iterative discovery: 49 + # 1. Start with ldd on binaries → get compile-time linked libraries 50 + # 2. Recursively: ldd on discovered libraries → get their dependencies 51 + # 3. strings scan discovered libraries → find dlopen'd libraries (like libgcc_s, libpcsclite_real) 52 + # 4. Repeat step 2-3 on newly discovered libraries until no new deps found 53 + RUN echo "Discovering runtime dependencies (iterative)..." && \ 54 + # Define binaries to scan (includes CCID driver to catch libusb dependency) 55 + SCAN_BINARIES="/workspace/hsm-operator /usr/sbin/pcscd /usr/bin/pkcs11-tool /usr/lib/pcsc/drivers/ifd-ccid.bundle/Contents/Linux/libccid.so" && \ 56 + mkdir -p /runtime-deps && \ 57 + touch /tmp/deps_all.txt /tmp/deps_previous.txt /tmp/deps_new.txt && \ 58 + # Start with our binaries 59 + echo "$SCAN_BINARIES" | tr ' ' '\n' > /tmp/deps_new.txt && \ 60 + ITERATION=0 && \ 61 + while [ -s /tmp/deps_new.txt ] && [ $ITERATION -lt 10 ]; do \ 62 + ITERATION=$((ITERATION + 1)) && \ 63 + NEW_COUNT=$(wc -l < /tmp/deps_new.txt) && \ 64 + echo "Iteration $ITERATION: Processing $NEW_COUNT new items..." && \ 65 + # Run ldd on new items 66 + for item in $(cat /tmp/deps_new.txt); do \ 67 + if [ -f "$item" ]; then \ 68 + ldd "$item" 2>/dev/null | grep "=>" | awk '{print $3}' | grep -v "^$" || true; \ 69 + # Also get dynamic linker 70 + ldd "$item" 2>/dev/null | grep -o '/lib.*/ld-linux[^ ]*' || true; \ 71 + fi; \ 72 + done >> /tmp/deps_all.txt && \ 73 + # Scan new items for dlopen'd libraries (strings method) 74 + for item in $(cat /tmp/deps_new.txt); do \ 75 + if [ -f "$item" ]; then \ 76 + strings "$item" 2>/dev/null | grep -E '\.so(\.[0-9]+)*$' | while read soname; do \ 77 + find /usr/lib /lib -name "$soname" 2>/dev/null || true; \ 78 + done; \ 79 + fi; \ 80 + done >> /tmp/deps_all.txt && \ 81 + # Find newly discovered deps (not in previous iterations) 82 + sort -u /tmp/deps_all.txt > /tmp/deps_all_sorted.txt && \ 83 + comm -13 /tmp/deps_previous.txt /tmp/deps_all_sorted.txt > /tmp/deps_new.txt && \ 84 + cp /tmp/deps_all_sorted.txt /tmp/deps_previous.txt; \ 85 + done && \ 86 + # Final deduplication - just remove duplicate paths, keep both /lib and /usr/lib variants 87 + sort -u /tmp/deps_all.txt > /tmp/deps.txt && \ 88 + echo "Found $(wc -l < /tmp/deps.txt) unique library paths after $ITERATION iterations" && \ 89 + cat /tmp/deps.txt && \ 90 + for lib in $(cat /tmp/deps.txt); do \ 91 + if [ -f "$lib" ]; then \ 92 + dir=$(dirname "$lib"); \ 93 + mkdir -p "/runtime-deps$dir"; \ 94 + cp -L "$lib" "/runtime-deps$lib"; \ 95 + fi; \ 96 + done && \ 97 + echo "Dependencies collected to /runtime-deps" && \ 98 + # Verify all binaries can find their dependencies 99 + echo "Testing binaries for missing dependencies..." && \ 100 + for binary in $SCAN_BINARIES; do \ 101 + echo "Testing $binary..."; \ 102 + ldd "$binary" 2>&1 | grep "not found" && echo "ERROR: Missing dependencies for $binary" && exit 1 || true; \ 103 + done && \ 104 + echo "All binaries have satisfied dependencies" 45 105 46 - # Copy essential system files and create nonroot user 47 - COPY --from=builder /etc/passwd /etc/passwd 48 - COPY --from=builder /etc/group /etc/group 106 + # Stage 2: Ultra-minimal FROM scratch runtime (no shell, no distro) 107 + # Maximum security: smallest possible attack surface (~15MB vs ~30MB distroless) 108 + FROM scratch 49 109 50 - # Ensure nonroot user exists (distroless provides user 65532:65532) 110 + # Copy minimal user/group files for nonroot user (secure by default) 111 + COPY --from=builder /tmp/passwd /etc/passwd 112 + COPY --from=builder /tmp/group /etc/group 51 113 52 - # Copy PKCS#11 and USB libraries with explicit architecture paths 53 - # Use find to locate the correct architecture-specific paths 114 + # Copy all runtime library dependencies (auto-discovered via ldd/strings) 115 + # Includes dynamic linker (ld-linux-*.so) for all architectures (x86_64, arm64, etc.) 116 + COPY --from=builder /runtime-deps / 117 + 118 + # Copy PKCS#11 library (loaded via dlopen by Go app at runtime with user-specified path) 54 119 COPY --from=builder /usr/lib/*/opensc-pkcs11.so /usr/lib/pkcs11/ 55 - COPY --from=builder /usr/lib/*/libopensc.so.8* /usr/lib/ 56 - COPY --from=builder /usr/lib/*/libpcsclite.so.1* /usr/lib/ 57 - COPY --from=builder /usr/lib/*/libusb-1.0.so.0* /usr/lib/ 58 - COPY --from=builder /usr/lib/*/libudev.so.1* /usr/lib/ 59 - COPY --from=builder /usr/lib/*/libglib-2.0.so.0* /lib/*/libglib-2.0.so.0* /usr/lib/ 60 - COPY --from=builder /usr/lib/*/libgio-2.0.so.0* /lib/*/libgio-2.0.so.0* /usr/lib/ 61 - COPY --from=builder /usr/lib/*/libgobject-2.0.so.0* /lib/*/libgobject-2.0.so.0* /usr/lib/ 62 - COPY --from=builder /usr/lib/*/libgmodule-2.0.so.0* /lib/*/libgmodule-2.0.so.0* /usr/lib/ 63 - COPY --from=builder /usr/lib/*/libmount.so.1* /lib/*/libmount.so.1* /usr/lib/ 64 - COPY --from=builder /usr/lib/*/libselinux.so.1* /lib/*/libselinux.so.1* /usr/lib/ 65 - COPY --from=builder /usr/lib/*/libffi.so.8* /lib/*/libffi.so.8* /usr/lib/ 66 - COPY --from=builder /usr/lib/*/libpcre2-8.so.0* /lib/*/libpcre2-8.so.0* /usr/lib/ 67 - COPY --from=builder /usr/lib/*/libblkid.so.1* /lib/*/libblkid.so.1* /usr/lib/ 68 - COPY --from=builder /usr/lib/*/libcap.so.2* /usr/lib/ 69 - COPY --from=builder /usr/lib/*/libsystemd.so.0* /lib/*/libsystemd.so.0* /usr/lib/ 70 - COPY --from=builder /usr/lib/*/libgcrypt.so.20* /lib/*/libgcrypt.so.20* /usr/lib/ 71 - COPY --from=builder /usr/lib/*/liblzma.so.5* /lib/*/liblzma.so.5* /usr/lib/ 72 - COPY --from=builder /usr/lib/*/libzstd.so.1* /lib/*/libzstd.so.1* /usr/lib/ 73 - COPY --from=builder /usr/lib/*/liblz4.so.1* /lib/*/liblz4.so.1* /usr/lib/ 74 - COPY --from=builder /usr/lib/*/libgpg-error.so.0* /lib/*/libgpg-error.so.0* /usr/lib/ 75 - COPY --from=builder /lib/*/libgcc_s.so.1* /usr/lib/ 76 - # Copy zlib for pkcs11-tool 77 - COPY --from=builder /lib/*/libz.so.1* /usr/lib/ 78 120 79 121 # Copy essential binaries 80 122 COPY --from=builder /usr/sbin/pcscd /usr/sbin/ 81 123 COPY --from=builder /usr/bin/pkcs11-tool /usr/bin/ 124 + 125 + # Copy udev rules for HSM devices (CCID support) 126 + COPY --from=builder /lib/udev/rules.d/92-libccid.rules /lib/udev/rules.d/ 127 + 128 + # Copy CCID drivers for pcscd (Debian Trixie provides v1.6.2 with native Pico HSM multi-interface support) 129 + COPY --from=builder /usr/lib/pcsc /usr/lib/pcsc 130 + 131 + # Copy CCID configuration file (needed for Info.plist symlink) 132 + COPY --from=builder /etc/libccid_Info.plist /etc/ 82 133 83 134 # Copy CA certificates 84 135 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 85 136 86 - # Copy runtime directories (but NOT pcsc drivers - avoiding CCID) 137 + # Copy runtime directories (pcscd will use these) 87 138 COPY --from=builder /var/run/pcscd /run/pcscd 88 139 COPY --from=builder /var/lock/pcsc /var/lock/pcsc 89 140 90 - # Copy application binary and entrypoint 141 + # Copy application binary (manages pcscd lifecycle internally - no shell needed) 91 142 COPY --from=builder /workspace/hsm-operator /hsm-operator 92 - COPY --chmod=755 entrypoint.sh /entrypoint.sh 93 143 94 - # Default to distroless nonroot user (can be overridden by deployment securityContext) 144 + # Default to nonroot user for security (manager/discovery modes don't need root) 145 + # Agent mode overrides to root via Kubernetes securityContext for USB device access 95 146 USER 65532:65532 96 147 97 - # Stage 3: Debug variant with shell (DEFAULT for testing) 98 - FROM runtime 99 - 100 - # Debug image includes busybox shell for troubleshooting 101 - # Access via: kubectl exec -it pod -- /busybox/sh 102 - ENTRYPOINT ["/entrypoint.sh"] 148 + # Direct binary execution - pcscd lifecycle managed by Go code in agent mode 149 + ENTRYPOINT ["/hsm-operator"]
+2 -2
Makefile
··· 3 3 # To re-generate a bundle for another specific version without changing the standard setup, you can: 4 4 # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) 5 5 # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) 6 - VERSION ?= 0.6.24 6 + VERSION ?= 0.6.32 7 7 8 8 # CHANNELS define the bundle channels used in the bundle. 9 9 # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") ··· 321 321 ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') 322 322 #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) 323 323 ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') 324 - GOLANGCI_LINT_VERSION ?= main 324 + GOLANGCI_LINT_VERSION ?= v2.5.0 325 325 326 326 .PHONY: kustomize 327 327 kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
-50
entrypoint.sh
··· 1 - #!/busybox/sh 2 - set -e 3 - 4 - # Debug: Show user and USB device permissions for agent mode only 5 - if [ "$1" = "--mode=agent" ]; then 6 - echo "Starting pcscd as user: $(id)" 7 - echo "Groups: $(groups)" 8 - echo "USB device permissions:" 9 - if [ -d /dev/bus/usb ]; then 10 - ls -la /dev/bus/usb/ | head -20 11 - echo "Checking for specific USB devices..." 12 - find /dev/bus/usb -type c -exec ls -la {} \; 2>/dev/null | grep -E "20a0|4230" || echo "No HSM devices found by vendor/product ID yet" 13 - else 14 - echo "ERROR: /dev/bus/usb not mounted" 15 - exit 1 16 - fi 17 - 18 - # Try to trigger udev rules for USB devices 19 - if command -v udevadm >/dev/null 2>&1; then 20 - echo "Triggering udev rules for USB devices..." 21 - udevadm trigger --subsystem-match=usb --action=add 2>/dev/null || true 22 - udevadm settle --timeout=2 2>/dev/null || true 23 - fi 24 - 25 - # Start pcscd (no CCID drivers available, will use direct access) 26 - pcscd -f -d -a & 27 - PCSCD_PID=$! 28 - 29 - sleep 3 30 - 31 - # Verify pcscd started 32 - if ! kill -0 $PCSCD_PID 2>/dev/null; then 33 - echo "ERROR: pcscd failed to start" 34 - exit 1 35 - fi 36 - fi 37 - 38 - # Entrypoint script for HSM Secrets Operator 39 - # Supports running manager, discovery, or agent binaries from the same container 40 - 41 - case "$1" in 42 - "--mode="*) 43 - # Direct mode flag usage (preferred) 44 - exec /hsm-operator "$@" 45 - ;; 46 - *) 47 - # Default to manager for backward compatibility 48 - exec /hsm-operator --mode=manager "$@" 49 - ;; 50 - esac
+2 -2
helm/hsm-secrets-operator/Chart.yaml
··· 2 2 name: hsm-secrets-operator 3 3 description: A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets 4 4 type: application 5 - version: 0.6.24 6 - appVersion: v0.6.24 5 + version: 0.6.32 6 + appVersion: v0.6.32 7 7 icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg 8 8 home: https://github.com/evanjarrett/hsm-secrets-operator 9 9 sources:
+170
internal/agent/pcscd_manager.go
··· 1 + /* 2 + Copyright 2025. 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 agent 18 + 19 + import ( 20 + "context" 21 + "fmt" 22 + "os" 23 + "os/exec" 24 + "syscall" 25 + "time" 26 + 27 + "github.com/go-logr/logr" 28 + ) 29 + 30 + // PCSCDManager manages the PC/SC Smart Card Daemon (pcscd) lifecycle. 31 + // It starts pcscd in foreground mode and handles graceful shutdown. 32 + type PCSCDManager struct { 33 + cmd *exec.Cmd 34 + ctx context.Context 35 + cancel context.CancelFunc 36 + logger logr.Logger 37 + } 38 + 39 + // NewPCSCDManager creates a new PCSCD manager instance. 40 + func NewPCSCDManager(logger logr.Logger) *PCSCDManager { 41 + ctx, cancel := context.WithCancel(context.Background()) 42 + return &PCSCDManager{ 43 + ctx: ctx, 44 + cancel: cancel, 45 + logger: logger.WithName("pcscd-manager"), 46 + } 47 + } 48 + 49 + // Start initializes and starts the pcscd daemon. 50 + // It runs pcscd in foreground mode with debug output and polkit disabled. 51 + // Blocks until pcscd is ready or times out after 5 seconds. 52 + func (p *PCSCDManager) Start() error { 53 + if p.cmd != nil { 54 + return fmt.Errorf("pcscd is already running") 55 + } 56 + 57 + p.logger.Info("Starting pcscd daemon") 58 + 59 + // Start pcscd with: 60 + // -f: foreground mode (don't daemonize) 61 + // --disable-polkit: disable PolicyKit (no D-Bus in container) 62 + // Note: Removed -d and -a debug flags for production - add back if needed 63 + p.cmd = exec.CommandContext(p.ctx, "/usr/sbin/pcscd", "-f", "--disable-polkit") 64 + 65 + // Pipe output to parent process for centralized logging 66 + p.cmd.Stdout = os.Stdout 67 + p.cmd.Stderr = os.Stderr 68 + 69 + // Set process group for proper signal handling 70 + // This ensures child processes are also signaled on shutdown 71 + p.cmd.SysProcAttr = &syscall.SysProcAttr{ 72 + Setpgid: true, 73 + } 74 + 75 + // Start the process 76 + if err := p.cmd.Start(); err != nil { 77 + return fmt.Errorf("failed to start pcscd: %w", err) 78 + } 79 + 80 + p.logger.Info("pcscd process started", "pid", p.cmd.Process.Pid) 81 + 82 + // Wait for pcscd to be ready 83 + if err := p.waitForReady(); err != nil { 84 + // If pcscd fails to start, clean up the process 85 + if stopErr := p.Stop(); stopErr != nil { 86 + p.logger.Error(stopErr, "Failed to stop pcscd during cleanup") 87 + } 88 + return fmt.Errorf("pcscd failed to become ready: %w", err) 89 + } 90 + 91 + p.logger.Info("pcscd is ready") 92 + return nil 93 + } 94 + 95 + // Stop gracefully shuts down the pcscd daemon. 96 + // It sends SIGTERM first, then SIGKILL if the process doesn't exit within 5 seconds. 97 + func (p *PCSCDManager) Stop() error { 98 + // Always cancel the context first, even if process isn't running 99 + p.cancel() 100 + 101 + if p.cmd == nil || p.cmd.Process == nil { 102 + p.logger.V(1).Info("pcscd is not running, nothing to stop") 103 + return nil 104 + } 105 + 106 + p.logger.Info("Stopping pcscd daemon", "pid", p.cmd.Process.Pid) 107 + 108 + // Send SIGTERM for graceful shutdown 109 + if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { 110 + p.logger.Error(err, "Failed to send SIGTERM to pcscd, forcing kill") 111 + return p.cmd.Process.Kill() 112 + } 113 + 114 + // Wait for process to exit with timeout 115 + done := make(chan error, 1) 116 + go func() { 117 + done <- p.cmd.Wait() 118 + }() 119 + 120 + select { 121 + case err := <-done: 122 + if err != nil { 123 + p.logger.V(1).Info("pcscd exited with error", "error", err) 124 + } else { 125 + p.logger.Info("pcscd stopped gracefully") 126 + } 127 + return err 128 + case <-time.After(5 * time.Second): 129 + // Timeout - force kill 130 + p.logger.Info("pcscd did not exit within timeout, forcing kill") 131 + if err := p.cmd.Process.Kill(); err != nil { 132 + return fmt.Errorf("failed to kill pcscd: %w", err) 133 + } 134 + _ = p.cmd.Wait() // Ignore wait error after force kill 135 + return fmt.Errorf("pcscd was forcefully killed after timeout") 136 + } 137 + } 138 + 139 + // waitForReady polls for pcscd readiness by checking if the socket exists. 140 + // PC/SC Lite creates a socket at /var/run/pcscd/pcscd.comm when ready. 141 + // Waits up to 5 seconds with 100ms polling interval. 142 + func (p *PCSCDManager) waitForReady() error { 143 + const ( 144 + maxAttempts = 50 // 50 attempts 145 + pollInterval = 100 * time.Millisecond // 100ms interval 146 + socketPath = "/var/run/pcscd/pcscd.comm" 147 + ) 148 + 149 + p.logger.V(1).Info("Waiting for pcscd to be ready", "socket", socketPath) 150 + 151 + for i := 0; i < maxAttempts; i++ { 152 + // Check if the socket exists 153 + if _, err := os.Stat(socketPath); err == nil { 154 + p.logger.V(1).Info("pcscd socket detected", "attempts", i+1) 155 + // Give it a tiny bit more time to fully initialize 156 + time.Sleep(100 * time.Millisecond) 157 + return nil 158 + } 159 + 160 + // Check if process is still running 161 + // If the process exited, no point in waiting 162 + if p.cmd.ProcessState != nil && p.cmd.ProcessState.Exited() { 163 + return fmt.Errorf("pcscd process exited unexpectedly") 164 + } 165 + 166 + time.Sleep(pollInterval) 167 + } 168 + 169 + return fmt.Errorf("pcscd did not become ready within %v", time.Duration(maxAttempts)*pollInterval) 170 + }
+188
internal/agent/pcscd_manager_test.go
··· 1 + /* 2 + Copyright 2025. 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 agent 18 + 19 + import ( 20 + "os" 21 + "os/exec" 22 + "testing" 23 + "time" 24 + 25 + "github.com/go-logr/logr" 26 + ctrl "sigs.k8s.io/controller-runtime" 27 + "sigs.k8s.io/controller-runtime/pkg/log/zap" 28 + ) 29 + 30 + func TestNewPCSCDManager(t *testing.T) { 31 + logger := zap.New(zap.UseDevMode(true)) 32 + mgr := NewPCSCDManager(logger) 33 + 34 + if mgr == nil { 35 + t.Fatal("NewPCSCDManager returned nil") 36 + } 37 + 38 + if mgr.ctx == nil { 39 + t.Error("Context was not initialized") 40 + } 41 + 42 + if mgr.cancel == nil { 43 + t.Error("Cancel function was not initialized") 44 + } 45 + 46 + if mgr.cmd != nil { 47 + t.Error("Command should be nil before Start()") 48 + } 49 + } 50 + 51 + func TestPCSCDManager_StartWithoutPCSCD(t *testing.T) { 52 + // This test verifies error handling when pcscd binary doesn't exist 53 + // Note: This test would need to mock exec.Command to properly test 54 + // without actually running pcscd. For now, we document expected behavior. 55 + 56 + // If pcscd is not available at /usr/sbin/pcscd, Start() should fail 57 + // In a production container, pcscd will always be present 58 + 59 + // Skip this test in CI environments where pcscd may not be installed 60 + if _, err := os.Stat("/usr/sbin/pcscd"); os.IsNotExist(err) { 61 + t.Skip("pcscd binary not found, skipping test") 62 + } 63 + 64 + // If we reach here, pcscd exists, so we can't test the "not found" case 65 + // without mocking. Skip the test. 66 + t.Skip("Cannot test missing pcscd without mocking exec.Command") 67 + } 68 + 69 + func TestPCSCDManager_MultipleStartAttempts(t *testing.T) { 70 + // Test that calling Start() multiple times fails appropriately 71 + logger := zap.New(zap.UseDevMode(true)) 72 + mgr := NewPCSCDManager(logger) 73 + 74 + // Mock the cmd to prevent actual pcscd start 75 + // In real implementation, we'd need dependency injection or interface 76 + // to properly mock exec.Command 77 + 78 + // Simulate cmd being already set 79 + if mgr.cmd == nil { 80 + // Set a dummy command to simulate already-started state 81 + mgr.cmd = &exec.Cmd{} // This is just for testing the check 82 + } 83 + 84 + err := mgr.Start() 85 + if err == nil { 86 + t.Error("Expected error when starting already-running pcscd, got nil") 87 + } 88 + 89 + if err != nil && err.Error() != "pcscd is already running" { 90 + t.Errorf("Expected 'pcscd is already running' error, got: %v", err) 91 + } 92 + } 93 + 94 + func TestPCSCDManager_StopWithoutStart(t *testing.T) { 95 + // Test that Stop() is safe to call even if Start() was never called 96 + logger := zap.New(zap.UseDevMode(true)) 97 + mgr := NewPCSCDManager(logger) 98 + 99 + // Should not panic or error 100 + err := mgr.Stop() 101 + if err != nil { 102 + t.Errorf("Stop() should be safe when not running, got error: %v", err) 103 + } 104 + } 105 + 106 + func TestPCSCDManager_ContextCancellation(t *testing.T) { 107 + // Test that cancelling the context affects the manager 108 + logger := zap.New(zap.UseDevMode(true)) 109 + mgr := NewPCSCDManager(logger) 110 + 111 + // Verify context is not cancelled initially 112 + select { 113 + case <-mgr.ctx.Done(): 114 + t.Error("Context should not be cancelled initially") 115 + default: 116 + // Good - context is active 117 + } 118 + 119 + // Call Stop() which should cancel the context 120 + if err := mgr.Stop(); err != nil { 121 + t.Logf("Stop returned error (expected when not running): %v", err) 122 + } 123 + 124 + // Give it a moment to cancel 125 + time.Sleep(10 * time.Millisecond) 126 + 127 + // Verify context is now cancelled 128 + select { 129 + case <-mgr.ctx.Done(): 130 + // Good - context was cancelled 131 + default: 132 + t.Error("Context should be cancelled after Stop()") 133 + } 134 + } 135 + 136 + // Integration test helper - only runs if pcscd is available 137 + func getPCSCDTestLogger() logr.Logger { 138 + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) 139 + return ctrl.Log.WithName("pcscd-test") 140 + } 141 + 142 + // TestPCSCDManager_Integration is an integration test that requires pcscd 143 + // It's skipped automatically if pcscd is not available or already running 144 + func TestPCSCDManager_Integration(t *testing.T) { 145 + // Verify pcscd binary exists 146 + if _, err := os.Stat("/usr/sbin/pcscd"); os.IsNotExist(err) { 147 + t.Skip("pcscd binary not found, skipping integration test") 148 + } 149 + 150 + // Check if pcscd is already running (socket exists) 151 + if _, err := os.Stat("/var/run/pcscd/pcscd.comm"); err == nil { 152 + t.Skip("pcscd is already running, skipping integration test") 153 + } 154 + 155 + logger := getPCSCDTestLogger() 156 + mgr := NewPCSCDManager(logger) 157 + 158 + // Start pcscd 159 + if err := mgr.Start(); err != nil { 160 + t.Skipf("Failed to start pcscd (may be a permission issue): %v", err) 161 + } 162 + 163 + // Verify process is running 164 + if mgr.cmd == nil || mgr.cmd.Process == nil { 165 + t.Fatal("pcscd process should be running") 166 + } 167 + 168 + // Give it time to fully initialize 169 + time.Sleep(1 * time.Second) 170 + 171 + // Verify socket exists 172 + if _, err := os.Stat("/var/run/pcscd/pcscd.comm"); os.IsNotExist(err) { 173 + t.Error("pcscd socket not found after start") 174 + } 175 + 176 + // Stop pcscd 177 + if err := mgr.Stop(); err != nil { 178 + t.Errorf("Failed to stop pcscd: %v", err) 179 + } 180 + 181 + // Verify process is stopped 182 + time.Sleep(100 * time.Millisecond) 183 + if mgr.cmd != nil && mgr.cmd.ProcessState != nil { 184 + if !mgr.cmd.ProcessState.Exited() { 185 + t.Error("pcscd process should have exited") 186 + } 187 + } 188 + }
+13
internal/modes/agent/agent.go
··· 142 142 } 143 143 144 144 if usePKCS11 { 145 + // Start pcscd daemon before initializing PKCS#11 client 146 + setupLog.Info("Starting pcscd daemon for hardware HSM support") 147 + pcscdMgr := agent.NewPCSCDManager(setupLog) 148 + if err := pcscdMgr.Start(); err != nil { 149 + return fmt.Errorf("failed to start pcscd: %w", err) 150 + } 151 + defer func() { 152 + setupLog.Info("Stopping pcscd daemon") 153 + if err := pcscdMgr.Stop(); err != nil { 154 + setupLog.Error(err, "Failed to stop pcscd cleanly") 155 + } 156 + }() 157 + 145 158 // Create PIN provider for Kubernetes Secret access 146 159 pinProvider := hsm.NewKubernetesPINProvider(k8sClient, k8sTypedClient, agentConfig.DeviceName, agentConfig.PodNamespace) 147 160