···3838 - Dynamically deployed pods for direct HSM communication
3939 - gRPC server on port 9090 with HTTP health checks on port 8093
4040 - Real PKCS#11 client for production or MockClient for testing
4141+ - Manages pcscd daemon lifecycle internally via PCSCDManager (no shell scripts required)
41424243**Test Utility:**
4344- **Test HSM Binary** (`cmd/test-hsm/main.go`): Standalone HSM operations testing and debugging
···60616162**gRPC Communication Architecture:**
6263- Protocol definition in `api/proto/hsm/v1/hsm.proto` with 10 HSM operations
6363-- Manager ↔ Agent: gRPC for efficient, type-safe HSM operations
6464+- Manager ↔ Agent: gRPC for efficient, type-safe HSM operations
6465- Discovery → Manager: Pod annotations for race-free device reporting
6566- **External → Manager**: REST API proxy routes to ALL agents for multi-device operations
6667- Generated code: `api/proto/hsm/v1/hsm.pb.go` and `hsm_grpc.pb.go`
6868+6969+**PCSCD Lifecycle Management:**
7070+- **PCSCDManager** (`internal/agent/pcscd_manager.go`): Go-native pcscd process management
7171+- Automatically started in agent mode when real PKCS#11 hardware is detected
7272+- Foreground mode with stdout/stderr piping for integrated logging
7373+- Graceful shutdown handling: SIGTERM → 5s timeout → SIGKILL fallback
7474+- Socket-based readiness detection (`/var/run/pcscd/pcscd.comm`)
7575+- Eliminates shell script dependency for maximum container security
67766877**Controller Hierarchy:**
6978```
···133142134143### Docker & Deployment
135144```bash
136136-# Production image (agent has PKCS#11 support)
145145+# Production image (FROM scratch base - ultra-minimal ~15MB)
137146make docker-build IMG=hsm-secrets-operator:latest
138147139139-# Production build only (testing handled via build tags)
148148+# Multi-arch build (supports amd64 and arm64)
149149+docker buildx build --platform linux/amd64,linux/arm64 -t hsm-secrets-operator:latest .
140150141151# Deploy to cluster
142152make deploy IMG=hsm-secrets-operator:latest
143153144144-# Generate installer bundle
154154+# Generate installer bundle
145155make build-installer IMG=hsm-secrets-operator:latest
146156```
157157+158158+**Container Architecture:**
159159+- **Base Image**: FROM scratch (no distro, no shell - maximum security)
160160+- **Size**: ~15MB (vs ~30MB with distroless:debug)
161161+- **Security**: Minimal attack surface - only essential binaries and libraries
162162+- **Dependencies**: Auto-discovered via iterative ldd/strings analysis in builder stage
163163+- **Multi-arch**: Supports x86_64 and arm64 via dynamic linker auto-detection
164164+- **Process Management**: Direct binary execution with Go-managed pcscd lifecycle
165165+- **No Shell**: All process management handled in Go code (internal/agent/pcscd_manager.go)
147166148167## CRD Structure and Relationships
149168
+91-44
Dockerfile
···22# Phase 2: Root + Distroless - compensates for root requirement with minimal attack surface
3344# Stage 1: Go builder (also serves as dependency source)
55-FROM golang:1.24-bookworm AS builder
55+FROM golang:1.24-trixie AS builder
66ARG TARGETOS
77ARG TARGETARCH
88···1010RUN apt-get update && apt-get install -y \
1111 opensc \
1212 pcscd \
1313+ libccid \
1314 libpcsclite1 \
1415 libusb-1.0-0 \
1516 udev \
···2021 && rm -rf /var/lib/apt/lists/*
21222223# Create necessary runtime directories
2323-RUN mkdir -p /run/pcscd /var/lock/pcsc && \
2424- chmod 755 /run/pcscd /var/lock/pcsc
2424+RUN mkdir -p /run/pcscd /var/run/pcscd /var/lock/pcsc && \
2525+ chmod 755 /run/pcscd /var/run/pcscd /var/lock/pcsc
2626+2727+# Create minimal /etc/passwd and /etc/group for nonroot user (65532:65532)
2828+RUN echo "nonroot:x:65532:65532:nonroot:/:" > /tmp/passwd && \
2929+ echo "nonroot:x:65532:" > /tmp/group
25302631WORKDIR /workspace
2732# Copy the Go Modules manifests
···4045# Build with CGO enabled for PKCS#11 support
4146RUN CGO_ENABLED=1 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o hsm-operator cmd/hsm-operator/main.go
42474343-# Stage 2: Base runtime stage with all files
4444-FROM gcr.io/distroless/cc-debian12:debug AS runtime
4848+# Collect all runtime dependencies using iterative discovery:
4949+# 1. Start with ldd on binaries → get compile-time linked libraries
5050+# 2. Recursively: ldd on discovered libraries → get their dependencies
5151+# 3. strings scan discovered libraries → find dlopen'd libraries (like libgcc_s, libpcsclite_real)
5252+# 4. Repeat step 2-3 on newly discovered libraries until no new deps found
5353+RUN echo "Discovering runtime dependencies (iterative)..." && \
5454+ # Define binaries to scan (includes CCID driver to catch libusb dependency)
5555+ SCAN_BINARIES="/workspace/hsm-operator /usr/sbin/pcscd /usr/bin/pkcs11-tool /usr/lib/pcsc/drivers/ifd-ccid.bundle/Contents/Linux/libccid.so" && \
5656+ mkdir -p /runtime-deps && \
5757+ touch /tmp/deps_all.txt /tmp/deps_previous.txt /tmp/deps_new.txt && \
5858+ # Start with our binaries
5959+ echo "$SCAN_BINARIES" | tr ' ' '\n' > /tmp/deps_new.txt && \
6060+ ITERATION=0 && \
6161+ while [ -s /tmp/deps_new.txt ] && [ $ITERATION -lt 10 ]; do \
6262+ ITERATION=$((ITERATION + 1)) && \
6363+ NEW_COUNT=$(wc -l < /tmp/deps_new.txt) && \
6464+ echo "Iteration $ITERATION: Processing $NEW_COUNT new items..." && \
6565+ # Run ldd on new items
6666+ for item in $(cat /tmp/deps_new.txt); do \
6767+ if [ -f "$item" ]; then \
6868+ ldd "$item" 2>/dev/null | grep "=>" | awk '{print $3}' | grep -v "^$" || true; \
6969+ # Also get dynamic linker
7070+ ldd "$item" 2>/dev/null | grep -o '/lib.*/ld-linux[^ ]*' || true; \
7171+ fi; \
7272+ done >> /tmp/deps_all.txt && \
7373+ # Scan new items for dlopen'd libraries (strings method)
7474+ for item in $(cat /tmp/deps_new.txt); do \
7575+ if [ -f "$item" ]; then \
7676+ strings "$item" 2>/dev/null | grep -E '\.so(\.[0-9]+)*$' | while read soname; do \
7777+ find /usr/lib /lib -name "$soname" 2>/dev/null || true; \
7878+ done; \
7979+ fi; \
8080+ done >> /tmp/deps_all.txt && \
8181+ # Find newly discovered deps (not in previous iterations)
8282+ sort -u /tmp/deps_all.txt > /tmp/deps_all_sorted.txt && \
8383+ comm -13 /tmp/deps_previous.txt /tmp/deps_all_sorted.txt > /tmp/deps_new.txt && \
8484+ cp /tmp/deps_all_sorted.txt /tmp/deps_previous.txt; \
8585+ done && \
8686+ # Final deduplication - just remove duplicate paths, keep both /lib and /usr/lib variants
8787+ sort -u /tmp/deps_all.txt > /tmp/deps.txt && \
8888+ echo "Found $(wc -l < /tmp/deps.txt) unique library paths after $ITERATION iterations" && \
8989+ cat /tmp/deps.txt && \
9090+ for lib in $(cat /tmp/deps.txt); do \
9191+ if [ -f "$lib" ]; then \
9292+ dir=$(dirname "$lib"); \
9393+ mkdir -p "/runtime-deps$dir"; \
9494+ cp -L "$lib" "/runtime-deps$lib"; \
9595+ fi; \
9696+ done && \
9797+ echo "Dependencies collected to /runtime-deps" && \
9898+ # Verify all binaries can find their dependencies
9999+ echo "Testing binaries for missing dependencies..." && \
100100+ for binary in $SCAN_BINARIES; do \
101101+ echo "Testing $binary..."; \
102102+ ldd "$binary" 2>&1 | grep "not found" && echo "ERROR: Missing dependencies for $binary" && exit 1 || true; \
103103+ done && \
104104+ echo "All binaries have satisfied dependencies"
451054646-# Copy essential system files and create nonroot user
4747-COPY --from=builder /etc/passwd /etc/passwd
4848-COPY --from=builder /etc/group /etc/group
106106+# Stage 2: Ultra-minimal FROM scratch runtime (no shell, no distro)
107107+# Maximum security: smallest possible attack surface (~15MB vs ~30MB distroless)
108108+FROM scratch
491095050-# Ensure nonroot user exists (distroless provides user 65532:65532)
110110+# Copy minimal user/group files for nonroot user (secure by default)
111111+COPY --from=builder /tmp/passwd /etc/passwd
112112+COPY --from=builder /tmp/group /etc/group
511135252-# Copy PKCS#11 and USB libraries with explicit architecture paths
5353-# Use find to locate the correct architecture-specific paths
114114+# Copy all runtime library dependencies (auto-discovered via ldd/strings)
115115+# Includes dynamic linker (ld-linux-*.so) for all architectures (x86_64, arm64, etc.)
116116+COPY --from=builder /runtime-deps /
117117+118118+# Copy PKCS#11 library (loaded via dlopen by Go app at runtime with user-specified path)
54119COPY --from=builder /usr/lib/*/opensc-pkcs11.so /usr/lib/pkcs11/
5555-COPY --from=builder /usr/lib/*/libopensc.so.8* /usr/lib/
5656-COPY --from=builder /usr/lib/*/libpcsclite.so.1* /usr/lib/
5757-COPY --from=builder /usr/lib/*/libusb-1.0.so.0* /usr/lib/
5858-COPY --from=builder /usr/lib/*/libudev.so.1* /usr/lib/
5959-COPY --from=builder /usr/lib/*/libglib-2.0.so.0* /lib/*/libglib-2.0.so.0* /usr/lib/
6060-COPY --from=builder /usr/lib/*/libgio-2.0.so.0* /lib/*/libgio-2.0.so.0* /usr/lib/
6161-COPY --from=builder /usr/lib/*/libgobject-2.0.so.0* /lib/*/libgobject-2.0.so.0* /usr/lib/
6262-COPY --from=builder /usr/lib/*/libgmodule-2.0.so.0* /lib/*/libgmodule-2.0.so.0* /usr/lib/
6363-COPY --from=builder /usr/lib/*/libmount.so.1* /lib/*/libmount.so.1* /usr/lib/
6464-COPY --from=builder /usr/lib/*/libselinux.so.1* /lib/*/libselinux.so.1* /usr/lib/
6565-COPY --from=builder /usr/lib/*/libffi.so.8* /lib/*/libffi.so.8* /usr/lib/
6666-COPY --from=builder /usr/lib/*/libpcre2-8.so.0* /lib/*/libpcre2-8.so.0* /usr/lib/
6767-COPY --from=builder /usr/lib/*/libblkid.so.1* /lib/*/libblkid.so.1* /usr/lib/
6868-COPY --from=builder /usr/lib/*/libcap.so.2* /usr/lib/
6969-COPY --from=builder /usr/lib/*/libsystemd.so.0* /lib/*/libsystemd.so.0* /usr/lib/
7070-COPY --from=builder /usr/lib/*/libgcrypt.so.20* /lib/*/libgcrypt.so.20* /usr/lib/
7171-COPY --from=builder /usr/lib/*/liblzma.so.5* /lib/*/liblzma.so.5* /usr/lib/
7272-COPY --from=builder /usr/lib/*/libzstd.so.1* /lib/*/libzstd.so.1* /usr/lib/
7373-COPY --from=builder /usr/lib/*/liblz4.so.1* /lib/*/liblz4.so.1* /usr/lib/
7474-COPY --from=builder /usr/lib/*/libgpg-error.so.0* /lib/*/libgpg-error.so.0* /usr/lib/
7575-COPY --from=builder /lib/*/libgcc_s.so.1* /usr/lib/
7676-# Copy zlib for pkcs11-tool
7777-COPY --from=builder /lib/*/libz.so.1* /usr/lib/
7812079121# Copy essential binaries
80122COPY --from=builder /usr/sbin/pcscd /usr/sbin/
81123COPY --from=builder /usr/bin/pkcs11-tool /usr/bin/
124124+125125+# Copy udev rules for HSM devices (CCID support)
126126+COPY --from=builder /lib/udev/rules.d/92-libccid.rules /lib/udev/rules.d/
127127+128128+# Copy CCID drivers for pcscd (Debian Trixie provides v1.6.2 with native Pico HSM multi-interface support)
129129+COPY --from=builder /usr/lib/pcsc /usr/lib/pcsc
130130+131131+# Copy CCID configuration file (needed for Info.plist symlink)
132132+COPY --from=builder /etc/libccid_Info.plist /etc/
8213383134# Copy CA certificates
84135COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
851368686-# Copy runtime directories (but NOT pcsc drivers - avoiding CCID)
137137+# Copy runtime directories (pcscd will use these)
87138COPY --from=builder /var/run/pcscd /run/pcscd
88139COPY --from=builder /var/lock/pcsc /var/lock/pcsc
891409090-# Copy application binary and entrypoint
141141+# Copy application binary (manages pcscd lifecycle internally - no shell needed)
91142COPY --from=builder /workspace/hsm-operator /hsm-operator
9292-COPY --chmod=755 entrypoint.sh /entrypoint.sh
931439494-# Default to distroless nonroot user (can be overridden by deployment securityContext)
144144+# Default to nonroot user for security (manager/discovery modes don't need root)
145145+# Agent mode overrides to root via Kubernetes securityContext for USB device access
95146USER 65532:65532
961479797-# Stage 3: Debug variant with shell (DEFAULT for testing)
9898-FROM runtime
9999-100100-# Debug image includes busybox shell for troubleshooting
101101-# Access via: kubectl exec -it pod -- /busybox/sh
102102-ENTRYPOINT ["/entrypoint.sh"]148148+# Direct binary execution - pcscd lifecycle managed by Go code in agent mode
149149+ENTRYPOINT ["/hsm-operator"]
+2-2
Makefile
···33# To re-generate a bundle for another specific version without changing the standard setup, you can:
44# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2)
55# - use environment variables to overwrite this value (e.g export VERSION=0.0.2)
66-VERSION ?= 0.6.24
66+VERSION ?= 0.6.32
7788# CHANNELS define the bundle channels used in the bundle.
99# Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable")
···321321ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}')
322322#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
323323ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}')
324324-GOLANGCI_LINT_VERSION ?= main
324324+GOLANGCI_LINT_VERSION ?= v2.5.0
325325326326.PHONY: kustomize
327327kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
-50
entrypoint.sh
···11-#!/busybox/sh
22-set -e
33-44-# Debug: Show user and USB device permissions for agent mode only
55-if [ "$1" = "--mode=agent" ]; then
66- echo "Starting pcscd as user: $(id)"
77- echo "Groups: $(groups)"
88- echo "USB device permissions:"
99- if [ -d /dev/bus/usb ]; then
1010- ls -la /dev/bus/usb/ | head -20
1111- echo "Checking for specific USB devices..."
1212- 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"
1313- else
1414- echo "ERROR: /dev/bus/usb not mounted"
1515- exit 1
1616- fi
1717-1818- # Try to trigger udev rules for USB devices
1919- if command -v udevadm >/dev/null 2>&1; then
2020- echo "Triggering udev rules for USB devices..."
2121- udevadm trigger --subsystem-match=usb --action=add 2>/dev/null || true
2222- udevadm settle --timeout=2 2>/dev/null || true
2323- fi
2424-2525- # Start pcscd (no CCID drivers available, will use direct access)
2626- pcscd -f -d -a &
2727- PCSCD_PID=$!
2828-2929- sleep 3
3030-3131- # Verify pcscd started
3232- if ! kill -0 $PCSCD_PID 2>/dev/null; then
3333- echo "ERROR: pcscd failed to start"
3434- exit 1
3535- fi
3636-fi
3737-3838-# Entrypoint script for HSM Secrets Operator
3939-# Supports running manager, discovery, or agent binaries from the same container
4040-4141-case "$1" in
4242- "--mode="*)
4343- # Direct mode flag usage (preferred)
4444- exec /hsm-operator "$@"
4545- ;;
4646- *)
4747- # Default to manager for backward compatibility
4848- exec /hsm-operator --mode=manager "$@"
4949- ;;
5050-esac
+2-2
helm/hsm-secrets-operator/Chart.yaml
···22name: hsm-secrets-operator
33description: A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets
44type: application
55-version: 0.6.24
66-appVersion: v0.6.24
55+version: 0.6.32
66+appVersion: v0.6.32
77icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg
88home: https://github.com/evanjarrett/hsm-secrets-operator
99sources:
+170
internal/agent/pcscd_manager.go
···11+/*
22+Copyright 2025.
33+44+Licensed under the Apache License, Version 2.0 (the "License");
55+you may not use this file except in compliance with the License.
66+You may obtain a copy of the License at
77+88+ http://www.apache.org/licenses/LICENSE-2.0
99+1010+Unless required by applicable law or agreed to in writing, software
1111+distributed under the License is distributed on an "AS IS" BASIS,
1212+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+See the License for the specific language governing permissions and
1414+limitations under the License.
1515+*/
1616+1717+package agent
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "os"
2323+ "os/exec"
2424+ "syscall"
2525+ "time"
2626+2727+ "github.com/go-logr/logr"
2828+)
2929+3030+// PCSCDManager manages the PC/SC Smart Card Daemon (pcscd) lifecycle.
3131+// It starts pcscd in foreground mode and handles graceful shutdown.
3232+type PCSCDManager struct {
3333+ cmd *exec.Cmd
3434+ ctx context.Context
3535+ cancel context.CancelFunc
3636+ logger logr.Logger
3737+}
3838+3939+// NewPCSCDManager creates a new PCSCD manager instance.
4040+func NewPCSCDManager(logger logr.Logger) *PCSCDManager {
4141+ ctx, cancel := context.WithCancel(context.Background())
4242+ return &PCSCDManager{
4343+ ctx: ctx,
4444+ cancel: cancel,
4545+ logger: logger.WithName("pcscd-manager"),
4646+ }
4747+}
4848+4949+// Start initializes and starts the pcscd daemon.
5050+// It runs pcscd in foreground mode with debug output and polkit disabled.
5151+// Blocks until pcscd is ready or times out after 5 seconds.
5252+func (p *PCSCDManager) Start() error {
5353+ if p.cmd != nil {
5454+ return fmt.Errorf("pcscd is already running")
5555+ }
5656+5757+ p.logger.Info("Starting pcscd daemon")
5858+5959+ // Start pcscd with:
6060+ // -f: foreground mode (don't daemonize)
6161+ // --disable-polkit: disable PolicyKit (no D-Bus in container)
6262+ // Note: Removed -d and -a debug flags for production - add back if needed
6363+ p.cmd = exec.CommandContext(p.ctx, "/usr/sbin/pcscd", "-f", "--disable-polkit")
6464+6565+ // Pipe output to parent process for centralized logging
6666+ p.cmd.Stdout = os.Stdout
6767+ p.cmd.Stderr = os.Stderr
6868+6969+ // Set process group for proper signal handling
7070+ // This ensures child processes are also signaled on shutdown
7171+ p.cmd.SysProcAttr = &syscall.SysProcAttr{
7272+ Setpgid: true,
7373+ }
7474+7575+ // Start the process
7676+ if err := p.cmd.Start(); err != nil {
7777+ return fmt.Errorf("failed to start pcscd: %w", err)
7878+ }
7979+8080+ p.logger.Info("pcscd process started", "pid", p.cmd.Process.Pid)
8181+8282+ // Wait for pcscd to be ready
8383+ if err := p.waitForReady(); err != nil {
8484+ // If pcscd fails to start, clean up the process
8585+ if stopErr := p.Stop(); stopErr != nil {
8686+ p.logger.Error(stopErr, "Failed to stop pcscd during cleanup")
8787+ }
8888+ return fmt.Errorf("pcscd failed to become ready: %w", err)
8989+ }
9090+9191+ p.logger.Info("pcscd is ready")
9292+ return nil
9393+}
9494+9595+// Stop gracefully shuts down the pcscd daemon.
9696+// It sends SIGTERM first, then SIGKILL if the process doesn't exit within 5 seconds.
9797+func (p *PCSCDManager) Stop() error {
9898+ // Always cancel the context first, even if process isn't running
9999+ p.cancel()
100100+101101+ if p.cmd == nil || p.cmd.Process == nil {
102102+ p.logger.V(1).Info("pcscd is not running, nothing to stop")
103103+ return nil
104104+ }
105105+106106+ p.logger.Info("Stopping pcscd daemon", "pid", p.cmd.Process.Pid)
107107+108108+ // Send SIGTERM for graceful shutdown
109109+ if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
110110+ p.logger.Error(err, "Failed to send SIGTERM to pcscd, forcing kill")
111111+ return p.cmd.Process.Kill()
112112+ }
113113+114114+ // Wait for process to exit with timeout
115115+ done := make(chan error, 1)
116116+ go func() {
117117+ done <- p.cmd.Wait()
118118+ }()
119119+120120+ select {
121121+ case err := <-done:
122122+ if err != nil {
123123+ p.logger.V(1).Info("pcscd exited with error", "error", err)
124124+ } else {
125125+ p.logger.Info("pcscd stopped gracefully")
126126+ }
127127+ return err
128128+ case <-time.After(5 * time.Second):
129129+ // Timeout - force kill
130130+ p.logger.Info("pcscd did not exit within timeout, forcing kill")
131131+ if err := p.cmd.Process.Kill(); err != nil {
132132+ return fmt.Errorf("failed to kill pcscd: %w", err)
133133+ }
134134+ _ = p.cmd.Wait() // Ignore wait error after force kill
135135+ return fmt.Errorf("pcscd was forcefully killed after timeout")
136136+ }
137137+}
138138+139139+// waitForReady polls for pcscd readiness by checking if the socket exists.
140140+// PC/SC Lite creates a socket at /var/run/pcscd/pcscd.comm when ready.
141141+// Waits up to 5 seconds with 100ms polling interval.
142142+func (p *PCSCDManager) waitForReady() error {
143143+ const (
144144+ maxAttempts = 50 // 50 attempts
145145+ pollInterval = 100 * time.Millisecond // 100ms interval
146146+ socketPath = "/var/run/pcscd/pcscd.comm"
147147+ )
148148+149149+ p.logger.V(1).Info("Waiting for pcscd to be ready", "socket", socketPath)
150150+151151+ for i := 0; i < maxAttempts; i++ {
152152+ // Check if the socket exists
153153+ if _, err := os.Stat(socketPath); err == nil {
154154+ p.logger.V(1).Info("pcscd socket detected", "attempts", i+1)
155155+ // Give it a tiny bit more time to fully initialize
156156+ time.Sleep(100 * time.Millisecond)
157157+ return nil
158158+ }
159159+160160+ // Check if process is still running
161161+ // If the process exited, no point in waiting
162162+ if p.cmd.ProcessState != nil && p.cmd.ProcessState.Exited() {
163163+ return fmt.Errorf("pcscd process exited unexpectedly")
164164+ }
165165+166166+ time.Sleep(pollInterval)
167167+ }
168168+169169+ return fmt.Errorf("pcscd did not become ready within %v", time.Duration(maxAttempts)*pollInterval)
170170+}
+188
internal/agent/pcscd_manager_test.go
···11+/*
22+Copyright 2025.
33+44+Licensed under the Apache License, Version 2.0 (the "License");
55+you may not use this file except in compliance with the License.
66+You may obtain a copy of the License at
77+88+ http://www.apache.org/licenses/LICENSE-2.0
99+1010+Unless required by applicable law or agreed to in writing, software
1111+distributed under the License is distributed on an "AS IS" BASIS,
1212+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313+See the License for the specific language governing permissions and
1414+limitations under the License.
1515+*/
1616+1717+package agent
1818+1919+import (
2020+ "os"
2121+ "os/exec"
2222+ "testing"
2323+ "time"
2424+2525+ "github.com/go-logr/logr"
2626+ ctrl "sigs.k8s.io/controller-runtime"
2727+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
2828+)
2929+3030+func TestNewPCSCDManager(t *testing.T) {
3131+ logger := zap.New(zap.UseDevMode(true))
3232+ mgr := NewPCSCDManager(logger)
3333+3434+ if mgr == nil {
3535+ t.Fatal("NewPCSCDManager returned nil")
3636+ }
3737+3838+ if mgr.ctx == nil {
3939+ t.Error("Context was not initialized")
4040+ }
4141+4242+ if mgr.cancel == nil {
4343+ t.Error("Cancel function was not initialized")
4444+ }
4545+4646+ if mgr.cmd != nil {
4747+ t.Error("Command should be nil before Start()")
4848+ }
4949+}
5050+5151+func TestPCSCDManager_StartWithoutPCSCD(t *testing.T) {
5252+ // This test verifies error handling when pcscd binary doesn't exist
5353+ // Note: This test would need to mock exec.Command to properly test
5454+ // without actually running pcscd. For now, we document expected behavior.
5555+5656+ // If pcscd is not available at /usr/sbin/pcscd, Start() should fail
5757+ // In a production container, pcscd will always be present
5858+5959+ // Skip this test in CI environments where pcscd may not be installed
6060+ if _, err := os.Stat("/usr/sbin/pcscd"); os.IsNotExist(err) {
6161+ t.Skip("pcscd binary not found, skipping test")
6262+ }
6363+6464+ // If we reach here, pcscd exists, so we can't test the "not found" case
6565+ // without mocking. Skip the test.
6666+ t.Skip("Cannot test missing pcscd without mocking exec.Command")
6767+}
6868+6969+func TestPCSCDManager_MultipleStartAttempts(t *testing.T) {
7070+ // Test that calling Start() multiple times fails appropriately
7171+ logger := zap.New(zap.UseDevMode(true))
7272+ mgr := NewPCSCDManager(logger)
7373+7474+ // Mock the cmd to prevent actual pcscd start
7575+ // In real implementation, we'd need dependency injection or interface
7676+ // to properly mock exec.Command
7777+7878+ // Simulate cmd being already set
7979+ if mgr.cmd == nil {
8080+ // Set a dummy command to simulate already-started state
8181+ mgr.cmd = &exec.Cmd{} // This is just for testing the check
8282+ }
8383+8484+ err := mgr.Start()
8585+ if err == nil {
8686+ t.Error("Expected error when starting already-running pcscd, got nil")
8787+ }
8888+8989+ if err != nil && err.Error() != "pcscd is already running" {
9090+ t.Errorf("Expected 'pcscd is already running' error, got: %v", err)
9191+ }
9292+}
9393+9494+func TestPCSCDManager_StopWithoutStart(t *testing.T) {
9595+ // Test that Stop() is safe to call even if Start() was never called
9696+ logger := zap.New(zap.UseDevMode(true))
9797+ mgr := NewPCSCDManager(logger)
9898+9999+ // Should not panic or error
100100+ err := mgr.Stop()
101101+ if err != nil {
102102+ t.Errorf("Stop() should be safe when not running, got error: %v", err)
103103+ }
104104+}
105105+106106+func TestPCSCDManager_ContextCancellation(t *testing.T) {
107107+ // Test that cancelling the context affects the manager
108108+ logger := zap.New(zap.UseDevMode(true))
109109+ mgr := NewPCSCDManager(logger)
110110+111111+ // Verify context is not cancelled initially
112112+ select {
113113+ case <-mgr.ctx.Done():
114114+ t.Error("Context should not be cancelled initially")
115115+ default:
116116+ // Good - context is active
117117+ }
118118+119119+ // Call Stop() which should cancel the context
120120+ if err := mgr.Stop(); err != nil {
121121+ t.Logf("Stop returned error (expected when not running): %v", err)
122122+ }
123123+124124+ // Give it a moment to cancel
125125+ time.Sleep(10 * time.Millisecond)
126126+127127+ // Verify context is now cancelled
128128+ select {
129129+ case <-mgr.ctx.Done():
130130+ // Good - context was cancelled
131131+ default:
132132+ t.Error("Context should be cancelled after Stop()")
133133+ }
134134+}
135135+136136+// Integration test helper - only runs if pcscd is available
137137+func getPCSCDTestLogger() logr.Logger {
138138+ ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
139139+ return ctrl.Log.WithName("pcscd-test")
140140+}
141141+142142+// TestPCSCDManager_Integration is an integration test that requires pcscd
143143+// It's skipped automatically if pcscd is not available or already running
144144+func TestPCSCDManager_Integration(t *testing.T) {
145145+ // Verify pcscd binary exists
146146+ if _, err := os.Stat("/usr/sbin/pcscd"); os.IsNotExist(err) {
147147+ t.Skip("pcscd binary not found, skipping integration test")
148148+ }
149149+150150+ // Check if pcscd is already running (socket exists)
151151+ if _, err := os.Stat("/var/run/pcscd/pcscd.comm"); err == nil {
152152+ t.Skip("pcscd is already running, skipping integration test")
153153+ }
154154+155155+ logger := getPCSCDTestLogger()
156156+ mgr := NewPCSCDManager(logger)
157157+158158+ // Start pcscd
159159+ if err := mgr.Start(); err != nil {
160160+ t.Skipf("Failed to start pcscd (may be a permission issue): %v", err)
161161+ }
162162+163163+ // Verify process is running
164164+ if mgr.cmd == nil || mgr.cmd.Process == nil {
165165+ t.Fatal("pcscd process should be running")
166166+ }
167167+168168+ // Give it time to fully initialize
169169+ time.Sleep(1 * time.Second)
170170+171171+ // Verify socket exists
172172+ if _, err := os.Stat("/var/run/pcscd/pcscd.comm"); os.IsNotExist(err) {
173173+ t.Error("pcscd socket not found after start")
174174+ }
175175+176176+ // Stop pcscd
177177+ if err := mgr.Stop(); err != nil {
178178+ t.Errorf("Failed to stop pcscd: %v", err)
179179+ }
180180+181181+ // Verify process is stopped
182182+ time.Sleep(100 * time.Millisecond)
183183+ if mgr.cmd != nil && mgr.cmd.ProcessState != nil {
184184+ if !mgr.cmd.ProcessState.Exited() {
185185+ t.Error("pcscd process should have exited")
186186+ }
187187+ }
188188+}