···11+name: E2E Tests
22+33+on:
44+ push:
55+ pull_request:
66+77+jobs:
88+ test-e2e:
99+ name: Run on Ubuntu
1010+ runs-on: ubuntu-latest
1111+ steps:
1212+ - name: Clone the code
1313+ uses: actions/checkout@v4
1414+1515+ - name: Setup Go
1616+ uses: actions/setup-go@v5
1717+ with:
1818+ go-version-file: go.mod
1919+2020+ - name: Install the latest version of kind
2121+ run: |
2222+ curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
2323+ chmod +x ./kind
2424+ sudo mv ./kind /usr/local/bin/kind
2525+2626+ - name: Verify kind installation
2727+ run: kind version
2828+2929+ - name: Running Test e2e
3030+ run: |
3131+ go mod tidy
3232+ make test-e2e
+23
.github/workflows/test.yml
···11+name: Tests
22+33+on:
44+ push:
55+ pull_request:
66+77+jobs:
88+ test:
99+ name: Run on Ubuntu
1010+ runs-on: ubuntu-latest
1111+ steps:
1212+ - name: Clone the code
1313+ uses: actions/checkout@v4
1414+1515+ - name: Setup Go
1616+ uses: actions/setup-go@v5
1717+ with:
1818+ go-version-file: go.mod
1919+2020+ - name: Running Tests
2121+ run: |
2222+ go mod tidy
2323+ make test
+27
.gitignore
···11+# Binaries for programs and plugins
22+*.exe
33+*.exe~
44+*.dll
55+*.so
66+*.dylib
77+bin/*
88+Dockerfile.cross
99+1010+# Test binary, built with `go test -c`
1111+*.test
1212+1313+# Output of the go coverage tool, specifically when used with LiteIDE
1414+*.out
1515+1616+# Go workspace file
1717+go.work
1818+1919+# Kubernetes Generated files - skip generated files, except for vendored files
2020+!vendor/**/zz_generated.*
2121+2222+# editor and IDE paraphernalia
2323+.idea
2424+.vscode
2525+*.swp
2626+*.swo
2727+*~
···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+## Project Overview
66+77+A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets, providing true secret portability through hardware-based storage. The operator implements a controller pattern that watches HSMSecret Custom Resource Definitions (CRDs) and maintains bidirectional synchronization between HSM binary data files and Kubernetes Secret objects.
88+99+## Development Commands
1010+1111+### Operator SDK Commands
1212+```bash
1313+# Initialize operator project (if not already done)
1414+operator-sdk init --domain=j5t.io --repo=github.com/evanjarrett/hsm-secrets-operator
1515+1616+# Create new APIs/CRDs
1717+operator-sdk create api --group=hsm --version=v1alpha1 --kind=HSMSecret
1818+1919+# Generate manifests and code
2020+make generate
2121+make manifests
2222+2323+# Build and test
2424+make build
2525+make test
2626+2727+# Docker operations
2828+make docker-build IMG=<registry>/hsm-secrets-operator:latest
2929+make docker-push IMG=<registry>/hsm-secrets-operator:latest
3030+3131+# Deploy to cluster
3232+make deploy IMG=<registry>/hsm-secrets-operator:latest
3333+make undeploy
3434+```
3535+3636+### Development Workflow
3737+```bash
3838+# Run operator locally for development
3939+make install # Install CRDs
4040+make run # Run controller locally
4141+4242+# Test with sample resources
4343+kubectl apply -f config/samples/
4444+4545+# View logs
4646+kubectl logs -f deployment/hsm-secrets-operator-controller-manager -n hsm-secrets-operator-system
4747+```
4848+4949+## Architecture
5050+5151+### Core Components
5252+5353+1. **HSMSecret CRD**: Custom resource definition that represents a secret stored on the Pico HSM
5454+2. **HSMSecret Controller**: Watches HSMSecret resources and manages synchronization
5555+3. **HSM Client**: PKCS#11 interface wrapper for Pico HSM communication
5656+4. **Secret Manager**: Handles Kubernetes Secret object lifecycle
5757+5858+### Data Flow
5959+6060+```
6161+Pico HSM (binary files) <-> HSMSecret CRD <-> Kubernetes Secret
6262+ secrets/appnamespace/appname-secret -> appnamespace/appname-secret
6363+```
6464+6565+### Controller Pattern
6666+6767+The operator follows the standard Kubernetes controller pattern:
6868+- **Watch**: Monitor HSMSecret CRDs and HSM file changes
6969+- **Reconcile**: Ensure desired state matches actual state
7070+- **Update**: Sync changes bidirectionally between HSM and K8s Secrets
7171+7272+## Goals
7373+7474+### Primary Objectives
7575+- **Simple KV Secrets**: Map HSM files to Kubernetes Secret objects (1:1 mapping)
7676+- **Bidirectional Sync**: Changes in HSM automatically update Secret objects
7777+- **Hardware Security**: Leverage Pico HSM's hardware-based protection
7878+- **Secret Portability**: Enable moving secrets between clusters via HSM
7979+8080+### Key Features
8181+- **Import Secrets**: Load existing secrets from HSM into Kubernetes
8282+- **Edit Secrets**: Modify secrets without using cumbersome pkcs11-tool
8383+- **Delete Secrets**: Remove secrets from both HSM and Kubernetes
8484+- **Auto-Sync**: Detect HSM changes and update corresponding Secret objects
8585+8686+## HSMSecret CRD Structure
8787+8888+```yaml
8989+apiVersion: hsm.j5t.io/v1alpha1
9090+kind: HSMSecret
9191+metadata:
9292+ name: appname-secret
9393+ namespace: appnamespace
9494+spec:
9595+ hsmPath: "secrets/appnamespace/appname-secret" # Path on Pico HSM
9696+ secretName: "appname-secret" # Target K8s Secret name (optional)
9797+ autoSync: true # Enable bidirectional sync (default: true)
9898+ secretType: "Opaque" # Kubernetes Secret type (default: Opaque)
9999+ syncInterval: 300 # Sync interval in seconds (default: 300)
100100+status:
101101+ lastSyncTime: "2024-01-15T10:30:00Z"
102102+ hsmChecksum: "sha256:abc123..."
103103+ secretChecksum: "sha256:def456..."
104104+ syncStatus: "InSync" | "OutOfSync" | "Error" | "Pending"
105105+ lastError: "Error message if any"
106106+ conditions: [] # Standard Kubernetes conditions
107107+ secretRef: # Reference to created Secret
108108+ name: "appname-secret"
109109+ namespace: "appnamespace"
110110+```
111111+112112+## Implementation Strategy
113113+114114+### Phase 1: Basic Infrastructure ✅ COMPLETED
115115+- [x] Initialize operator-sdk project structure
116116+- [x] Define HSMSecret CRD with complete spec and status
117117+- [x] Implement HSM client wrapper interface with PKCS#11 and Mock implementations
118118+- [x] Create controller skeleton with full reconciliation logic
119119+120120+### Phase 2: Core Functionality ✅ COMPLETED
121121+- [x] Implement HSM file reading/writing via client interface
122122+- [x] Add Kubernetes Secret creation/update logic with owner references
123123+- [x] Build complete reconciliation loop with finalizers
124124+- [x] Add comprehensive error handling and logging
125125+126126+### Phase 3: Bidirectional Sync ✅ COMPLETED
127127+- [x] Implement HSM sync via configurable polling intervals
128128+- [x] Add SHA256 checksum-based change detection
129129+- [x] Handle conflict resolution through status reporting
130130+- [x] Add detailed status reporting with conditions and timestamps
131131+132132+### Phase 4: Secret Management Operations 🚧 IN PROGRESS
133133+- [x] Import existing HSM secrets through HSMSecret CRDs
134134+- [ ] Secret editing interface (kubectl plugin or annotations)
135135+- [x] Secret deletion with proper cleanup via finalizers
136136+- [ ] Bulk operations support
137137+138138+### Phase 5: USB Device Discovery ✅ COMPLETED
139139+- [x] HSMDevice CRD for representing discovered HSM hardware
140140+- [x] USB device discovery logic with sysfs scanning
141141+- [x] Path-based device discovery with glob patterns
142142+- [x] DaemonSet controller for node-level device scanning
143143+- [x] Device plugin integration for Kubernetes resource allocation
144144+- [x] Well-known HSM device specifications (Pico HSM, SmartCard-HSM)
145145+- [x] Auto-discovery based on device types
146146+147147+## ✅ Current Implementation Status
148148+149149+### Completed Components
150150+151151+1. **HSMSecret CRD** (`api/v1alpha1/hsmsecret_types.go`)
152152+ - Complete API definition with all fields and validation
153153+ - Custom printer columns for `kubectl get hsmsecret`
154154+ - Short name support (`hsmsec`)
155155+ - Comprehensive status tracking with checksums and conditions
156156+157157+2. **HSM Client Architecture** (`internal/hsm/`)
158158+ - **Client Interface**: Flexible interface supporting multiple HSM implementations
159159+ - **Mock Client**: Full testing implementation with pre-populated test secrets
160160+ - **PKCS#11 Client**: Production-ready skeleton for real Pico HSM integration
161161+ - **Checksum System**: SHA256 checksums for data integrity verification
162162+163163+3. **Controller Implementation** (`internal/controller/hsmsecret_controller.go`)
164164+ - Complete reconciliation loop with error handling
165165+ - Bidirectional sync between HSM and Kubernetes Secrets
166166+ - Finalizer-based cleanup on HSMSecret deletion
167167+ - Auto-sync with configurable intervals (default: 300s)
168168+ - Owner references for proper garbage collection
169169+ - Status updates with detailed condition reporting
170170+171171+4. **USB Device Discovery & Mirroring** (`internal/discovery/`)
172172+ - **USB Discoverer**: Scans sysfs for USB devices matching vendor/product IDs
173173+ - **Path Discoverer**: Glob-based device path discovery (e.g., /dev/ttyUSB*)
174174+ - **Device Manager**: Kubernetes resource allocation and device management
175175+ - **Mirroring Manager**: Cross-node HSM device synchronization and failover
176176+ - **Well-known Specs**: Built-in USB specifications for Pico HSM and SmartCard-HSM
177177+ - **Topology Manager**: Primary/mirror device role assignment and health monitoring
178178+179179+5. **HSMDevice CRD** (`api/v1alpha1/hsmdevice_types.go`)
180180+ - Complete device discovery specification with USB and path-based options
181181+ - Auto-discovery based on device types with well-known specifications
182182+ - Mirroring policies for cross-node high availability
183183+ - Node selector support for targeted discovery
184184+ - Comprehensive status tracking with discovered device details
185185+ - Custom printer columns for `kubectl get hsmdevice`
186186+187187+6. **REST API Server** (`internal/api/`)
188188+ - **Gin HTTP Server**: Complete REST API with all CRUD operations
189189+ - **Secret Management**: Create, read, update, delete HSM secrets via HTTP
190190+ - **Bulk Operations**: Import/export multiple secrets with JSON payloads
191191+ - **Health & Metrics**: System health checks and operational metrics
192192+ - **Error Handling**: Comprehensive error responses with detailed messages
193193+194194+6. **Production Features**
195195+ - ✅ All unit tests passing
196196+ - ✅ Docker image builds successfully
197197+ - ✅ CRDs and RBAC manifests auto-generated
198198+ - ✅ Sample HSMSecret and HSMDevice configurations provided
199199+ - ✅ DaemonSet configuration for node-level device discovery
200200+ - ✅ Proper RBAC permissions for Secrets, Events, and Device Discovery
201201+ - ✅ Comprehensive logging and error handling
202202+203203+### Ready for Deployment
204204+205205+The operator can be immediately deployed and tested:
206206+207207+```bash
208208+# Build and deploy
209209+make docker-build IMG=hsm-secrets-operator:latest
210210+make deploy IMG=hsm-secrets-operator:latest
211211+212212+# Deploy device discovery (optional - for USB HSM auto-discovery)
213213+kubectl apply -f config/samples/daemonset.yaml
214214+215215+# Test with sample resources
216216+kubectl apply -f config/samples/hsm_v1alpha1_hsmsecret.yaml
217217+kubectl apply -f config/samples/hsm_v1alpha1_hsmdevice.yaml
218218+219219+# Monitor status
220220+kubectl get hsmsecret -w
221221+kubectl get hsmdevice -w
222222+kubectl get secrets
223223+```
224224+225225+### Complete Files Structure
226226+```
227227+├── api/v1alpha1/
228228+│ ├── hsmsecret_types.go # HSMSecret CRD with mirroring support
229229+│ ├── hsmdevice_types.go # HSMDevice CRD with USB discovery
230230+│ └── groupversion_info.go # API group metadata
231231+├── internal/
232232+│ ├── controller/
233233+│ │ ├── hsmsecret_controller.go # Secret reconciliation with fallback
234234+│ │ └── hsmdevice_controller.go # Device discovery and mirroring
235235+│ ├── discovery/
236236+│ │ ├── usb.go # USB device discovery
237237+│ │ ├── mirroring.go # Cross-node device mirroring
238238+│ │ └── deviceplugin.go # Kubernetes device management
239239+│ ├── hsm/
240240+│ │ ├── client.go # HSM client interface
241241+│ │ ├── mock_client.go # Full test implementation
242242+│ │ └── pkcs11_client.go # Production PKCS#11 client
243243+│ └── api/
244244+│ ├── server.go # REST API server with Gin
245245+│ ├── handlers.go # HTTP request handlers
246246+│ └── middleware.go # API middleware
247247+├── examples/
248248+│ ├── basic/ # Basic usage examples
249249+│ ├── advanced/ # Advanced configurations
250250+│ │ ├── talos-deployment.yaml # Talos Linux deployment
251251+│ │ ├── talos-build-guide.md # Talos setup guide
252252+│ │ └── custom-library-guide.md # PKCS#11 library integration
253253+│ └── api/ # API usage examples
254254+│ ├── bulk-operations.sh # Basic bulk operations
255255+│ ├── advanced-bulk-import.sh # Advanced bulk import
256256+│ ├── direct-import-examples.sh # Direct API examples
257257+│ ├── production-import.json # Sample production config
258258+│ └── bulk-secrets.json # Sample bulk config
259259+├── scripts/
260260+│ └── build-talos.sh # Talos Linux build automation
261261+├── deploy/
262262+│ └── talos/ # Talos-specific manifests
263263+├── config/
264264+│ ├── crd/bases/ # Generated CRD manifests
265265+│ ├── rbac/ # Generated RBAC rules
266266+│ └── samples/ # Sample resources
267267+├── Dockerfile # Standard operator image
268268+├── Dockerfile.talos # Talos-optimized image
269269+└── cmd/main.go # Main operator entry point
270270+```
271271+272272+## Technical Requirements
273273+274274+### Dependencies
275275+- **operator-sdk**: For scaffolding and building the operator
276276+- **controller-runtime**: Kubernetes controller framework
277277+- **PKCS#11 library**: For HSM communication (sc-hsm-embedded)
278278+- **OpenSC**: PKCS#11 middleware for smart cards/HSMs
279279+280280+### HSM Integration
281281+- Use PKCS#11 interface for Pico HSM communication
282282+- Handle HSM authentication and session management
283283+- Implement secure key storage and retrieval
284284+- Support HSM-specific error handling
285285+286286+### Kubernetes Integration
287287+- Standard Secret object management
288288+- RBAC for Secret read/write operations
289289+- Event generation for audit trails
290290+- Finalizers for cleanup on deletion
291291+292292+## Development Environment
293293+294294+### Container Setup
295295+The Dockerfile builds an Alpine-based environment with:
296296+- OpenSC development libraries
297297+- PCSC-Lite for smart card communication
298298+- sc-hsm-embedded library compilation
299299+- USB device support
300300+301301+### Testing Strategy
302302+- Unit tests for HSM client wrapper
303303+- Integration tests with mock HSM
304304+- End-to-end tests with real Pico HSM device
305305+- Chaos testing for sync reliability
306306+307307+## Security Considerations
308308+309309+### HSM Security
310310+- Private keys never leave the HSM
311311+- All cryptographic operations performed on-device
312312+- Hardware-based random number generation
313313+- Tamper resistance and secure storage
314314+315315+### Kubernetes Security
316316+- Principle of least privilege RBAC
317317+- Secret encryption at rest (etcd)
318318+- Network policies for HSM access
319319+- Audit logging for all operations
320320+321321+### Operational Security
322322+- HSM authentication management
323323+- Certificate lifecycle management
324324+- Backup and recovery procedures
325325+- Key rotation strategies
326326+327327+## Monitoring and Observability
328328+329329+### Metrics
330330+- Secret sync success/failure rates
331331+- HSM operation latencies
332332+- Secret object count and status
333333+- Error rates by type
334334+335335+### Logging
336336+- Structured logging with correlation IDs
337337+- HSM operation audit trail
338338+- Secret lifecycle events
339339+- Performance metrics
340340+341341+### Alerting
342342+- HSM connectivity issues
343343+- Sync failures or conflicts
344344+- Authentication failures
345345+- Hardware errors
346346+347347+## Future Enhancements
348348+349349+### Advanced Features
350350+- Multi-HSM support for high availability
351351+- Cross-cluster secret replication
352352+- Secret versioning and rollback
353353+- Automated key rotation
354354+355355+### Integration Opportunities
356356+- ArgoCD/GitOps integration
357357+- Vault operator compatibility
358358+- Service mesh certificate management
359359+- CI/CD pipeline integration
360360+361361+## Getting Started
362362+363363+### Prerequisites
364364+- Kubernetes cluster (v1.20+)
365365+- Pico HSM device with configured partitions
366366+- operator-sdk CLI tool
367367+- kubectl access with appropriate RBAC
368368+369369+### Quick Start
370370+```bash
371371+# Initialize operator project
372372+operator-sdk init --domain=security --repo=github.com/evanjarrett/hsm-secrets-operator
373373+374374+# Create CRD
375375+operator-sdk create api --group=hsm --version=v1alpha1 --kind=HSMSecret
376376+377377+# Build and deploy
378378+make docker-build docker-push IMG=<registry>/hsm-secrets-operator:latest
379379+make deploy IMG=<registry>/hsm-secrets-operator:latest
380380+```
381381+382382+### Example Usage
383383+```yaml
384384+# Create HSMSecret resource
385385+apiVersion: hsm.j5t.io/v1alpha1
386386+kind: HSMSecret
387387+metadata:
388388+ name: database-credentials
389389+ namespace: production
390390+spec:
391391+ hsmPath: "secrets/production/database-credentials"
392392+ secretName: "database-credentials"
393393+ autoSync: true
394394+ syncInterval: 60
395395+ secretType: Opaque
396396+```
397397+398398+### Monitoring Operations
399399+```bash
400400+# View HSMSecret status with custom columns
401401+kubectl get hsmsecret
402402+kubectl get hsmsec # Using short name
403403+404404+# View HSMDevice status with custom columns
405405+kubectl get hsmdevice
406406+kubectl get hsmdev # Using short name
407407+408408+# Describe for detailed information
409409+kubectl describe hsmsecret database-credentials
410410+kubectl describe hsmdevice pico-hsm-discovery
411411+412412+# Check created secrets
413413+kubectl get secrets -l managed-by=hsm-secrets-operator
414414+415415+# Monitor sync and discovery status
416416+kubectl get hsmsecret database-credentials -o jsonpath='{.status.syncStatus}'
417417+kubectl get hsmdevice pico-hsm-discovery -o jsonpath='{.status.phase}'
418418+419419+# View discovered devices
420420+kubectl get hsmdevice pico-hsm-discovery -o jsonpath='{.status.discoveredDevices[*].devicePath}'
421421+```
422422+423423+This operator design provides a secure, hardware-backed secret management solution that integrates seamlessly with Kubernetes while maintaining the security benefits of HSM-based storage.
+62
Dockerfile
···11+# Build the manager binary
22+FROM golang:1.24 AS builder
33+ARG TARGETOS
44+ARG TARGETARCH
55+66+WORKDIR /workspace
77+# Copy the Go Modules manifests
88+COPY go.mod go.mod
99+COPY go.sum go.sum
1010+# cache deps before building and copying source so that we don't need to re-download as much
1111+# and so that source changes don't invalidate our downloaded layer
1212+RUN go mod download
1313+1414+# Copy the go source
1515+COPY cmd/main.go cmd/main.go
1616+COPY api/ api/
1717+COPY internal/ internal/
1818+1919+# Build
2020+# the GOARCH has not a default value to allow the binary be built according to the host where the command
2121+# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
2222+# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
2323+# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
2424+RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
2525+2626+FROM alpine:3.22 AS base
2727+2828+# Update Alpine packages
2929+RUN apk update
3030+3131+# Install compilation tools
3232+RUN apk add --no-cache \
3333+ git \
3434+ gcc \
3535+ g++ \
3636+ make \
3737+ cmake \
3838+ pkgconfig \
3939+ openssl-dev \
4040+ pcsc-lite-dev \
4141+ libusb-dev \
4242+ autoconf \
4343+ automake \
4444+ libtool
4545+4646+RUN cd / && git clone https://github.com/CardContact/sc-hsm-embedded.git
4747+WORKDIR /sc-hsm-embedded
4848+RUN autoreconf -fi && ./configure
4949+RUN make && make install
5050+5151+FROM alpine:3.22
5252+RUN apk add --no-cache opensc-dev ccid pcsc-lite openssl libtool libusb
5353+5454+COPY --from=base /usr/lib/libssl.so* /usr/lib/
5555+COPY --from=base /usr/lib/libcrypto.so* /usr/lib/
5656+COPY --from=base /usr/local/ /usr/local/
5757+5858+WORKDIR /
5959+COPY --from=builder /workspace/manager .
6060+USER 65532:65532
6161+6262+ENTRYPOINT ["/manager"]
+128
Dockerfile.talos
···11+# Multi-stage Dockerfile for HSM Secrets Operator with PKCS#11 libraries
22+# Optimized for Talos Linux deployments
33+44+# Stage 1: Build PKCS#11 libraries
55+FROM alpine:3.18 AS pkcs11-builder
66+77+# Install build dependencies
88+RUN apk add --no-cache \
99+ wget \
1010+ tar \
1111+ gzip \
1212+ build-base \
1313+ autoconf \
1414+ automake \
1515+ libtool \
1616+ pkgconfig \
1717+ openssl-dev \
1818+ libusb-dev \
1919+ pcsc-lite-dev \
2020+ flex \
2121+ help2man
2222+2323+# Build OpenSC (most common PKCS#11 library)
2424+ENV OPENSC_VERSION=0.24.0
2525+RUN wget https://github.com/OpenSC/OpenSC/releases/download/${OPENSC_VERSION}/opensc-${OPENSC_VERSION}.tar.gz && \
2626+ tar -xzf opensc-${OPENSC_VERSION}.tar.gz && \
2727+ cd opensc-${OPENSC_VERSION} && \
2828+ ./configure \
2929+ --prefix=/usr/local \
3030+ --enable-pcsc \
3131+ --enable-openssl \
3232+ --disable-static \
3333+ --enable-shared && \
3434+ make -j$(nproc) && \
3535+ make install
3636+3737+# Build YubiKey PKCS#11 library (optional but common)
3838+ENV YUBICO_PIV_VERSION=2.4.0
3939+RUN wget https://developers.yubico.com/yubico-piv-tool/Releases/yubico-piv-tool-${YUBICO_PIV_VERSION}.tar.gz && \
4040+ tar -xzf yubico-piv-tool-${YUBICO_PIV_VERSION}.tar.gz && \
4141+ cd yubico-piv-tool-${YUBICO_PIV_VERSION} && \
4242+ ./configure --prefix=/usr/local && \
4343+ make -j$(nproc) && \
4444+ make install
4545+4646+# Build SoftHSM (useful for testing and dev environments)
4747+ENV SOFTHSM_VERSION=2.6.1
4848+RUN wget https://dist.opendnssec.org/source/softhsm-${SOFTHSM_VERSION}.tar.gz && \
4949+ tar -xzf softhsm-${SOFTHSM_VERSION}.tar.gz && \
5050+ cd softhsm-${SOFTHSM_VERSION} && \
5151+ ./configure --prefix=/usr/local && \
5252+ make -j$(nproc) && \
5353+ make install
5454+5555+# Organize libraries for runtime stage
5656+RUN mkdir -p /pkcs11-libs && \
5757+ cp /usr/local/lib/pkcs11/*.so /pkcs11-libs/ && \
5858+ cp /usr/local/lib/libykcs11*.so /pkcs11-libs/ 2>/dev/null || true && \
5959+ cp /usr/local/lib/libsofthsm2.so /pkcs11-libs/ 2>/dev/null || true && \
6060+ chmod 755 /pkcs11-libs/*.so
6161+6262+# Create library configuration files
6363+RUN mkdir -p /pkcs11-config
6464+COPY <<EOF /pkcs11-config/opensc.conf
6565+# OpenSC Configuration for HSM Secrets Operator
6666+app default {
6767+ card_drivers = piv, openpgp, sc-hsm;
6868+ reader_drivers = pcsc, openct;
6969+}
7070+EOF
7171+7272+# Stage 2: Build Go application
7373+FROM golang:1.21-alpine AS go-builder
7474+7575+ARG TARGETOS
7676+ARG TARGETARCH
7777+7878+WORKDIR /workspace
7979+8080+# Copy the Go Modules manifests
8181+COPY go.mod go.mod
8282+COPY go.sum go.sum
8383+8484+# Cache deps before building and copying source so that we don't need to re-download as much
8585+# and so that source changes don't invalidate our downloaded layer
8686+RUN go mod download
8787+8888+# Copy the go source
8989+COPY cmd/main.go cmd/main.go
9090+COPY api/ api/
9191+COPY internal/ internal/
9292+9393+# Build
9494+# the GOARCH has not a default value to allow the binary be built according to the host where the command
9595+# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
9696+# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
9797+# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
9898+RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
9999+100100+# Stage 3: Runtime image optimized for Talos
101101+FROM gcr.io/distroless/static:nonroot
102102+103103+# Labels for image metadata
104104+LABEL org.opencontainers.image.title="HSM Secrets Operator for Talos"
105105+LABEL org.opencontainers.image.description="Kubernetes operator for managing HSM-backed secrets on Talos Linux"
106106+LABEL org.opencontainers.image.vendor="HSM Secrets Operator"
107107+LABEL org.opencontainers.image.licenses="Apache-2.0"
108108+109109+# Copy PKCS#11 libraries from builder stage
110110+COPY --from=pkcs11-builder /pkcs11-libs/* /usr/local/lib/pkcs11/
111111+COPY --from=pkcs11-builder /pkcs11-config/* /etc/pkcs11/
112112+113113+# Copy runtime dependencies (minimal)
114114+COPY --from=pkcs11-builder /usr/local/lib/libopensc.so* /usr/local/lib/
115115+COPY --from=pkcs11-builder /usr/local/lib/libykcs11.so* /usr/local/lib/
116116+COPY --from=pkcs11-builder /usr/local/lib/libsofthsm2.so* /usr/local/lib/
117117+118118+# Copy the manager binary
119119+COPY --from=go-builder /workspace/manager .
120120+121121+# Set library path for PKCS#11 libraries
122122+ENV LD_LIBRARY_PATH="/usr/local/lib/pkcs11:/usr/local/lib"
123123+ENV PKCS11_MODULE_PATH="/usr/local/lib/pkcs11"
124124+ENV OPENSC_CONF="/etc/pkcs11/opensc.conf"
125125+126126+USER 65532:65532
127127+128128+ENTRYPOINT ["/manager"]
+362
Makefile
···11+# VERSION defines the project version for the bundle.
22+# Update this value when you upgrade the version of your project.
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.0.1
77+88+# 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")
1010+# To re-generate a bundle for other specific channels without changing the standard setup, you can:
1111+# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable)
1212+# - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable")
1313+ifneq ($(origin CHANNELS), undefined)
1414+BUNDLE_CHANNELS := --channels=$(CHANNELS)
1515+endif
1616+1717+# DEFAULT_CHANNEL defines the default channel used in the bundle.
1818+# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable")
1919+# To re-generate a bundle for any other default channel without changing the default setup, you can:
2020+# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable)
2121+# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable")
2222+ifneq ($(origin DEFAULT_CHANNEL), undefined)
2323+BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL)
2424+endif
2525+BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL)
2626+2727+# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images.
2828+# This variable is used to construct full image tags for bundle and catalog images.
2929+#
3030+# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both
3131+# j5t.io/hsm-secrets-operator-bundle:$VERSION and j5t.io/hsm-secrets-operator-catalog:$VERSION.
3232+IMAGE_TAG_BASE ?= j5t.io/hsm-secrets-operator
3333+3434+# BUNDLE_IMG defines the image:tag used for the bundle.
3535+# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=<some-registry>/<project-name-bundle>:<tag>)
3636+BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION)
3737+3838+# BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command
3939+BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS)
4040+4141+# USE_IMAGE_DIGESTS defines if images are resolved via tags or digests
4242+# You can enable this value if you would like to use SHA Based Digests
4343+# To enable set flag to true
4444+USE_IMAGE_DIGESTS ?= false
4545+ifeq ($(USE_IMAGE_DIGESTS), true)
4646+ BUNDLE_GEN_FLAGS += --use-image-digests
4747+endif
4848+4949+# Set the Operator SDK version to use. By default, what is installed on the system is used.
5050+# This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit.
5151+OPERATOR_SDK_VERSION ?= v1.41.1
5252+# Image URL to use all building/pushing image targets
5353+IMG ?= controller:latest
5454+5555+# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
5656+ifeq (,$(shell go env GOBIN))
5757+GOBIN=$(shell go env GOPATH)/bin
5858+else
5959+GOBIN=$(shell go env GOBIN)
6060+endif
6161+6262+# CONTAINER_TOOL defines the container tool to be used for building images.
6363+# Be aware that the target commands are only tested with Docker which is
6464+# scaffolded by default. However, you might want to replace it to use other
6565+# tools. (i.e. podman)
6666+CONTAINER_TOOL ?= docker
6767+6868+# Setting SHELL to bash allows bash commands to be executed by recipes.
6969+# Options are set to exit when a recipe line exits non-zero or a piped command fails.
7070+SHELL = /usr/bin/env bash -o pipefail
7171+.SHELLFLAGS = -ec
7272+7373+.PHONY: all
7474+all: build
7575+7676+##@ General
7777+7878+# The help target prints out all targets with their descriptions organized
7979+# beneath their categories. The categories are represented by '##@' and the
8080+# target descriptions by '##'. The awk command is responsible for reading the
8181+# entire set of makefiles included in this invocation, looking for lines of the
8282+# file as xyz: ## something, and then pretty-format the target and help. Then,
8383+# if there's a line with ##@ something, that gets pretty-printed as a category.
8484+# More info on the usage of ANSI control characters for terminal formatting:
8585+# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
8686+# More info on the awk command:
8787+# http://linuxcommand.org/lc3_adv_awk.php
8888+8989+.PHONY: help
9090+help: ## Display this help.
9191+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
9292+9393+##@ Development
9494+9595+.PHONY: manifests
9696+manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
9797+ $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
9898+9999+.PHONY: generate
100100+generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
101101+ $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
102102+103103+.PHONY: fmt
104104+fmt: ## Run go fmt against code.
105105+ go fmt ./...
106106+107107+.PHONY: vet
108108+vet: ## Run go vet against code.
109109+ go vet ./...
110110+111111+.PHONY: test
112112+test: manifests generate fmt vet setup-envtest ## Run tests.
113113+ KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out
114114+115115+# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.
116116+# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
117117+# CertManager is installed by default; skip with:
118118+# - CERT_MANAGER_INSTALL_SKIP=true
119119+KIND_CLUSTER ?= hsm-secrets-operator-test-e2e
120120+121121+.PHONY: setup-test-e2e
122122+setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist
123123+ @command -v $(KIND) >/dev/null 2>&1 || { \
124124+ echo "Kind is not installed. Please install Kind manually."; \
125125+ exit 1; \
126126+ }
127127+ @case "$$($(KIND) get clusters)" in \
128128+ *"$(KIND_CLUSTER)"*) \
129129+ echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \
130130+ *) \
131131+ echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \
132132+ $(KIND) create cluster --name $(KIND_CLUSTER) ;; \
133133+ esac
134134+135135+.PHONY: test-e2e
136136+test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
137137+ KIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e/ -v -ginkgo.v
138138+ $(MAKE) cleanup-test-e2e
139139+140140+.PHONY: cleanup-test-e2e
141141+cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests
142142+ @$(KIND) delete cluster --name $(KIND_CLUSTER)
143143+144144+.PHONY: lint
145145+lint: golangci-lint ## Run golangci-lint linter
146146+ $(GOLANGCI_LINT) run
147147+148148+.PHONY: lint-fix
149149+lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes
150150+ $(GOLANGCI_LINT) run --fix
151151+152152+.PHONY: lint-config
153153+lint-config: golangci-lint ## Verify golangci-lint linter configuration
154154+ $(GOLANGCI_LINT) config verify
155155+156156+##@ Build
157157+158158+.PHONY: build
159159+build: manifests generate fmt vet ## Build manager binary.
160160+ go build -o bin/manager cmd/main.go
161161+162162+.PHONY: run
163163+run: manifests generate fmt vet ## Run a controller from your host.
164164+ go run ./cmd/main.go
165165+166166+# If you wish to build the manager image targeting other platforms you can use the --platform flag.
167167+# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
168168+# More info: https://docs.docker.com/develop/develop-images/build_enhancements/
169169+.PHONY: docker-build
170170+docker-build: ## Build docker image with the manager.
171171+ $(CONTAINER_TOOL) build -t ${IMG} .
172172+173173+.PHONY: docker-push
174174+docker-push: ## Push docker image with the manager.
175175+ $(CONTAINER_TOOL) push ${IMG}
176176+177177+# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple
178178+# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to:
179179+# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/
180180+# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/
181181+# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=<myregistry/image:<tag>> then the export will fail)
182182+# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option.
183183+PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le
184184+.PHONY: docker-buildx
185185+docker-buildx: ## Build and push docker image for the manager for cross-platform support
186186+ # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile
187187+ sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross
188188+ - $(CONTAINER_TOOL) buildx create --name hsm-secrets-operator-builder
189189+ $(CONTAINER_TOOL) buildx use hsm-secrets-operator-builder
190190+ - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross .
191191+ - $(CONTAINER_TOOL) buildx rm hsm-secrets-operator-builder
192192+ rm Dockerfile.cross
193193+194194+.PHONY: build-installer
195195+build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment.
196196+ mkdir -p dist
197197+ cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
198198+ $(KUSTOMIZE) build config/default > dist/install.yaml
199199+200200+##@ Deployment
201201+202202+ifndef ignore-not-found
203203+ ignore-not-found = false
204204+endif
205205+206206+.PHONY: install
207207+install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
208208+ $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f -
209209+210210+.PHONY: uninstall
211211+uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
212212+ $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
213213+214214+.PHONY: deploy
215215+deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
216216+ cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
217217+ $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f -
218218+219219+.PHONY: undeploy
220220+undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
221221+ $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
222222+223223+##@ Dependencies
224224+225225+## Location to install dependencies to
226226+LOCALBIN ?= $(shell pwd)/bin
227227+$(LOCALBIN):
228228+ mkdir -p $(LOCALBIN)
229229+230230+## Tool Binaries
231231+KUBECTL ?= kubectl
232232+KIND ?= kind
233233+KUSTOMIZE ?= $(LOCALBIN)/kustomize
234234+CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
235235+ENVTEST ?= $(LOCALBIN)/setup-envtest
236236+GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
237237+238238+## Tool Versions
239239+KUSTOMIZE_VERSION ?= v5.6.0
240240+CONTROLLER_TOOLS_VERSION ?= v0.18.0
241241+#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20)
242242+ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}')
243243+#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
244244+ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}')
245245+GOLANGCI_LINT_VERSION ?= v2.1.0
246246+247247+.PHONY: kustomize
248248+kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
249249+$(KUSTOMIZE): $(LOCALBIN)
250250+ $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION))
251251+252252+.PHONY: controller-gen
253253+controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
254254+$(CONTROLLER_GEN): $(LOCALBIN)
255255+ $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))
256256+257257+.PHONY: setup-envtest
258258+setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory.
259259+ @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..."
260260+ @$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \
261261+ echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \
262262+ exit 1; \
263263+ }
264264+265265+.PHONY: envtest
266266+envtest: $(ENVTEST) ## Download setup-envtest locally if necessary.
267267+$(ENVTEST): $(LOCALBIN)
268268+ $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION))
269269+270270+.PHONY: golangci-lint
271271+golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
272272+$(GOLANGCI_LINT): $(LOCALBIN)
273273+ $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))
274274+275275+# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist
276276+# $1 - target path with name of binary
277277+# $2 - package url which can be installed
278278+# $3 - specific version of package
279279+define go-install-tool
280280+@[ -f "$(1)-$(3)" ] || { \
281281+set -e; \
282282+package=$(2)@$(3) ;\
283283+echo "Downloading $${package}" ;\
284284+rm -f $(1) || true ;\
285285+GOBIN=$(LOCALBIN) go install $${package} ;\
286286+mv $(1) $(1)-$(3) ;\
287287+} ;\
288288+ln -sf $(1)-$(3) $(1)
289289+endef
290290+291291+.PHONY: operator-sdk
292292+OPERATOR_SDK ?= $(LOCALBIN)/operator-sdk
293293+operator-sdk: ## Download operator-sdk locally if necessary.
294294+ifeq (,$(wildcard $(OPERATOR_SDK)))
295295+ifeq (, $(shell which operator-sdk 2>/dev/null))
296296+ @{ \
297297+ set -e ;\
298298+ mkdir -p $(dir $(OPERATOR_SDK)) ;\
299299+ OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \
300300+ curl -sSLo $(OPERATOR_SDK) https://github.com/operator-framework/operator-sdk/releases/download/$(OPERATOR_SDK_VERSION)/operator-sdk_$${OS}_$${ARCH} ;\
301301+ chmod +x $(OPERATOR_SDK) ;\
302302+ }
303303+else
304304+OPERATOR_SDK = $(shell which operator-sdk)
305305+endif
306306+endif
307307+308308+.PHONY: bundle
309309+bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files.
310310+ $(OPERATOR_SDK) generate kustomize manifests -q
311311+ cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG)
312312+ $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS)
313313+ $(OPERATOR_SDK) bundle validate ./bundle
314314+315315+.PHONY: bundle-build
316316+bundle-build: ## Build the bundle image.
317317+ $(CONTAINER_TOOL) build -f bundle.Dockerfile -t $(BUNDLE_IMG) .
318318+319319+.PHONY: bundle-push
320320+bundle-push: ## Push the bundle image.
321321+ $(MAKE) docker-push IMG=$(BUNDLE_IMG)
322322+323323+.PHONY: opm
324324+OPM = $(LOCALBIN)/opm
325325+opm: ## Download opm locally if necessary.
326326+ifeq (,$(wildcard $(OPM)))
327327+ifeq (,$(shell which opm 2>/dev/null))
328328+ @{ \
329329+ set -e ;\
330330+ mkdir -p $(dir $(OPM)) ;\
331331+ OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \
332332+ curl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.55.0/$${OS}-$${ARCH}-opm ;\
333333+ chmod +x $(OPM) ;\
334334+ }
335335+else
336336+OPM = $(shell which opm)
337337+endif
338338+endif
339339+340340+# A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0).
341341+# These images MUST exist in a registry and be pull-able.
342342+BUNDLE_IMGS ?= $(BUNDLE_IMG)
343343+344344+# The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0).
345345+CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION)
346346+347347+# Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image.
348348+ifneq ($(origin CATALOG_BASE_IMG), undefined)
349349+FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG)
350350+endif
351351+352352+# Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'.
353353+# This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see:
354354+# https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator
355355+.PHONY: catalog-build
356356+catalog-build: opm ## Build a catalog image.
357357+ $(OPM) index add --container-tool $(CONTAINER_TOOL) --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT)
358358+359359+# Push the catalog image.
360360+.PHONY: catalog-push
361361+catalog-push: ## Push a catalog image.
362362+ $(MAKE) docker-push IMG=$(CATALOG_IMG)
+32
PROJECT
···11+# Code generated by tool. DO NOT EDIT.
22+# This file is used to track the info used to scaffold your project
33+# and allow the plugins properly work.
44+# More info: https://book.kubebuilder.io/reference/project-config.html
55+domain: j5t.io
66+layout:
77+- go.kubebuilder.io/v4
88+plugins:
99+ manifests.sdk.operatorframework.io/v2: {}
1010+ scorecard.sdk.operatorframework.io/v2: {}
1111+projectName: hsm-secrets-operator
1212+repo: github.com/evanjarrett/hsm-secrets-operator
1313+resources:
1414+- api:
1515+ crdVersion: v1
1616+ namespaced: true
1717+ controller: true
1818+ domain: j5t.io
1919+ group: hsm
2020+ kind: HSMSecret
2121+ path: github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1
2222+ version: v1alpha1
2323+- api:
2424+ crdVersion: v1
2525+ namespaced: true
2626+ controller: true
2727+ domain: j5t.io
2828+ group: hsm
2929+ kind: HSMDevice
3030+ path: github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1
3131+ version: v1alpha1
3232+version: "3"
+135
README.md
···11+# hsm-secrets-operator
22+// TODO(user): Add simple overview of use/purpose
33+44+## Description
55+// TODO(user): An in-depth paragraph about your project and overview of use
66+77+## Getting Started
88+99+### Prerequisites
1010+- go version v1.24.0+
1111+- docker version 17.03+.
1212+- kubectl version v1.11.3+.
1313+- Access to a Kubernetes v1.11.3+ cluster.
1414+1515+### To Deploy on the cluster
1616+**Build and push your image to the location specified by `IMG`:**
1717+1818+```sh
1919+make docker-build docker-push IMG=<some-registry>/hsm-secrets-operator:tag
2020+```
2121+2222+**NOTE:** This image ought to be published in the personal registry you specified.
2323+And it is required to have access to pull the image from the working environment.
2424+Make sure you have the proper permission to the registry if the above commands don’t work.
2525+2626+**Install the CRDs into the cluster:**
2727+2828+```sh
2929+make install
3030+```
3131+3232+**Deploy the Manager to the cluster with the image specified by `IMG`:**
3333+3434+```sh
3535+make deploy IMG=<some-registry>/hsm-secrets-operator:tag
3636+```
3737+3838+> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin
3939+privileges or be logged in as admin.
4040+4141+**Create instances of your solution**
4242+You can apply the samples (examples) from the config/sample:
4343+4444+```sh
4545+kubectl apply -k config/samples/
4646+```
4747+4848+>**NOTE**: Ensure that the samples has default values to test it out.
4949+5050+### To Uninstall
5151+**Delete the instances (CRs) from the cluster:**
5252+5353+```sh
5454+kubectl delete -k config/samples/
5555+```
5656+5757+**Delete the APIs(CRDs) from the cluster:**
5858+5959+```sh
6060+make uninstall
6161+```
6262+6363+**UnDeploy the controller from the cluster:**
6464+6565+```sh
6666+make undeploy
6767+```
6868+6969+## Project Distribution
7070+7171+Following the options to release and provide this solution to the users.
7272+7373+### By providing a bundle with all YAML files
7474+7575+1. Build the installer for the image built and published in the registry:
7676+7777+```sh
7878+make build-installer IMG=<some-registry>/hsm-secrets-operator:tag
7979+```
8080+8181+**NOTE:** The makefile target mentioned above generates an 'install.yaml'
8282+file in the dist directory. This file contains all the resources built
8383+with Kustomize, which are necessary to install this project without its
8484+dependencies.
8585+8686+2. Using the installer
8787+8888+Users can just run 'kubectl apply -f <URL for YAML BUNDLE>' to install
8989+the project, i.e.:
9090+9191+```sh
9292+kubectl apply -f https://raw.githubusercontent.com/<org>/hsm-secrets-operator/<tag or branch>/dist/install.yaml
9393+```
9494+9595+### By providing a Helm Chart
9696+9797+1. Build the chart using the optional helm plugin
9898+9999+```sh
100100+operator-sdk edit --plugins=helm/v1-alpha
101101+```
102102+103103+2. See that a chart was generated under 'dist/chart', and users
104104+can obtain this solution from there.
105105+106106+**NOTE:** If you change the project, you need to update the Helm Chart
107107+using the same command above to sync the latest changes. Furthermore,
108108+if you create webhooks, you need to use the above command with
109109+the '--force' flag and manually ensure that any custom configuration
110110+previously added to 'dist/chart/values.yaml' or 'dist/chart/manager/manager.yaml'
111111+is manually re-applied afterwards.
112112+113113+## Contributing
114114+// TODO(user): Add detailed information on how you would like others to contribute to this project
115115+116116+**NOTE:** Run `make help` for more information on all potential `make` targets
117117+118118+More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)
119119+120120+## License
121121+122122+Copyright 2025.
123123+124124+Licensed under the Apache License, Version 2.0 (the "License");
125125+you may not use this file except in compliance with the License.
126126+You may obtain a copy of the License at
127127+128128+ http://www.apache.org/licenses/LICENSE-2.0
129129+130130+Unless required by applicable law or agreed to in writing, software
131131+distributed under the License is distributed on an "AS IS" BASIS,
132132+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
133133+See the License for the specific language governing permissions and
134134+limitations under the License.
135135+
+36
api/v1alpha1/groupversion_info.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 v1alpha1 contains API Schema definitions for the hsm v1alpha1 API group.
1818+// +kubebuilder:object:generate=true
1919+// +groupName=hsm.j5t.io
2020+package v1alpha1
2121+2222+import (
2323+ "k8s.io/apimachinery/pkg/runtime/schema"
2424+ "sigs.k8s.io/controller-runtime/pkg/scheme"
2525+)
2626+2727+var (
2828+ // GroupVersion is group version used to register these objects.
2929+ GroupVersion = schema.GroupVersion{Group: "hsm.j5t.io", Version: "v1alpha1"}
3030+3131+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme.
3232+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
3333+3434+ // AddToScheme adds the types in this group-version to the given scheme.
3535+ AddToScheme = SchemeBuilder.AddToScheme
3636+)
+284
api/v1alpha1/hsmdevice_types.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 v1alpha1
1818+1919+import (
2020+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2121+)
2222+2323+// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
2424+// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
2525+2626+// USBDeviceSpec defines USB device identification criteria
2727+type USBDeviceSpec struct {
2828+ // VendorID is the USB vendor ID (e.g., "20a0" for Pico HSM)
2929+ VendorID string `json:"vendorId"`
3030+3131+ // ProductID is the USB product ID (e.g., "4230" for Pico HSM)
3232+ ProductID string `json:"productId"`
3333+3434+ // SerialNumber optionally matches a specific device serial number
3535+ // +optional
3636+ SerialNumber string `json:"serialNumber,omitempty"`
3737+}
3838+3939+// DevicePathSpec defines device path-based identification
4040+type DevicePathSpec struct {
4141+ // Path is the device path pattern (e.g., "/dev/ttyUSB*", "/dev/sc-hsm*")
4242+ Path string `json:"path"`
4343+4444+ // Permissions are the required permissions for device access
4545+ // +optional
4646+ Permissions string `json:"permissions,omitempty"`
4747+}
4848+4949+// HSMDeviceType represents the type of HSM device
5050+type HSMDeviceType string
5151+5252+const (
5353+ // HSMDeviceTypePicoHSM represents a Pico HSM device
5454+ HSMDeviceTypePicoHSM HSMDeviceType = "PicoHSM"
5555+ // HSMDeviceTypeSmartCardHSM represents a SmartCard-HSM
5656+ HSMDeviceTypeSmartCardHSM HSMDeviceType = "SmartCardHSM"
5757+ // HSMDeviceTypeGeneric represents a generic PKCS#11 device
5858+ HSMDeviceTypeGeneric HSMDeviceType = "Generic"
5959+)
6060+6161+// MirroringPolicy defines how devices should be mirrored across nodes
6262+type MirroringPolicy string
6363+6464+const (
6565+ // MirroringPolicyNone disables device mirroring
6666+ MirroringPolicyNone MirroringPolicy = "None"
6767+ // MirroringPolicyReadOnly enables readonly mirroring across nodes
6868+ MirroringPolicyReadOnly MirroringPolicy = "ReadOnly"
6969+ // MirroringPolicyActive enables active-active mirroring (future)
7070+ MirroringPolicyActive MirroringPolicy = "Active"
7171+)
7272+7373+// MirroringSpec defines device mirroring configuration
7474+type MirroringSpec struct {
7575+ // Policy specifies the mirroring strategy
7676+ // +kubebuilder:default="None"
7777+ // +optional
7878+ Policy MirroringPolicy `json:"policy,omitempty"`
7979+8080+ // SyncInterval defines how often to sync device data across nodes (in seconds)
8181+ // +kubebuilder:default=60
8282+ // +optional
8383+ SyncInterval int32 `json:"syncInterval,omitempty"`
8484+8585+ // TargetNodes specifies nodes that should have mirrored access
8686+ // If empty, mirrors to all nodes with the device
8787+ // +optional
8888+ TargetNodes []string `json:"targetNodes,omitempty"`
8989+9090+ // PrimaryNode specifies the preferred primary node for write operations
9191+ // +optional
9292+ PrimaryNode string `json:"primaryNode,omitempty"`
9393+9494+ // AutoFailover enables automatic failover to healthy nodes
9595+ // +kubebuilder:default=true
9696+ // +optional
9797+ AutoFailover bool `json:"autoFailover,omitempty"`
9898+}
9999+100100+// HSMDeviceSpec defines the desired state of HSMDevice.
101101+type HSMDeviceSpec struct {
102102+ // DeviceType specifies the type of HSM device
103103+ DeviceType HSMDeviceType `json:"deviceType"`
104104+105105+ // USB defines USB-based device discovery criteria
106106+ // +optional
107107+ USB *USBDeviceSpec `json:"usb,omitempty"`
108108+109109+ // DevicePath defines path-based device discovery criteria
110110+ // +optional
111111+ DevicePath *DevicePathSpec `json:"devicePath,omitempty"`
112112+113113+ // NodeSelector specifies which nodes should be scanned for this device
114114+ // +optional
115115+ NodeSelector map[string]string `json:"nodeSelector,omitempty"`
116116+117117+ // PKCS11LibraryPath is the path to the PKCS#11 library for this device
118118+ // +optional
119119+ PKCS11LibraryPath string `json:"pkcs11LibraryPath,omitempty"`
120120+121121+ // MaxDevices limits how many instances of this device can be discovered
122122+ // +kubebuilder:default=10
123123+ // +optional
124124+ MaxDevices int32 `json:"maxDevices,omitempty"`
125125+126126+ // Mirroring configures cross-node device mirroring for high availability
127127+ // +optional
128128+ Mirroring *MirroringSpec `json:"mirroring,omitempty"`
129129+}
130130+131131+// DeviceRole defines the role of a device in a mirrored setup
132132+type DeviceRole string
133133+134134+const (
135135+ // DeviceRolePrimary indicates the device is the primary (read-write)
136136+ DeviceRolePrimary DeviceRole = "Primary"
137137+ // DeviceRoleReadOnly indicates the device is a readonly mirror
138138+ DeviceRoleReadOnly DeviceRole = "ReadOnly"
139139+ // DeviceRoleStandby indicates the device is available for failover
140140+ DeviceRoleStandby DeviceRole = "Standby"
141141+)
142142+143143+// DiscoveredDevice represents a discovered HSM device instance
144144+type DiscoveredDevice struct {
145145+ // DevicePath is the system path to the discovered device
146146+ DevicePath string `json:"devicePath"`
147147+148148+ // SerialNumber is the serial number of the device (if available)
149149+ // +optional
150150+ SerialNumber string `json:"serialNumber,omitempty"`
151151+152152+ // NodeName is the name of the node where the device was discovered
153153+ NodeName string `json:"nodeName"`
154154+155155+ // LastSeen is the timestamp when the device was last detected
156156+ LastSeen metav1.Time `json:"lastSeen"`
157157+158158+ // DeviceInfo contains additional device information
159159+ // +optional
160160+ DeviceInfo map[string]string `json:"deviceInfo,omitempty"`
161161+162162+ // Available indicates if the device is currently available for use
163163+ Available bool `json:"available"`
164164+165165+ // ResourceName is the Kubernetes resource name for this device
166166+ // +optional
167167+ ResourceName string `json:"resourceName,omitempty"`
168168+169169+ // Role indicates the role of this device in a mirrored setup
170170+ // +optional
171171+ Role DeviceRole `json:"role,omitempty"`
172172+173173+ // MirroredFrom indicates the primary device this is mirrored from
174174+ // +optional
175175+ MirroredFrom string `json:"mirroredFrom,omitempty"`
176176+177177+ // LastSyncTime is when this device was last synchronized
178178+ // +optional
179179+ LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"`
180180+181181+ // Health represents the health status of the device
182182+ // +optional
183183+ Health string `json:"health,omitempty"`
184184+}
185185+186186+// MirroringStatus represents the status of device mirroring
187187+type MirroringStatus struct {
188188+ // Enabled indicates if mirroring is currently active
189189+ Enabled bool `json:"enabled"`
190190+191191+ // PrimaryNode is the current primary node
192192+ // +optional
193193+ PrimaryNode string `json:"primaryNode,omitempty"`
194194+195195+ // MirroredNodes lists nodes with mirrored access
196196+ // +optional
197197+ MirroredNodes []string `json:"mirroredNodes,omitempty"`
198198+199199+ // LastSyncTime is when devices were last synchronized
200200+ // +optional
201201+ LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"`
202202+203203+ // FailoverCount tracks the number of failovers that have occurred
204204+ FailoverCount int32 `json:"failoverCount"`
205205+206206+ // SyncErrors tracks synchronization errors
207207+ // +optional
208208+ SyncErrors []string `json:"syncErrors,omitempty"`
209209+}
210210+211211+// HSMDeviceStatus defines the observed state of HSMDevice.
212212+type HSMDeviceStatus struct {
213213+ // DiscoveredDevices lists all discovered devices matching the spec
214214+ // +optional
215215+ DiscoveredDevices []DiscoveredDevice `json:"discoveredDevices,omitempty"`
216216+217217+ // TotalDevices is the total number of discovered devices
218218+ TotalDevices int32 `json:"totalDevices"`
219219+220220+ // AvailableDevices is the number of currently available devices
221221+ AvailableDevices int32 `json:"availableDevices"`
222222+223223+ // LastDiscoveryTime is the timestamp of the last discovery scan
224224+ // +optional
225225+ LastDiscoveryTime *metav1.Time `json:"lastDiscoveryTime,omitempty"`
226226+227227+ // Conditions represent the latest available observations of the device state
228228+ // +optional
229229+ Conditions []metav1.Condition `json:"conditions,omitempty"`
230230+231231+ // Phase represents the current phase of device discovery
232232+ // +optional
233233+ Phase HSMDevicePhase `json:"phase,omitempty"`
234234+235235+ // Mirroring represents the status of device mirroring
236236+ // +optional
237237+ Mirroring *MirroringStatus `json:"mirroring,omitempty"`
238238+}
239239+240240+// HSMDevicePhase represents the current phase of device discovery
241241+type HSMDevicePhase string
242242+243243+const (
244244+ // HSMDevicePhasePending indicates discovery is not yet started
245245+ HSMDevicePhasePending HSMDevicePhase = "Pending"
246246+ // HSMDevicePhaseDiscovering indicates discovery is in progress
247247+ HSMDevicePhaseDiscovering HSMDevicePhase = "Discovering"
248248+ // HSMDevicePhaseReady indicates devices have been discovered and are ready
249249+ HSMDevicePhaseReady HSMDevicePhase = "Ready"
250250+ // HSMDevicePhaseError indicates an error occurred during discovery
251251+ HSMDevicePhaseError HSMDevicePhase = "Error"
252252+)
253253+254254+// +kubebuilder:object:root=true
255255+// +kubebuilder:subresource:status
256256+// +kubebuilder:resource:shortName=hsmdev
257257+// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.deviceType`
258258+// +kubebuilder:printcolumn:name="Total",type=integer,JSONPath=`.status.totalDevices`
259259+// +kubebuilder:printcolumn:name="Available",type=integer,JSONPath=`.status.availableDevices`
260260+// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
261261+// +kubebuilder:printcolumn:name="Last Discovery",type=date,JSONPath=`.status.lastDiscoveryTime`
262262+// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
263263+264264+// HSMDevice is the Schema for the hsmdevices API.
265265+type HSMDevice struct {
266266+ metav1.TypeMeta `json:",inline"`
267267+ metav1.ObjectMeta `json:"metadata,omitempty"`
268268+269269+ Spec HSMDeviceSpec `json:"spec,omitempty"`
270270+ Status HSMDeviceStatus `json:"status,omitempty"`
271271+}
272272+273273+// +kubebuilder:object:root=true
274274+275275+// HSMDeviceList contains a list of HSMDevice.
276276+type HSMDeviceList struct {
277277+ metav1.TypeMeta `json:",inline"`
278278+ metav1.ListMeta `json:"metadata,omitempty"`
279279+ Items []HSMDevice `json:"items"`
280280+}
281281+282282+func init() {
283283+ SchemeBuilder.Register(&HSMDevice{}, &HSMDeviceList{})
284284+}
+129
api/v1alpha1/hsmsecret_types.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 v1alpha1
1818+1919+import (
2020+ corev1 "k8s.io/api/core/v1"
2121+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2222+)
2323+2424+// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
2525+// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
2626+2727+// HSMSecretSpec defines the desired state of HSMSecret.
2828+type HSMSecretSpec struct {
2929+ // HSMPath is the path on the Pico HSM where the secret data is stored
3030+ // Example: "secrets/appnamespace/appname-secret"
3131+ HSMPath string `json:"hsmPath"`
3232+3333+ // SecretName is the name of the Kubernetes Secret object to create/update
3434+ // Defaults to the HSMSecret name if not specified
3535+ // +optional
3636+ SecretName string `json:"secretName,omitempty"`
3737+3838+ // AutoSync enables bidirectional synchronization between HSM and Kubernetes Secret
3939+ // +kubebuilder:default=true
4040+ // +optional
4141+ AutoSync bool `json:"autoSync,omitempty"`
4242+4343+ // SecretType specifies the type of Kubernetes Secret to create
4444+ // +kubebuilder:default="Opaque"
4545+ // +optional
4646+ SecretType corev1.SecretType `json:"secretType,omitempty"`
4747+4848+ // SyncInterval defines how often to check for HSM changes (in seconds)
4949+ // Only applies when AutoSync is true
5050+ // +kubebuilder:default=300
5151+ // +optional
5252+ SyncInterval int32 `json:"syncInterval,omitempty"`
5353+}
5454+5555+// SyncStatus represents the synchronization state
5656+type SyncStatus string
5757+5858+const (
5959+ // SyncStatusInSync indicates HSM and K8s Secret are synchronized
6060+ SyncStatusInSync SyncStatus = "InSync"
6161+ // SyncStatusOutOfSync indicates HSM and K8s Secret differ
6262+ SyncStatusOutOfSync SyncStatus = "OutOfSync"
6363+ // SyncStatusError indicates an error occurred during synchronization
6464+ SyncStatusError SyncStatus = "Error"
6565+ // SyncStatusPending indicates synchronization is in progress
6666+ SyncStatusPending SyncStatus = "Pending"
6767+)
6868+6969+// HSMSecretStatus defines the observed state of HSMSecret.
7070+type HSMSecretStatus struct {
7171+ // LastSyncTime is the timestamp of the last successful synchronization
7272+ // +optional
7373+ LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"`
7474+7575+ // HSMChecksum is the SHA256 checksum of the HSM data
7676+ // +optional
7777+ HSMChecksum string `json:"hsmChecksum,omitempty"`
7878+7979+ // SecretChecksum is the SHA256 checksum of the Kubernetes Secret data
8080+ // +optional
8181+ SecretChecksum string `json:"secretChecksum,omitempty"`
8282+8383+ // SyncStatus indicates the current synchronization status
8484+ // +optional
8585+ SyncStatus SyncStatus `json:"syncStatus,omitempty"`
8686+8787+ // LastError contains the last error message if SyncStatus is Error
8888+ // +optional
8989+ LastError string `json:"lastError,omitempty"`
9090+9191+ // Conditions represent the latest available observations of the HSMSecret's current state
9292+ // +optional
9393+ Conditions []metav1.Condition `json:"conditions,omitempty"`
9494+9595+ // SecretRef references the created Kubernetes Secret
9696+ // +optional
9797+ SecretRef *corev1.ObjectReference `json:"secretRef,omitempty"`
9898+}
9999+100100+// +kubebuilder:object:root=true
101101+// +kubebuilder:subresource:status
102102+// +kubebuilder:resource:shortName=hsmsec
103103+// +kubebuilder:printcolumn:name="HSM Path",type=string,JSONPath=`.spec.hsmPath`
104104+// +kubebuilder:printcolumn:name="Secret Name",type=string,JSONPath=`.spec.secretName`
105105+// +kubebuilder:printcolumn:name="Sync Status",type=string,JSONPath=`.status.syncStatus`
106106+// +kubebuilder:printcolumn:name="Last Sync",type=date,JSONPath=`.status.lastSyncTime`
107107+// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
108108+109109+// HSMSecret is the Schema for the hsmsecrets API.
110110+type HSMSecret struct {
111111+ metav1.TypeMeta `json:",inline"`
112112+ metav1.ObjectMeta `json:"metadata,omitempty"`
113113+114114+ Spec HSMSecretSpec `json:"spec,omitempty"`
115115+ Status HSMSecretStatus `json:"status,omitempty"`
116116+}
117117+118118+// +kubebuilder:object:root=true
119119+120120+// HSMSecretList contains a list of HSMSecret.
121121+type HSMSecretList struct {
122122+ metav1.TypeMeta `json:",inline"`
123123+ metav1.ListMeta `json:"metadata,omitempty"`
124124+ Items []HSMSecret `json:"items"`
125125+}
126126+127127+func init() {
128128+ SchemeBuilder.Register(&HSMSecret{}, &HSMSecretList{})
129129+}
+372
api/v1alpha1/zz_generated.deepcopy.go
···11+//go:build !ignore_autogenerated
22+33+/*
44+Copyright 2025.
55+66+Licensed under the Apache License, Version 2.0 (the "License");
77+you may not use this file except in compliance with the License.
88+You may obtain a copy of the License at
99+1010+ http://www.apache.org/licenses/LICENSE-2.0
1111+1212+Unless required by applicable law or agreed to in writing, software
1313+distributed under the License is distributed on an "AS IS" BASIS,
1414+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1515+See the License for the specific language governing permissions and
1616+limitations under the License.
1717+*/
1818+1919+// Code generated by controller-gen. DO NOT EDIT.
2020+2121+package v1alpha1
2222+2323+import (
2424+ corev1 "k8s.io/api/core/v1"
2525+ "k8s.io/apimachinery/pkg/apis/meta/v1"
2626+ runtime "k8s.io/apimachinery/pkg/runtime"
2727+)
2828+2929+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
3030+func (in *DevicePathSpec) DeepCopyInto(out *DevicePathSpec) {
3131+ *out = *in
3232+}
3333+3434+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevicePathSpec.
3535+func (in *DevicePathSpec) DeepCopy() *DevicePathSpec {
3636+ if in == nil {
3737+ return nil
3838+ }
3939+ out := new(DevicePathSpec)
4040+ in.DeepCopyInto(out)
4141+ return out
4242+}
4343+4444+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
4545+func (in *DiscoveredDevice) DeepCopyInto(out *DiscoveredDevice) {
4646+ *out = *in
4747+ in.LastSeen.DeepCopyInto(&out.LastSeen)
4848+ if in.DeviceInfo != nil {
4949+ in, out := &in.DeviceInfo, &out.DeviceInfo
5050+ *out = make(map[string]string, len(*in))
5151+ for key, val := range *in {
5252+ (*out)[key] = val
5353+ }
5454+ }
5555+ if in.LastSyncTime != nil {
5656+ in, out := &in.LastSyncTime, &out.LastSyncTime
5757+ *out = (*in).DeepCopy()
5858+ }
5959+}
6060+6161+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiscoveredDevice.
6262+func (in *DiscoveredDevice) DeepCopy() *DiscoveredDevice {
6363+ if in == nil {
6464+ return nil
6565+ }
6666+ out := new(DiscoveredDevice)
6767+ in.DeepCopyInto(out)
6868+ return out
6969+}
7070+7171+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
7272+func (in *HSMDevice) DeepCopyInto(out *HSMDevice) {
7373+ *out = *in
7474+ out.TypeMeta = in.TypeMeta
7575+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
7676+ in.Spec.DeepCopyInto(&out.Spec)
7777+ in.Status.DeepCopyInto(&out.Status)
7878+}
7979+8080+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMDevice.
8181+func (in *HSMDevice) DeepCopy() *HSMDevice {
8282+ if in == nil {
8383+ return nil
8484+ }
8585+ out := new(HSMDevice)
8686+ in.DeepCopyInto(out)
8787+ return out
8888+}
8989+9090+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
9191+func (in *HSMDevice) DeepCopyObject() runtime.Object {
9292+ if c := in.DeepCopy(); c != nil {
9393+ return c
9494+ }
9595+ return nil
9696+}
9797+9898+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
9999+func (in *HSMDeviceList) DeepCopyInto(out *HSMDeviceList) {
100100+ *out = *in
101101+ out.TypeMeta = in.TypeMeta
102102+ in.ListMeta.DeepCopyInto(&out.ListMeta)
103103+ if in.Items != nil {
104104+ in, out := &in.Items, &out.Items
105105+ *out = make([]HSMDevice, len(*in))
106106+ for i := range *in {
107107+ (*in)[i].DeepCopyInto(&(*out)[i])
108108+ }
109109+ }
110110+}
111111+112112+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMDeviceList.
113113+func (in *HSMDeviceList) DeepCopy() *HSMDeviceList {
114114+ if in == nil {
115115+ return nil
116116+ }
117117+ out := new(HSMDeviceList)
118118+ in.DeepCopyInto(out)
119119+ return out
120120+}
121121+122122+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
123123+func (in *HSMDeviceList) DeepCopyObject() runtime.Object {
124124+ if c := in.DeepCopy(); c != nil {
125125+ return c
126126+ }
127127+ return nil
128128+}
129129+130130+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
131131+func (in *HSMDeviceSpec) DeepCopyInto(out *HSMDeviceSpec) {
132132+ *out = *in
133133+ if in.USB != nil {
134134+ in, out := &in.USB, &out.USB
135135+ *out = new(USBDeviceSpec)
136136+ **out = **in
137137+ }
138138+ if in.DevicePath != nil {
139139+ in, out := &in.DevicePath, &out.DevicePath
140140+ *out = new(DevicePathSpec)
141141+ **out = **in
142142+ }
143143+ if in.NodeSelector != nil {
144144+ in, out := &in.NodeSelector, &out.NodeSelector
145145+ *out = make(map[string]string, len(*in))
146146+ for key, val := range *in {
147147+ (*out)[key] = val
148148+ }
149149+ }
150150+ if in.Mirroring != nil {
151151+ in, out := &in.Mirroring, &out.Mirroring
152152+ *out = new(MirroringSpec)
153153+ (*in).DeepCopyInto(*out)
154154+ }
155155+}
156156+157157+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMDeviceSpec.
158158+func (in *HSMDeviceSpec) DeepCopy() *HSMDeviceSpec {
159159+ if in == nil {
160160+ return nil
161161+ }
162162+ out := new(HSMDeviceSpec)
163163+ in.DeepCopyInto(out)
164164+ return out
165165+}
166166+167167+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
168168+func (in *HSMDeviceStatus) DeepCopyInto(out *HSMDeviceStatus) {
169169+ *out = *in
170170+ if in.DiscoveredDevices != nil {
171171+ in, out := &in.DiscoveredDevices, &out.DiscoveredDevices
172172+ *out = make([]DiscoveredDevice, len(*in))
173173+ for i := range *in {
174174+ (*in)[i].DeepCopyInto(&(*out)[i])
175175+ }
176176+ }
177177+ if in.LastDiscoveryTime != nil {
178178+ in, out := &in.LastDiscoveryTime, &out.LastDiscoveryTime
179179+ *out = (*in).DeepCopy()
180180+ }
181181+ if in.Conditions != nil {
182182+ in, out := &in.Conditions, &out.Conditions
183183+ *out = make([]v1.Condition, len(*in))
184184+ for i := range *in {
185185+ (*in)[i].DeepCopyInto(&(*out)[i])
186186+ }
187187+ }
188188+ if in.Mirroring != nil {
189189+ in, out := &in.Mirroring, &out.Mirroring
190190+ *out = new(MirroringStatus)
191191+ (*in).DeepCopyInto(*out)
192192+ }
193193+}
194194+195195+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMDeviceStatus.
196196+func (in *HSMDeviceStatus) DeepCopy() *HSMDeviceStatus {
197197+ if in == nil {
198198+ return nil
199199+ }
200200+ out := new(HSMDeviceStatus)
201201+ in.DeepCopyInto(out)
202202+ return out
203203+}
204204+205205+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
206206+func (in *HSMSecret) DeepCopyInto(out *HSMSecret) {
207207+ *out = *in
208208+ out.TypeMeta = in.TypeMeta
209209+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
210210+ out.Spec = in.Spec
211211+ in.Status.DeepCopyInto(&out.Status)
212212+}
213213+214214+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMSecret.
215215+func (in *HSMSecret) DeepCopy() *HSMSecret {
216216+ if in == nil {
217217+ return nil
218218+ }
219219+ out := new(HSMSecret)
220220+ in.DeepCopyInto(out)
221221+ return out
222222+}
223223+224224+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
225225+func (in *HSMSecret) DeepCopyObject() runtime.Object {
226226+ if c := in.DeepCopy(); c != nil {
227227+ return c
228228+ }
229229+ return nil
230230+}
231231+232232+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
233233+func (in *HSMSecretList) DeepCopyInto(out *HSMSecretList) {
234234+ *out = *in
235235+ out.TypeMeta = in.TypeMeta
236236+ in.ListMeta.DeepCopyInto(&out.ListMeta)
237237+ if in.Items != nil {
238238+ in, out := &in.Items, &out.Items
239239+ *out = make([]HSMSecret, len(*in))
240240+ for i := range *in {
241241+ (*in)[i].DeepCopyInto(&(*out)[i])
242242+ }
243243+ }
244244+}
245245+246246+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMSecretList.
247247+func (in *HSMSecretList) DeepCopy() *HSMSecretList {
248248+ if in == nil {
249249+ return nil
250250+ }
251251+ out := new(HSMSecretList)
252252+ in.DeepCopyInto(out)
253253+ return out
254254+}
255255+256256+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
257257+func (in *HSMSecretList) DeepCopyObject() runtime.Object {
258258+ if c := in.DeepCopy(); c != nil {
259259+ return c
260260+ }
261261+ return nil
262262+}
263263+264264+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
265265+func (in *HSMSecretSpec) DeepCopyInto(out *HSMSecretSpec) {
266266+ *out = *in
267267+}
268268+269269+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMSecretSpec.
270270+func (in *HSMSecretSpec) DeepCopy() *HSMSecretSpec {
271271+ if in == nil {
272272+ return nil
273273+ }
274274+ out := new(HSMSecretSpec)
275275+ in.DeepCopyInto(out)
276276+ return out
277277+}
278278+279279+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
280280+func (in *HSMSecretStatus) DeepCopyInto(out *HSMSecretStatus) {
281281+ *out = *in
282282+ if in.LastSyncTime != nil {
283283+ in, out := &in.LastSyncTime, &out.LastSyncTime
284284+ *out = (*in).DeepCopy()
285285+ }
286286+ if in.Conditions != nil {
287287+ in, out := &in.Conditions, &out.Conditions
288288+ *out = make([]v1.Condition, len(*in))
289289+ for i := range *in {
290290+ (*in)[i].DeepCopyInto(&(*out)[i])
291291+ }
292292+ }
293293+ if in.SecretRef != nil {
294294+ in, out := &in.SecretRef, &out.SecretRef
295295+ *out = new(corev1.ObjectReference)
296296+ **out = **in
297297+ }
298298+}
299299+300300+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HSMSecretStatus.
301301+func (in *HSMSecretStatus) DeepCopy() *HSMSecretStatus {
302302+ if in == nil {
303303+ return nil
304304+ }
305305+ out := new(HSMSecretStatus)
306306+ in.DeepCopyInto(out)
307307+ return out
308308+}
309309+310310+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
311311+func (in *MirroringSpec) DeepCopyInto(out *MirroringSpec) {
312312+ *out = *in
313313+ if in.TargetNodes != nil {
314314+ in, out := &in.TargetNodes, &out.TargetNodes
315315+ *out = make([]string, len(*in))
316316+ copy(*out, *in)
317317+ }
318318+}
319319+320320+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MirroringSpec.
321321+func (in *MirroringSpec) DeepCopy() *MirroringSpec {
322322+ if in == nil {
323323+ return nil
324324+ }
325325+ out := new(MirroringSpec)
326326+ in.DeepCopyInto(out)
327327+ return out
328328+}
329329+330330+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
331331+func (in *MirroringStatus) DeepCopyInto(out *MirroringStatus) {
332332+ *out = *in
333333+ if in.MirroredNodes != nil {
334334+ in, out := &in.MirroredNodes, &out.MirroredNodes
335335+ *out = make([]string, len(*in))
336336+ copy(*out, *in)
337337+ }
338338+ if in.LastSyncTime != nil {
339339+ in, out := &in.LastSyncTime, &out.LastSyncTime
340340+ *out = (*in).DeepCopy()
341341+ }
342342+ if in.SyncErrors != nil {
343343+ in, out := &in.SyncErrors, &out.SyncErrors
344344+ *out = make([]string, len(*in))
345345+ copy(*out, *in)
346346+ }
347347+}
348348+349349+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MirroringStatus.
350350+func (in *MirroringStatus) DeepCopy() *MirroringStatus {
351351+ if in == nil {
352352+ return nil
353353+ }
354354+ out := new(MirroringStatus)
355355+ in.DeepCopyInto(out)
356356+ return out
357357+}
358358+359359+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
360360+func (in *USBDeviceSpec) DeepCopyInto(out *USBDeviceSpec) {
361361+ *out = *in
362362+}
363363+364364+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDeviceSpec.
365365+func (in *USBDeviceSpec) DeepCopy() *USBDeviceSpec {
366366+ if in == nil {
367367+ return nil
368368+ }
369369+ out := new(USBDeviceSpec)
370370+ in.DeepCopyInto(out)
371371+ return out
372372+}
+310
cmd/main.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 main
1818+1919+import (
2020+ "context"
2121+ "crypto/tls"
2222+ "flag"
2323+ "os"
2424+ "path/filepath"
2525+2626+ // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
2727+ // to ensure that exec-entrypoint and run can make use of them.
2828+ _ "k8s.io/client-go/plugin/pkg/client/auth"
2929+3030+ "k8s.io/apimachinery/pkg/runtime"
3131+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
3232+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
3333+ ctrl "sigs.k8s.io/controller-runtime"
3434+ "sigs.k8s.io/controller-runtime/pkg/certwatcher"
3535+ "sigs.k8s.io/controller-runtime/pkg/healthz"
3636+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
3737+ "sigs.k8s.io/controller-runtime/pkg/metrics/filters"
3838+ metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
3939+ "sigs.k8s.io/controller-runtime/pkg/webhook"
4040+4141+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
4242+ "github.com/evanjarrett/hsm-secrets-operator/internal/api"
4343+ "github.com/evanjarrett/hsm-secrets-operator/internal/controller"
4444+ "github.com/evanjarrett/hsm-secrets-operator/internal/discovery"
4545+ "github.com/evanjarrett/hsm-secrets-operator/internal/hsm"
4646+ // +kubebuilder:scaffold:imports
4747+)
4848+4949+var (
5050+ scheme = runtime.NewScheme()
5151+ setupLog = ctrl.Log.WithName("setup")
5252+)
5353+5454+func init() {
5555+ utilruntime.Must(clientgoscheme.AddToScheme(scheme))
5656+5757+ utilruntime.Must(hsmv1alpha1.AddToScheme(scheme))
5858+ // +kubebuilder:scaffold:scheme
5959+}
6060+6161+// nolint:gocyclo
6262+func main() {
6363+ var metricsAddr string
6464+ var metricsCertPath, metricsCertName, metricsCertKey string
6565+ var webhookCertPath, webhookCertName, webhookCertKey string
6666+ var enableLeaderElection bool
6767+ var probeAddr string
6868+ var secureMetrics bool
6969+ var enableHTTP2 bool
7070+ var enableAPI bool
7171+ var apiPort int
7272+ var tlsOpts []func(*tls.Config)
7373+ flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
7474+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
7575+ flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
7676+ flag.BoolVar(&enableLeaderElection, "leader-elect", false,
7777+ "Enable leader election for controller manager. "+
7878+ "Enabling this will ensure there is only one active controller manager.")
7979+ flag.BoolVar(&secureMetrics, "metrics-secure", true,
8080+ "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
8181+ flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.")
8282+ flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.")
8383+ flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.")
8484+ flag.StringVar(&metricsCertPath, "metrics-cert-path", "",
8585+ "The directory that contains the metrics server certificate.")
8686+ flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.")
8787+ flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
8888+ flag.BoolVar(&enableHTTP2, "enable-http2", false,
8989+ "If set, HTTP/2 will be enabled for the metrics and webhook servers")
9090+ flag.BoolVar(&enableAPI, "enable-api", true,
9191+ "Enable the REST API server for HSM secret management")
9292+ flag.IntVar(&apiPort, "api-port", 8090,
9393+ "Port for the REST API server")
9494+ opts := zap.Options{
9595+ Development: true,
9696+ }
9797+ opts.BindFlags(flag.CommandLine)
9898+ flag.Parse()
9999+100100+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
101101+102102+ // if the enable-http2 flag is false (the default), http/2 should be disabled
103103+ // due to its vulnerabilities. More specifically, disabling http/2 will
104104+ // prevent from being vulnerable to the HTTP/2 Stream Cancellation and
105105+ // Rapid Reset CVEs. For more information see:
106106+ // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
107107+ // - https://github.com/advisories/GHSA-4374-p667-p6c8
108108+ disableHTTP2 := func(c *tls.Config) {
109109+ setupLog.Info("disabling http/2")
110110+ c.NextProtos = []string{"http/1.1"}
111111+ }
112112+113113+ if !enableHTTP2 {
114114+ tlsOpts = append(tlsOpts, disableHTTP2)
115115+ }
116116+117117+ // Create watchers for metrics and webhooks certificates
118118+ var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher
119119+120120+ // Initial webhook TLS options
121121+ webhookTLSOpts := tlsOpts
122122+123123+ if len(webhookCertPath) > 0 {
124124+ setupLog.Info("Initializing webhook certificate watcher using provided certificates",
125125+ "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey)
126126+127127+ var err error
128128+ webhookCertWatcher, err = certwatcher.New(
129129+ filepath.Join(webhookCertPath, webhookCertName),
130130+ filepath.Join(webhookCertPath, webhookCertKey),
131131+ )
132132+ if err != nil {
133133+ setupLog.Error(err, "Failed to initialize webhook certificate watcher")
134134+ os.Exit(1)
135135+ }
136136+137137+ webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) {
138138+ config.GetCertificate = webhookCertWatcher.GetCertificate
139139+ })
140140+ }
141141+142142+ webhookServer := webhook.NewServer(webhook.Options{
143143+ TLSOpts: webhookTLSOpts,
144144+ })
145145+146146+ // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
147147+ // More info:
148148+ // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/server
149149+ // - https://book.kubebuilder.io/reference/metrics.html
150150+ metricsServerOptions := metricsserver.Options{
151151+ BindAddress: metricsAddr,
152152+ SecureServing: secureMetrics,
153153+ TLSOpts: tlsOpts,
154154+ }
155155+156156+ if secureMetrics {
157157+ // FilterProvider is used to protect the metrics endpoint with authn/authz.
158158+ // These configurations ensure that only authorized users and service accounts
159159+ // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
160160+ // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/filters#WithAuthenticationAndAuthorization
161161+ metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
162162+ }
163163+164164+ // If the certificate is not specified, controller-runtime will automatically
165165+ // generate self-signed certificates for the metrics server. While convenient for development and testing,
166166+ // this setup is not recommended for production.
167167+ //
168168+ // TODO(user): If you enable certManager, uncomment the following lines:
169169+ // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates
170170+ // managed by cert-manager for the metrics server.
171171+ // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification.
172172+ if len(metricsCertPath) > 0 {
173173+ setupLog.Info("Initializing metrics certificate watcher using provided certificates",
174174+ "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey)
175175+176176+ var err error
177177+ metricsCertWatcher, err = certwatcher.New(
178178+ filepath.Join(metricsCertPath, metricsCertName),
179179+ filepath.Join(metricsCertPath, metricsCertKey),
180180+ )
181181+ if err != nil {
182182+ setupLog.Error(err, "to initialize metrics certificate watcher", "error", err)
183183+ os.Exit(1)
184184+ }
185185+186186+ metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) {
187187+ config.GetCertificate = metricsCertWatcher.GetCertificate
188188+ })
189189+ }
190190+191191+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
192192+ Scheme: scheme,
193193+ Metrics: metricsServerOptions,
194194+ WebhookServer: webhookServer,
195195+ HealthProbeBindAddress: probeAddr,
196196+ LeaderElection: enableLeaderElection,
197197+ LeaderElectionID: "64b68d60.j5t.io",
198198+ // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
199199+ // when the Manager ends. This requires the binary to immediately end when the
200200+ // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
201201+ // speeds up voluntary leader transitions as the new leader don't have to wait
202202+ // LeaseDuration time first.
203203+ //
204204+ // In the default scaffold provided, the program ends immediately after
205205+ // the manager stops, so would be fine to enable this option. However,
206206+ // if you are doing or is intended to do any operation such as perform cleanups
207207+ // after the manager stops then its usage might be unsafe.
208208+ // LeaderElectionReleaseOnCancel: true,
209209+ })
210210+ if err != nil {
211211+ setupLog.Error(err, "unable to start manager")
212212+ os.Exit(1)
213213+ }
214214+215215+ // Initialize HSM client
216216+ hsmClient := hsm.NewMockClient() // Use mock client for now
217217+ hsmConfig := hsm.DefaultConfig()
218218+ // TODO: Load HSM config from environment variables or config file
219219+220220+ ctx := context.Background()
221221+ if err := hsmClient.Initialize(ctx, hsmConfig); err != nil {
222222+ setupLog.Error(err, "unable to initialize HSM client")
223223+ os.Exit(1)
224224+ }
225225+226226+ // Initialize USB discoverer
227227+ usbDiscoverer := discovery.NewUSBDiscoverer()
228228+229229+ // Get node name for device discovery
230230+ nodeName := os.Getenv("NODE_NAME")
231231+ if nodeName == "" {
232232+ if hostname, err := os.Hostname(); err == nil {
233233+ nodeName = hostname
234234+ } else {
235235+ nodeName = "unknown"
236236+ }
237237+ }
238238+239239+ // Initialize mirroring manager for cross-node HSM device synchronization
240240+ mirroringManager := discovery.NewMirroringManager(mgr.GetClient(), nodeName)
241241+242242+ // Register the HSM client with the mirroring manager for this node
243243+ mirroringManager.RegisterHSMClient(nodeName, hsmClient)
244244+245245+ if err := (&controller.HSMSecretReconciler{
246246+ Client: mgr.GetClient(),
247247+ Scheme: mgr.GetScheme(),
248248+ HSMClient: hsmClient,
249249+ MirroringManager: mirroringManager,
250250+ }).SetupWithManager(mgr); err != nil {
251251+ setupLog.Error(err, "unable to create controller", "controller", "HSMSecret")
252252+ os.Exit(1)
253253+ }
254254+255255+ if err := (&controller.HSMDeviceReconciler{
256256+ Client: mgr.GetClient(),
257257+ Scheme: mgr.GetScheme(),
258258+ NodeName: nodeName,
259259+ USBDiscoverer: usbDiscoverer,
260260+ MirroringManager: mirroringManager,
261261+ }).SetupWithManager(mgr); err != nil {
262262+ setupLog.Error(err, "unable to create controller", "controller", "HSMDevice")
263263+ os.Exit(1)
264264+ }
265265+ // +kubebuilder:scaffold:builder
266266+267267+ if metricsCertWatcher != nil {
268268+ setupLog.Info("Adding metrics certificate watcher to manager")
269269+ if err := mgr.Add(metricsCertWatcher); err != nil {
270270+ setupLog.Error(err, "unable to add metrics certificate watcher to manager")
271271+ os.Exit(1)
272272+ }
273273+ }
274274+275275+ if webhookCertWatcher != nil {
276276+ setupLog.Info("Adding webhook certificate watcher to manager")
277277+ if err := mgr.Add(webhookCertWatcher); err != nil {
278278+ setupLog.Error(err, "unable to add webhook certificate watcher to manager")
279279+ os.Exit(1)
280280+ }
281281+ }
282282+283283+ if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
284284+ setupLog.Error(err, "unable to set up health check")
285285+ os.Exit(1)
286286+ }
287287+ if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
288288+ setupLog.Error(err, "unable to set up ready check")
289289+ os.Exit(1)
290290+ }
291291+292292+ // Start API server if enabled
293293+ if enableAPI {
294294+ apiServer := api.NewServer(mgr.GetClient(), hsmClient, mirroringManager, ctrl.Log.WithName("api"))
295295+296296+ // Start API server in a separate goroutine
297297+ go func() {
298298+ setupLog.Info("starting API server", "port", apiPort)
299299+ if err := apiServer.Start(apiPort); err != nil {
300300+ setupLog.Error(err, "problem running API server")
301301+ }
302302+ }()
303303+ }
304304+305305+ setupLog.Info("starting manager")
306306+ if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
307307+ setupLog.Error(err, "problem running manager")
308308+ os.Exit(1)
309309+ }
310310+}
+326
config/crd/bases/hsm.j5t.io_hsmdevices.yaml
···11+---
22+apiVersion: apiextensions.k8s.io/v1
33+kind: CustomResourceDefinition
44+metadata:
55+ annotations:
66+ controller-gen.kubebuilder.io/version: v0.18.0
77+ name: hsmdevices.hsm.j5t.io
88+spec:
99+ group: hsm.j5t.io
1010+ names:
1111+ kind: HSMDevice
1212+ listKind: HSMDeviceList
1313+ plural: hsmdevices
1414+ shortNames:
1515+ - hsmdev
1616+ singular: hsmdevice
1717+ scope: Namespaced
1818+ versions:
1919+ - additionalPrinterColumns:
2020+ - jsonPath: .spec.deviceType
2121+ name: Type
2222+ type: string
2323+ - jsonPath: .status.totalDevices
2424+ name: Total
2525+ type: integer
2626+ - jsonPath: .status.availableDevices
2727+ name: Available
2828+ type: integer
2929+ - jsonPath: .status.phase
3030+ name: Phase
3131+ type: string
3232+ - jsonPath: .status.lastDiscoveryTime
3333+ name: Last Discovery
3434+ type: date
3535+ - jsonPath: .metadata.creationTimestamp
3636+ name: Age
3737+ type: date
3838+ name: v1alpha1
3939+ schema:
4040+ openAPIV3Schema:
4141+ description: HSMDevice is the Schema for the hsmdevices API.
4242+ properties:
4343+ apiVersion:
4444+ description: |-
4545+ APIVersion defines the versioned schema of this representation of an object.
4646+ Servers should convert recognized schemas to the latest internal value, and
4747+ may reject unrecognized values.
4848+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
4949+ type: string
5050+ kind:
5151+ description: |-
5252+ Kind is a string value representing the REST resource this object represents.
5353+ Servers may infer this from the endpoint the client submits requests to.
5454+ Cannot be updated.
5555+ In CamelCase.
5656+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
5757+ type: string
5858+ metadata:
5959+ type: object
6060+ spec:
6161+ description: HSMDeviceSpec defines the desired state of HSMDevice.
6262+ properties:
6363+ devicePath:
6464+ description: DevicePath defines path-based device discovery criteria
6565+ properties:
6666+ path:
6767+ description: Path is the device path pattern (e.g., "/dev/ttyUSB*",
6868+ "/dev/sc-hsm*")
6969+ type: string
7070+ permissions:
7171+ description: Permissions are the required permissions for device
7272+ access
7373+ type: string
7474+ required:
7575+ - path
7676+ type: object
7777+ deviceType:
7878+ description: DeviceType specifies the type of HSM device
7979+ type: string
8080+ maxDevices:
8181+ default: 10
8282+ description: MaxDevices limits how many instances of this device can
8383+ be discovered
8484+ format: int32
8585+ type: integer
8686+ mirroring:
8787+ description: Mirroring configures cross-node device mirroring for
8888+ high availability
8989+ properties:
9090+ autoFailover:
9191+ default: true
9292+ description: AutoFailover enables automatic failover to healthy
9393+ nodes
9494+ type: boolean
9595+ policy:
9696+ default: None
9797+ description: Policy specifies the mirroring strategy
9898+ type: string
9999+ primaryNode:
100100+ description: PrimaryNode specifies the preferred primary node
101101+ for write operations
102102+ type: string
103103+ syncInterval:
104104+ default: 60
105105+ description: SyncInterval defines how often to sync device data
106106+ across nodes (in seconds)
107107+ format: int32
108108+ type: integer
109109+ targetNodes:
110110+ description: |-
111111+ TargetNodes specifies nodes that should have mirrored access
112112+ If empty, mirrors to all nodes with the device
113113+ items:
114114+ type: string
115115+ type: array
116116+ type: object
117117+ nodeSelector:
118118+ additionalProperties:
119119+ type: string
120120+ description: NodeSelector specifies which nodes should be scanned
121121+ for this device
122122+ type: object
123123+ pkcs11LibraryPath:
124124+ description: PKCS11LibraryPath is the path to the PKCS#11 library
125125+ for this device
126126+ type: string
127127+ usb:
128128+ description: USB defines USB-based device discovery criteria
129129+ properties:
130130+ productId:
131131+ description: ProductID is the USB product ID (e.g., "4230" for
132132+ Pico HSM)
133133+ type: string
134134+ serialNumber:
135135+ description: SerialNumber optionally matches a specific device
136136+ serial number
137137+ type: string
138138+ vendorId:
139139+ description: VendorID is the USB vendor ID (e.g., "20a0" for Pico
140140+ HSM)
141141+ type: string
142142+ required:
143143+ - productId
144144+ - vendorId
145145+ type: object
146146+ required:
147147+ - deviceType
148148+ type: object
149149+ status:
150150+ description: HSMDeviceStatus defines the observed state of HSMDevice.
151151+ properties:
152152+ availableDevices:
153153+ description: AvailableDevices is the number of currently available
154154+ devices
155155+ format: int32
156156+ type: integer
157157+ conditions:
158158+ description: Conditions represent the latest available observations
159159+ of the device state
160160+ items:
161161+ description: Condition contains details for one aspect of the current
162162+ state of this API Resource.
163163+ properties:
164164+ lastTransitionTime:
165165+ description: |-
166166+ lastTransitionTime is the last time the condition transitioned from one status to another.
167167+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
168168+ format: date-time
169169+ type: string
170170+ message:
171171+ description: |-
172172+ message is a human readable message indicating details about the transition.
173173+ This may be an empty string.
174174+ maxLength: 32768
175175+ type: string
176176+ observedGeneration:
177177+ description: |-
178178+ observedGeneration represents the .metadata.generation that the condition was set based upon.
179179+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
180180+ with respect to the current state of the instance.
181181+ format: int64
182182+ minimum: 0
183183+ type: integer
184184+ reason:
185185+ description: |-
186186+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
187187+ Producers of specific condition types may define expected values and meanings for this field,
188188+ and whether the values are considered a guaranteed API.
189189+ The value should be a CamelCase string.
190190+ This field may not be empty.
191191+ maxLength: 1024
192192+ minLength: 1
193193+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
194194+ type: string
195195+ status:
196196+ description: status of the condition, one of True, False, Unknown.
197197+ enum:
198198+ - "True"
199199+ - "False"
200200+ - Unknown
201201+ type: string
202202+ type:
203203+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
204204+ maxLength: 316
205205+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
206206+ type: string
207207+ required:
208208+ - lastTransitionTime
209209+ - message
210210+ - reason
211211+ - status
212212+ - type
213213+ type: object
214214+ type: array
215215+ discoveredDevices:
216216+ description: DiscoveredDevices lists all discovered devices matching
217217+ the spec
218218+ items:
219219+ description: DiscoveredDevice represents a discovered HSM device
220220+ instance
221221+ properties:
222222+ available:
223223+ description: Available indicates if the device is currently
224224+ available for use
225225+ type: boolean
226226+ deviceInfo:
227227+ additionalProperties:
228228+ type: string
229229+ description: DeviceInfo contains additional device information
230230+ type: object
231231+ devicePath:
232232+ description: DevicePath is the system path to the discovered
233233+ device
234234+ type: string
235235+ health:
236236+ description: Health represents the health status of the device
237237+ type: string
238238+ lastSeen:
239239+ description: LastSeen is the timestamp when the device was last
240240+ detected
241241+ format: date-time
242242+ type: string
243243+ lastSyncTime:
244244+ description: LastSyncTime is when this device was last synchronized
245245+ format: date-time
246246+ type: string
247247+ mirroredFrom:
248248+ description: MirroredFrom indicates the primary device this
249249+ is mirrored from
250250+ type: string
251251+ nodeName:
252252+ description: NodeName is the name of the node where the device
253253+ was discovered
254254+ type: string
255255+ resourceName:
256256+ description: ResourceName is the Kubernetes resource name for
257257+ this device
258258+ type: string
259259+ role:
260260+ description: Role indicates the role of this device in a mirrored
261261+ setup
262262+ type: string
263263+ serialNumber:
264264+ description: SerialNumber is the serial number of the device
265265+ (if available)
266266+ type: string
267267+ required:
268268+ - available
269269+ - devicePath
270270+ - lastSeen
271271+ - nodeName
272272+ type: object
273273+ type: array
274274+ lastDiscoveryTime:
275275+ description: LastDiscoveryTime is the timestamp of the last discovery
276276+ scan
277277+ format: date-time
278278+ type: string
279279+ mirroring:
280280+ description: Mirroring represents the status of device mirroring
281281+ properties:
282282+ enabled:
283283+ description: Enabled indicates if mirroring is currently active
284284+ type: boolean
285285+ failoverCount:
286286+ description: FailoverCount tracks the number of failovers that
287287+ have occurred
288288+ format: int32
289289+ type: integer
290290+ lastSyncTime:
291291+ description: LastSyncTime is when devices were last synchronized
292292+ format: date-time
293293+ type: string
294294+ mirroredNodes:
295295+ description: MirroredNodes lists nodes with mirrored access
296296+ items:
297297+ type: string
298298+ type: array
299299+ primaryNode:
300300+ description: PrimaryNode is the current primary node
301301+ type: string
302302+ syncErrors:
303303+ description: SyncErrors tracks synchronization errors
304304+ items:
305305+ type: string
306306+ type: array
307307+ required:
308308+ - enabled
309309+ - failoverCount
310310+ type: object
311311+ phase:
312312+ description: Phase represents the current phase of device discovery
313313+ type: string
314314+ totalDevices:
315315+ description: TotalDevices is the total number of discovered devices
316316+ format: int32
317317+ type: integer
318318+ required:
319319+ - availableDevices
320320+ - totalDevices
321321+ type: object
322322+ type: object
323323+ served: true
324324+ storage: true
325325+ subresources:
326326+ status: {}
+218
config/crd/bases/hsm.j5t.io_hsmsecrets.yaml
···11+---
22+apiVersion: apiextensions.k8s.io/v1
33+kind: CustomResourceDefinition
44+metadata:
55+ annotations:
66+ controller-gen.kubebuilder.io/version: v0.18.0
77+ name: hsmsecrets.hsm.j5t.io
88+spec:
99+ group: hsm.j5t.io
1010+ names:
1111+ kind: HSMSecret
1212+ listKind: HSMSecretList
1313+ plural: hsmsecrets
1414+ shortNames:
1515+ - hsmsec
1616+ singular: hsmsecret
1717+ scope: Namespaced
1818+ versions:
1919+ - additionalPrinterColumns:
2020+ - jsonPath: .spec.hsmPath
2121+ name: HSM Path
2222+ type: string
2323+ - jsonPath: .spec.secretName
2424+ name: Secret Name
2525+ type: string
2626+ - jsonPath: .status.syncStatus
2727+ name: Sync Status
2828+ type: string
2929+ - jsonPath: .status.lastSyncTime
3030+ name: Last Sync
3131+ type: date
3232+ - jsonPath: .metadata.creationTimestamp
3333+ name: Age
3434+ type: date
3535+ name: v1alpha1
3636+ schema:
3737+ openAPIV3Schema:
3838+ description: HSMSecret is the Schema for the hsmsecrets API.
3939+ properties:
4040+ apiVersion:
4141+ description: |-
4242+ APIVersion defines the versioned schema of this representation of an object.
4343+ Servers should convert recognized schemas to the latest internal value, and
4444+ may reject unrecognized values.
4545+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
4646+ type: string
4747+ kind:
4848+ description: |-
4949+ Kind is a string value representing the REST resource this object represents.
5050+ Servers may infer this from the endpoint the client submits requests to.
5151+ Cannot be updated.
5252+ In CamelCase.
5353+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
5454+ type: string
5555+ metadata:
5656+ type: object
5757+ spec:
5858+ description: HSMSecretSpec defines the desired state of HSMSecret.
5959+ properties:
6060+ autoSync:
6161+ default: true
6262+ description: AutoSync enables bidirectional synchronization between
6363+ HSM and Kubernetes Secret
6464+ type: boolean
6565+ hsmPath:
6666+ description: |-
6767+ HSMPath is the path on the Pico HSM where the secret data is stored
6868+ Example: "secrets/appnamespace/appname-secret"
6969+ type: string
7070+ secretName:
7171+ description: |-
7272+ SecretName is the name of the Kubernetes Secret object to create/update
7373+ Defaults to the HSMSecret name if not specified
7474+ type: string
7575+ secretType:
7676+ default: Opaque
7777+ description: SecretType specifies the type of Kubernetes Secret to
7878+ create
7979+ type: string
8080+ syncInterval:
8181+ default: 300
8282+ description: |-
8383+ SyncInterval defines how often to check for HSM changes (in seconds)
8484+ Only applies when AutoSync is true
8585+ format: int32
8686+ type: integer
8787+ required:
8888+ - hsmPath
8989+ type: object
9090+ status:
9191+ description: HSMSecretStatus defines the observed state of HSMSecret.
9292+ properties:
9393+ conditions:
9494+ description: Conditions represent the latest available observations
9595+ of the HSMSecret's current state
9696+ items:
9797+ description: Condition contains details for one aspect of the current
9898+ state of this API Resource.
9999+ properties:
100100+ lastTransitionTime:
101101+ description: |-
102102+ lastTransitionTime is the last time the condition transitioned from one status to another.
103103+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
104104+ format: date-time
105105+ type: string
106106+ message:
107107+ description: |-
108108+ message is a human readable message indicating details about the transition.
109109+ This may be an empty string.
110110+ maxLength: 32768
111111+ type: string
112112+ observedGeneration:
113113+ description: |-
114114+ observedGeneration represents the .metadata.generation that the condition was set based upon.
115115+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
116116+ with respect to the current state of the instance.
117117+ format: int64
118118+ minimum: 0
119119+ type: integer
120120+ reason:
121121+ description: |-
122122+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
123123+ Producers of specific condition types may define expected values and meanings for this field,
124124+ and whether the values are considered a guaranteed API.
125125+ The value should be a CamelCase string.
126126+ This field may not be empty.
127127+ maxLength: 1024
128128+ minLength: 1
129129+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
130130+ type: string
131131+ status:
132132+ description: status of the condition, one of True, False, Unknown.
133133+ enum:
134134+ - "True"
135135+ - "False"
136136+ - Unknown
137137+ type: string
138138+ type:
139139+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
140140+ maxLength: 316
141141+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
142142+ type: string
143143+ required:
144144+ - lastTransitionTime
145145+ - message
146146+ - reason
147147+ - status
148148+ - type
149149+ type: object
150150+ type: array
151151+ hsmChecksum:
152152+ description: HSMChecksum is the SHA256 checksum of the HSM data
153153+ type: string
154154+ lastError:
155155+ description: LastError contains the last error message if SyncStatus
156156+ is Error
157157+ type: string
158158+ lastSyncTime:
159159+ description: LastSyncTime is the timestamp of the last successful
160160+ synchronization
161161+ format: date-time
162162+ type: string
163163+ secretChecksum:
164164+ description: SecretChecksum is the SHA256 checksum of the Kubernetes
165165+ Secret data
166166+ type: string
167167+ secretRef:
168168+ description: SecretRef references the created Kubernetes Secret
169169+ properties:
170170+ apiVersion:
171171+ description: API version of the referent.
172172+ type: string
173173+ fieldPath:
174174+ description: |-
175175+ If referring to a piece of an object instead of an entire object, this string
176176+ should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2].
177177+ For example, if the object reference is to a container within a pod, this would take on a value like:
178178+ "spec.containers{name}" (where "name" refers to the name of the container that triggered
179179+ the event) or if no container name is specified "spec.containers[2]" (container with
180180+ index 2 in this pod). This syntax is chosen only to have some well-defined way of
181181+ referencing a part of an object.
182182+ type: string
183183+ kind:
184184+ description: |-
185185+ Kind of the referent.
186186+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
187187+ type: string
188188+ name:
189189+ description: |-
190190+ Name of the referent.
191191+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
192192+ type: string
193193+ namespace:
194194+ description: |-
195195+ Namespace of the referent.
196196+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
197197+ type: string
198198+ resourceVersion:
199199+ description: |-
200200+ Specific resourceVersion to which this reference is made, if any.
201201+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
202202+ type: string
203203+ uid:
204204+ description: |-
205205+ UID of the referent.
206206+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids
207207+ type: string
208208+ type: object
209209+ x-kubernetes-map-type: atomic
210210+ syncStatus:
211211+ description: SyncStatus indicates the current synchronization status
212212+ type: string
213213+ type: object
214214+ type: object
215215+ served: true
216216+ storage: true
217217+ subresources:
218218+ status: {}
+17
config/crd/kustomization.yaml
···11+# This kustomization.yaml is not intended to be run by itself,
22+# since it depends on service name and namespace that are out of this kustomize package.
33+# It should be run by config/default
44+resources:
55+- bases/hsm.j5t.io_hsmsecrets.yaml
66+- bases/hsm.j5t.io_hsmdevices.yaml
77+# +kubebuilder:scaffold:crdkustomizeresource
88+99+patches:
1010+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
1111+# patches here are for enabling the conversion webhook for each CRD
1212+# +kubebuilder:scaffold:crdkustomizewebhookpatch
1313+1414+# [WEBHOOK] To enable webhook, uncomment the following section
1515+# the following config is for teaching kustomize how to do kustomization for CRDs.
1616+#configurations:
1717+#- kustomizeconfig.yaml
+19
config/crd/kustomizeconfig.yaml
···11+# This file is for teaching kustomize how to substitute name and namespace reference in CRD
22+nameReference:
33+- kind: Service
44+ version: v1
55+ fieldSpecs:
66+ - kind: CustomResourceDefinition
77+ version: v1
88+ group: apiextensions.k8s.io
99+ path: spec/conversion/webhook/clientConfig/service/name
1010+1111+namespace:
1212+- kind: CustomResourceDefinition
1313+ version: v1
1414+ group: apiextensions.k8s.io
1515+ path: spec/conversion/webhook/clientConfig/service/namespace
1616+ create: false
1717+1818+varReference:
1919+- path: metadata/annotations
+30
config/default/cert_metrics_manager_patch.yaml
···11+# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs.
22+33+# Add the volumeMount for the metrics-server certs
44+- op: add
55+ path: /spec/template/spec/containers/0/volumeMounts/-
66+ value:
77+ mountPath: /tmp/k8s-metrics-server/metrics-certs
88+ name: metrics-certs
99+ readOnly: true
1010+1111+# Add the --metrics-cert-path argument for the metrics server
1212+- op: add
1313+ path: /spec/template/spec/containers/0/args/-
1414+ value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs
1515+1616+# Add the metrics-server certs volume configuration
1717+- op: add
1818+ path: /spec/template/spec/volumes/-
1919+ value:
2020+ name: metrics-certs
2121+ secret:
2222+ secretName: metrics-server-cert
2323+ optional: false
2424+ items:
2525+ - key: ca.crt
2626+ path: ca.crt
2727+ - key: tls.crt
2828+ path: tls.crt
2929+ - key: tls.key
3030+ path: tls.key
+234
config/default/kustomization.yaml
···11+# Adds namespace to all resources.
22+namespace: hsm-secrets-operator-system
33+44+# Value of this field is prepended to the
55+# names of all resources, e.g. a deployment named
66+# "wordpress" becomes "alices-wordpress".
77+# Note that it should also match with the prefix (text before '-') of the namespace
88+# field above.
99+namePrefix: hsm-secrets-operator-
1010+1111+# Labels to add to all resources and selectors.
1212+#labels:
1313+#- includeSelectors: true
1414+# pairs:
1515+# someName: someValue
1616+1717+resources:
1818+- ../crd
1919+- ../rbac
2020+- ../manager
2121+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
2222+# crd/kustomization.yaml
2323+#- ../webhook
2424+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
2525+#- ../certmanager
2626+# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
2727+#- ../prometheus
2828+# [METRICS] Expose the controller manager metrics service.
2929+- metrics_service.yaml
3030+# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy.
3131+# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics.
3232+# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will
3333+# be able to communicate with the Webhook Server.
3434+#- ../network-policy
3535+3636+# Uncomment the patches line if you enable Metrics
3737+patches:
3838+# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443.
3939+# More info: https://book.kubebuilder.io/reference/metrics
4040+- path: manager_metrics_patch.yaml
4141+ target:
4242+ kind: Deployment
4343+4444+# Uncomment the patches line if you enable Metrics and CertManager
4545+# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line.
4646+# This patch will protect the metrics with certManager self-signed certs.
4747+#- path: cert_metrics_manager_patch.yaml
4848+# target:
4949+# kind: Deployment
5050+5151+# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
5252+# crd/kustomization.yaml
5353+#- path: manager_webhook_patch.yaml
5454+# target:
5555+# kind: Deployment
5656+5757+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
5858+# Uncomment the following replacements to add the cert-manager CA injection annotations
5959+#replacements:
6060+# - source: # Uncomment the following block to enable certificates for metrics
6161+# kind: Service
6262+# version: v1
6363+# name: controller-manager-metrics-service
6464+# fieldPath: metadata.name
6565+# targets:
6666+# - select:
6767+# kind: Certificate
6868+# group: cert-manager.io
6969+# version: v1
7070+# name: metrics-certs
7171+# fieldPaths:
7272+# - spec.dnsNames.0
7373+# - spec.dnsNames.1
7474+# options:
7575+# delimiter: '.'
7676+# index: 0
7777+# create: true
7878+# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor
7979+# kind: ServiceMonitor
8080+# group: monitoring.coreos.com
8181+# version: v1
8282+# name: controller-manager-metrics-monitor
8383+# fieldPaths:
8484+# - spec.endpoints.0.tlsConfig.serverName
8585+# options:
8686+# delimiter: '.'
8787+# index: 0
8888+# create: true
8989+#
9090+# - source:
9191+# kind: Service
9292+# version: v1
9393+# name: controller-manager-metrics-service
9494+# fieldPath: metadata.namespace
9595+# targets:
9696+# - select:
9797+# kind: Certificate
9898+# group: cert-manager.io
9999+# version: v1
100100+# name: metrics-certs
101101+# fieldPaths:
102102+# - spec.dnsNames.0
103103+# - spec.dnsNames.1
104104+# options:
105105+# delimiter: '.'
106106+# index: 1
107107+# create: true
108108+# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor
109109+# kind: ServiceMonitor
110110+# group: monitoring.coreos.com
111111+# version: v1
112112+# name: controller-manager-metrics-monitor
113113+# fieldPaths:
114114+# - spec.endpoints.0.tlsConfig.serverName
115115+# options:
116116+# delimiter: '.'
117117+# index: 1
118118+# create: true
119119+#
120120+# - source: # Uncomment the following block if you have any webhook
121121+# kind: Service
122122+# version: v1
123123+# name: webhook-service
124124+# fieldPath: .metadata.name # Name of the service
125125+# targets:
126126+# - select:
127127+# kind: Certificate
128128+# group: cert-manager.io
129129+# version: v1
130130+# name: serving-cert
131131+# fieldPaths:
132132+# - .spec.dnsNames.0
133133+# - .spec.dnsNames.1
134134+# options:
135135+# delimiter: '.'
136136+# index: 0
137137+# create: true
138138+# - source:
139139+# kind: Service
140140+# version: v1
141141+# name: webhook-service
142142+# fieldPath: .metadata.namespace # Namespace of the service
143143+# targets:
144144+# - select:
145145+# kind: Certificate
146146+# group: cert-manager.io
147147+# version: v1
148148+# name: serving-cert
149149+# fieldPaths:
150150+# - .spec.dnsNames.0
151151+# - .spec.dnsNames.1
152152+# options:
153153+# delimiter: '.'
154154+# index: 1
155155+# create: true
156156+#
157157+# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
158158+# kind: Certificate
159159+# group: cert-manager.io
160160+# version: v1
161161+# name: serving-cert # This name should match the one in certificate.yaml
162162+# fieldPath: .metadata.namespace # Namespace of the certificate CR
163163+# targets:
164164+# - select:
165165+# kind: ValidatingWebhookConfiguration
166166+# fieldPaths:
167167+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
168168+# options:
169169+# delimiter: '/'
170170+# index: 0
171171+# create: true
172172+# - source:
173173+# kind: Certificate
174174+# group: cert-manager.io
175175+# version: v1
176176+# name: serving-cert
177177+# fieldPath: .metadata.name
178178+# targets:
179179+# - select:
180180+# kind: ValidatingWebhookConfiguration
181181+# fieldPaths:
182182+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
183183+# options:
184184+# delimiter: '/'
185185+# index: 1
186186+# create: true
187187+#
188188+# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
189189+# kind: Certificate
190190+# group: cert-manager.io
191191+# version: v1
192192+# name: serving-cert
193193+# fieldPath: .metadata.namespace # Namespace of the certificate CR
194194+# targets:
195195+# - select:
196196+# kind: MutatingWebhookConfiguration
197197+# fieldPaths:
198198+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
199199+# options:
200200+# delimiter: '/'
201201+# index: 0
202202+# create: true
203203+# - source:
204204+# kind: Certificate
205205+# group: cert-manager.io
206206+# version: v1
207207+# name: serving-cert
208208+# fieldPath: .metadata.name
209209+# targets:
210210+# - select:
211211+# kind: MutatingWebhookConfiguration
212212+# fieldPaths:
213213+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
214214+# options:
215215+# delimiter: '/'
216216+# index: 1
217217+# create: true
218218+#
219219+# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)
220220+# kind: Certificate
221221+# group: cert-manager.io
222222+# version: v1
223223+# name: serving-cert
224224+# fieldPath: .metadata.namespace # Namespace of the certificate CR
225225+# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
226226+# +kubebuilder:scaffold:crdkustomizecainjectionns
227227+# - source:
228228+# kind: Certificate
229229+# group: cert-manager.io
230230+# version: v1
231231+# name: serving-cert
232232+# fieldPath: .metadata.name
233233+# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.
234234+# +kubebuilder:scaffold:crdkustomizecainjectionname
+4
config/default/manager_metrics_patch.yaml
···11+# This patch adds the args to allow exposing the metrics endpoint using HTTPS
22+- op: add
33+ path: /spec/template/spec/containers/0/args/0
44+ value: --metrics-bind-address=:8443
···11+apiVersion: v1
22+kind: Namespace
33+metadata:
44+ labels:
55+ control-plane: controller-manager
66+ app.kubernetes.io/name: hsm-secrets-operator
77+ app.kubernetes.io/managed-by: kustomize
88+ name: system
99+---
1010+apiVersion: apps/v1
1111+kind: Deployment
1212+metadata:
1313+ name: controller-manager
1414+ namespace: system
1515+ labels:
1616+ control-plane: controller-manager
1717+ app.kubernetes.io/name: hsm-secrets-operator
1818+ app.kubernetes.io/managed-by: kustomize
1919+spec:
2020+ selector:
2121+ matchLabels:
2222+ control-plane: controller-manager
2323+ app.kubernetes.io/name: hsm-secrets-operator
2424+ replicas: 1
2525+ template:
2626+ metadata:
2727+ annotations:
2828+ kubectl.kubernetes.io/default-container: manager
2929+ labels:
3030+ control-plane: controller-manager
3131+ app.kubernetes.io/name: hsm-secrets-operator
3232+ spec:
3333+ # TODO(user): Uncomment the following code to configure the nodeAffinity expression
3434+ # according to the platforms which are supported by your solution.
3535+ # It is considered best practice to support multiple architectures. You can
3636+ # build your manager image using the makefile target docker-buildx.
3737+ # affinity:
3838+ # nodeAffinity:
3939+ # requiredDuringSchedulingIgnoredDuringExecution:
4040+ # nodeSelectorTerms:
4141+ # - matchExpressions:
4242+ # - key: kubernetes.io/arch
4343+ # operator: In
4444+ # values:
4545+ # - amd64
4646+ # - arm64
4747+ # - ppc64le
4848+ # - s390x
4949+ # - key: kubernetes.io/os
5050+ # operator: In
5151+ # values:
5252+ # - linux
5353+ securityContext:
5454+ # Projects are configured by default to adhere to the "restricted" Pod Security Standards.
5555+ # This ensures that deployments meet the highest security requirements for Kubernetes.
5656+ # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
5757+ runAsNonRoot: true
5858+ seccompProfile:
5959+ type: RuntimeDefault
6060+ containers:
6161+ - command:
6262+ - /manager
6363+ args:
6464+ - --leader-elect
6565+ - --health-probe-bind-address=:8081
6666+ image: controller:latest
6767+ name: manager
6868+ ports: []
6969+ securityContext:
7070+ allowPrivilegeEscalation: false
7171+ capabilities:
7272+ drop:
7373+ - "ALL"
7474+ livenessProbe:
7575+ httpGet:
7676+ path: /healthz
7777+ port: 8081
7878+ initialDelaySeconds: 15
7979+ periodSeconds: 20
8080+ readinessProbe:
8181+ httpGet:
8282+ path: /readyz
8383+ port: 8081
8484+ initialDelaySeconds: 5
8585+ periodSeconds: 10
8686+ # TODO(user): Configure the resources accordingly based on the project requirements.
8787+ # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
8888+ resources:
8989+ limits:
9090+ cpu: 500m
9191+ memory: 128Mi
9292+ requests:
9393+ cpu: 10m
9494+ memory: 64Mi
9595+ volumeMounts: []
9696+ volumes: []
9797+ serviceAccountName: controller-manager
9898+ terminationGracePeriodSeconds: 10
+28
config/manifests/kustomization.yaml
···11+# These resources constitute the fully configured set of manifests
22+# used to generate the 'manifests/' directory in a bundle.
33+resources:
44+- bases/hsm-secrets-operator.clusterserviceversion.yaml
55+- ../default
66+- ../samples
77+- ../scorecard
88+99+# [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix.
1010+# Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager.
1111+# These patches remove the unnecessary "cert" volume and its manager container volumeMount.
1212+#patches:
1313+#- target:
1414+# group: apps
1515+# version: v1
1616+# kind: Deployment
1717+# name: controller-manager
1818+# namespace: system
1919+# patch: |-
2020+# # Remove the manager container's "cert" volumeMount, since OLM will create and mount a set of certs.
2121+# # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment.
2222+# - op: remove
2323+2424+# path: /spec/template/spec/containers/0/volumeMounts/0
2525+# # Remove the "cert" volume, since OLM will create and mount a set of certs.
2626+# # Update the indices in this path if adding or removing volumes in the manager's Deployment.
2727+# - op: remove
2828+# path: /spec/template/spec/volumes/0
+27
config/network-policy/allow-metrics-traffic.yaml
···11+# This NetworkPolicy allows ingress traffic
22+# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those
33+# namespaces are able to gather data from the metrics endpoint.
44+apiVersion: networking.k8s.io/v1
55+kind: NetworkPolicy
66+metadata:
77+ labels:
88+ app.kubernetes.io/name: hsm-secrets-operator
99+ app.kubernetes.io/managed-by: kustomize
1010+ name: allow-metrics-traffic
1111+ namespace: system
1212+spec:
1313+ podSelector:
1414+ matchLabels:
1515+ control-plane: controller-manager
1616+ app.kubernetes.io/name: hsm-secrets-operator
1717+ policyTypes:
1818+ - Ingress
1919+ ingress:
2020+ # This allows ingress traffic from any namespace with the label metrics: enabled
2121+ - from:
2222+ - namespaceSelector:
2323+ matchLabels:
2424+ metrics: enabled # Only from namespaces with this label
2525+ ports:
2626+ - port: 8443
2727+ protocol: TCP
···11+resources:
22+- monitor.yaml
33+44+# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus
55+# to securely reference certificates created and managed by cert-manager.
66+# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml
77+# to mount the "metrics-server-cert" secret in the Manager Deployment.
88+#patches:
99+# - path: monitor_tls_patch.yaml
1010+# target:
1111+# kind: ServiceMonitor
+27
config/prometheus/monitor.yaml
···11+# Prometheus Monitor Service (Metrics)
22+apiVersion: monitoring.coreos.com/v1
33+kind: ServiceMonitor
44+metadata:
55+ labels:
66+ control-plane: controller-manager
77+ app.kubernetes.io/name: hsm-secrets-operator
88+ app.kubernetes.io/managed-by: kustomize
99+ name: controller-manager-metrics-monitor
1010+ namespace: system
1111+spec:
1212+ endpoints:
1313+ - path: /metrics
1414+ port: https # Ensure this is the name of the port that exposes HTTPS metrics
1515+ scheme: https
1616+ bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
1717+ tlsConfig:
1818+ # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables
1919+ # certificate verification, exposing the system to potential man-in-the-middle attacks.
2020+ # For production environments, it is recommended to use cert-manager for automatic TLS certificate management.
2121+ # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml,
2222+ # which securely references the certificate from the 'metrics-server-cert' secret.
2323+ insecureSkipVerify: true
2424+ selector:
2525+ matchLabels:
2626+ control-plane: controller-manager
2727+ app.kubernetes.io/name: hsm-secrets-operator
+19
config/prometheus/monitor_tls_patch.yaml
···11+# Patch for Prometheus ServiceMonitor to enable secure TLS configuration
22+# using certificates managed by cert-manager
33+- op: replace
44+ path: /spec/endpoints/0/tlsConfig
55+ value:
66+ # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
77+ serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc
88+ insecureSkipVerify: false
99+ ca:
1010+ secret:
1111+ name: metrics-server-cert
1212+ key: ca.crt
1313+ cert:
1414+ secret:
1515+ name: metrics-server-cert
1616+ key: tls.crt
1717+ keySecret:
1818+ name: metrics-server-cert
1919+ key: tls.key
+27
config/rbac/hsmdevice_admin_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants full permissions ('*') over hsm.j5t.io.
55+# This role is intended for users authorized to modify roles and bindings within the cluster,
66+# enabling them to delegate specific permissions to other users or groups as needed.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmdevice-admin-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmdevices
2020+ verbs:
2121+ - '*'
2222+- apiGroups:
2323+ - hsm.j5t.io
2424+ resources:
2525+ - hsmdevices/status
2626+ verbs:
2727+ - get
+33
config/rbac/hsmdevice_editor_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants permissions to create, update, and delete resources within the hsm.j5t.io.
55+# This role is intended for users who need to manage these resources
66+# but should not control RBAC or manage permissions for others.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmdevice-editor-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmdevices
2020+ verbs:
2121+ - create
2222+ - delete
2323+ - get
2424+ - list
2525+ - patch
2626+ - update
2727+ - watch
2828+- apiGroups:
2929+ - hsm.j5t.io
3030+ resources:
3131+ - hsmdevices/status
3232+ verbs:
3333+ - get
+29
config/rbac/hsmdevice_viewer_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants read-only access to hsm.j5t.io resources.
55+# This role is intended for users who need visibility into these resources
66+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmdevice-viewer-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmdevices
2020+ verbs:
2121+ - get
2222+ - list
2323+ - watch
2424+- apiGroups:
2525+ - hsm.j5t.io
2626+ resources:
2727+ - hsmdevices/status
2828+ verbs:
2929+ - get
+27
config/rbac/hsmsecret_admin_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants full permissions ('*') over hsm.j5t.io.
55+# This role is intended for users authorized to modify roles and bindings within the cluster,
66+# enabling them to delegate specific permissions to other users or groups as needed.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmsecret-admin-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmsecrets
2020+ verbs:
2121+ - '*'
2222+- apiGroups:
2323+ - hsm.j5t.io
2424+ resources:
2525+ - hsmsecrets/status
2626+ verbs:
2727+ - get
+33
config/rbac/hsmsecret_editor_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants permissions to create, update, and delete resources within the hsm.j5t.io.
55+# This role is intended for users who need to manage these resources
66+# but should not control RBAC or manage permissions for others.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmsecret-editor-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmsecrets
2020+ verbs:
2121+ - create
2222+ - delete
2323+ - get
2424+ - list
2525+ - patch
2626+ - update
2727+ - watch
2828+- apiGroups:
2929+ - hsm.j5t.io
3030+ resources:
3131+ - hsmsecrets/status
3232+ verbs:
3333+ - get
+29
config/rbac/hsmsecret_viewer_role.yaml
···11+# This rule is not used by the project hsm-secrets-operator itself.
22+# It is provided to allow the cluster admin to help manage permissions for users.
33+#
44+# Grants read-only access to hsm.j5t.io resources.
55+# This role is intended for users who need visibility into these resources
66+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
77+88+apiVersion: rbac.authorization.k8s.io/v1
99+kind: ClusterRole
1010+metadata:
1111+ labels:
1212+ app.kubernetes.io/name: hsm-secrets-operator
1313+ app.kubernetes.io/managed-by: kustomize
1414+ name: hsmsecret-viewer-role
1515+rules:
1616+- apiGroups:
1717+ - hsm.j5t.io
1818+ resources:
1919+ - hsmsecrets
2020+ verbs:
2121+ - get
2222+ - list
2323+ - watch
2424+- apiGroups:
2525+ - hsm.j5t.io
2626+ resources:
2727+ - hsmsecrets/status
2828+ verbs:
2929+ - get
+31
config/rbac/kustomization.yaml
···11+resources:
22+# All RBAC will be applied under this service account in
33+# the deployment namespace. You may comment out this resource
44+# if your manager will use a service account that exists at
55+# runtime. Be sure to update RoleBinding and ClusterRoleBinding
66+# subjects if changing service account names.
77+- service_account.yaml
88+- role.yaml
99+- role_binding.yaml
1010+- leader_election_role.yaml
1111+- leader_election_role_binding.yaml
1212+# The following RBAC configurations are used to protect
1313+# the metrics endpoint with authn/authz. These configurations
1414+# ensure that only authorized users and service accounts
1515+# can access the metrics endpoint. Comment the following
1616+# permissions if you want to disable this protection.
1717+# More info: https://book.kubebuilder.io/reference/metrics.html
1818+- metrics_auth_role.yaml
1919+- metrics_auth_role_binding.yaml
2020+- metrics_reader_role.yaml
2121+# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by
2222+# default, aiding admins in cluster management. Those roles are
2323+# not used by the hsm-secrets-operator itself. You can comment the following lines
2424+# if you do not want those helpers be installed with your Project.
2525+- hsmdevice_admin_role.yaml
2626+- hsmdevice_editor_role.yaml
2727+- hsmdevice_viewer_role.yaml
2828+- hsmsecret_admin_role.yaml
2929+- hsmsecret_editor_role.yaml
3030+- hsmsecret_viewer_role.yaml
3131+
···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+*/
+234
internal/api/helpers.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 api
1818+1919+import (
2020+ "context"
2121+ "encoding/json"
2222+ "fmt"
2323+2424+ corev1 "k8s.io/api/core/v1"
2525+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626+ "k8s.io/apimachinery/pkg/types"
2727+2828+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
2929+ "github.com/evanjarrett/hsm-secrets-operator/internal/hsm"
3030+)
3131+3232+// generateHSMPath creates an HSM path from label and ID
3333+func (s *Server) generateHSMPath(label string, id uint32) string {
3434+ return fmt.Sprintf("secrets/api/%s", label)
3535+}
3636+3737+// convertToHSMData converts API data to HSM format based on the specified format
3838+func (s *Server) convertToHSMData(data map[string]interface{}, format SecretFormat) (hsm.SecretData, error) {
3939+ hsmData := make(hsm.SecretData)
4040+4141+ switch format {
4242+ case SecretFormatJSON:
4343+ // Convert each key-value pair to JSON bytes
4444+ for key, value := range data {
4545+ jsonBytes, err := json.Marshal(value)
4646+ if err != nil {
4747+ return nil, fmt.Errorf("failed to marshal value for key %s: %w", key, err)
4848+ }
4949+ hsmData[key] = jsonBytes
5050+ }
5151+ case SecretFormatText:
5252+ // Convert values to string bytes
5353+ for key, value := range data {
5454+ str := fmt.Sprintf("%v", value)
5555+ hsmData[key] = []byte(str)
5656+ }
5757+ case SecretFormatBinary:
5858+ // Expect values to be base64 encoded strings or byte arrays
5959+ for key, value := range data {
6060+ switch v := value.(type) {
6161+ case string:
6262+ hsmData[key] = []byte(v)
6363+ case []byte:
6464+ hsmData[key] = v
6565+ default:
6666+ return nil, fmt.Errorf("binary format requires string or byte array values for key %s", key)
6767+ }
6868+ }
6969+ default:
7070+ return nil, fmt.Errorf("unsupported format: %s", format)
7171+ }
7272+7373+ return hsmData, nil
7474+}
7575+7676+// convertFromHSMData converts HSM data back to API format
7777+func (s *Server) convertFromHSMData(hsmData hsm.SecretData) (map[string]interface{}, error) {
7878+ data := make(map[string]interface{})
7979+8080+ for key, value := range hsmData {
8181+ // Try to unmarshal as JSON first
8282+ var jsonValue interface{}
8383+ if err := json.Unmarshal(value, &jsonValue); err == nil {
8484+ data[key] = jsonValue
8585+ } else {
8686+ // Fall back to string representation
8787+ data[key] = string(value)
8888+ }
8989+ }
9090+9191+ return data, nil
9292+}
9393+9494+// createHSMSecretResource creates a corresponding HSMSecret Kubernetes resource
9595+func (s *Server) createHSMSecretResource(ctx context.Context, label, hsmPath, description string, tags map[string]string) error {
9696+ hsmSecret := &hsmv1alpha1.HSMSecret{
9797+ ObjectMeta: metav1.ObjectMeta{
9898+ Name: label,
9999+ Namespace: "default", // TODO: make configurable
100100+ Labels: map[string]string{
101101+ "managed-by": "hsm-api",
102102+ "app": "hsm-secrets-operator",
103103+ },
104104+ },
105105+ Spec: hsmv1alpha1.HSMSecretSpec{
106106+ HSMPath: hsmPath,
107107+ SecretName: label,
108108+ AutoSync: true,
109109+ SyncInterval: 300,
110110+ SecretType: corev1.SecretTypeOpaque,
111111+ },
112112+ }
113113+114114+ // Add tags as annotations
115115+ if len(tags) > 0 {
116116+ if hsmSecret.Annotations == nil {
117117+ hsmSecret.Annotations = make(map[string]string)
118118+ }
119119+ for k, v := range tags {
120120+ hsmSecret.Annotations[fmt.Sprintf("hsm.j5t.io/tag-%s", k)] = v
121121+ }
122122+ }
123123+124124+ // Add description as annotation
125125+ if description != "" {
126126+ if hsmSecret.Annotations == nil {
127127+ hsmSecret.Annotations = make(map[string]string)
128128+ }
129129+ hsmSecret.Annotations["hsm.j5t.io/description"] = description
130130+ }
131131+132132+ return s.client.Create(ctx, hsmSecret)
133133+}
134134+135135+// findHSMSecretByLabel finds an HSMSecret resource by its label/name
136136+func (s *Server) findHSMSecretByLabel(ctx context.Context, label string) (*hsmv1alpha1.HSMSecret, error) {
137137+ // Try default namespace first
138138+ hsmSecret := &hsmv1alpha1.HSMSecret{}
139139+ err := s.client.Get(ctx, types.NamespacedName{
140140+ Name: label,
141141+ Namespace: "default",
142142+ }, hsmSecret)
143143+144144+ if err == nil {
145145+ return hsmSecret, nil
146146+ }
147147+148148+ // If not found in default, search across all namespaces
149149+ var hsmSecretList hsmv1alpha1.HSMSecretList
150150+ if err := s.client.List(ctx, &hsmSecretList); err != nil {
151151+ return nil, fmt.Errorf("failed to list HSMSecret resources: %w", err)
152152+ }
153153+154154+ for _, secret := range hsmSecretList.Items {
155155+ if secret.Name == label {
156156+ return &secret, nil
157157+ }
158158+ }
159159+160160+ return nil, fmt.Errorf("HSMSecret with label %s not found", label)
161161+}
162162+163163+// findHSMDevice finds a suitable HSMDevice for readonly operations
164164+func (s *Server) findHSMDevice(ctx context.Context) (*hsmv1alpha1.HSMDevice, error) {
165165+ var hsmDeviceList hsmv1alpha1.HSMDeviceList
166166+ if err := s.client.List(ctx, &hsmDeviceList); err != nil {
167167+ return nil, fmt.Errorf("failed to list HSM devices: %w", err)
168168+ }
169169+170170+ // Look for devices that have mirroring enabled and are in a ready state
171171+ for _, device := range hsmDeviceList.Items {
172172+ if device.Spec.Mirroring != nil &&
173173+ device.Spec.Mirroring.Policy != hsmv1alpha1.MirroringPolicyNone &&
174174+ device.Status.Phase == hsmv1alpha1.HSMDevicePhaseReady &&
175175+ len(device.Status.DiscoveredDevices) > 0 {
176176+ return &device, nil
177177+ }
178178+ }
179179+180180+ return nil, fmt.Errorf("no suitable HSM device found")
181181+}
182182+183183+// importFromKubernetes imports secret data from a Kubernetes Secret
184184+func (s *Server) importFromKubernetes(ctx context.Context, secretName, namespace string, keyMapping map[string]string) (map[string]interface{}, error) {
185185+ if namespace == "" {
186186+ namespace = "default"
187187+ }
188188+189189+ // Get the Kubernetes Secret
190190+ secret := &corev1.Secret{}
191191+ err := s.client.Get(ctx, types.NamespacedName{
192192+ Name: secretName,
193193+ Namespace: namespace,
194194+ }, secret)
195195+ if err != nil {
196196+ return nil, fmt.Errorf("failed to get Kubernetes secret %s/%s: %w", namespace, secretName, err)
197197+ }
198198+199199+ // Convert secret data to API format
200200+ data := make(map[string]interface{})
201201+ for key, value := range secret.Data {
202202+ targetKey := key
203203+204204+ // Apply key mapping if provided
205205+ if keyMapping != nil {
206206+ if mappedKey, exists := keyMapping[key]; exists {
207207+ targetKey = mappedKey
208208+ }
209209+ }
210210+211211+ // Try to unmarshal as JSON, otherwise use as string
212212+ var jsonValue interface{}
213213+ if err := json.Unmarshal(value, &jsonValue); err == nil {
214214+ data[targetKey] = jsonValue
215215+ } else {
216216+ data[targetKey] = string(value)
217217+ }
218218+ }
219219+220220+ if len(data) == 0 {
221221+ return nil, fmt.Errorf("no data found in Kubernetes secret %s/%s", namespace, secretName)
222222+ }
223223+224224+ return data, nil
225225+}
226226+227227+// validateSecretAccess checks if the current user has access to the secret (placeholder for future authorization)
228228+func (s *Server) validateSecretAccess(ctx context.Context, label string, operation string) error {
229229+ // TODO: Implement proper authorization logic
230230+ // This could integrate with Kubernetes RBAC, external auth systems, etc.
231231+232232+ s.logger.V(1).Info("Access validation", "label", label, "operation", operation)
233233+ return nil
234234+}
+480
internal/api/server.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 api
1818+1919+import (
2020+ "fmt"
2121+ "net/http"
2222+ "strconv"
2323+ "time"
2424+2525+ "github.com/gin-gonic/gin"
2626+ "github.com/go-logr/logr"
2727+ "github.com/go-playground/validator/v10"
2828+ "sigs.k8s.io/controller-runtime/pkg/client"
2929+3030+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3131+ "github.com/evanjarrett/hsm-secrets-operator/internal/discovery"
3232+ "github.com/evanjarrett/hsm-secrets-operator/internal/hsm"
3333+)
3434+3535+// Server represents the HSM REST API server
3636+type Server struct {
3737+ client client.Client
3838+ hsmClient hsm.Client
3939+ mirroringManager *discovery.MirroringManager
4040+ validator *validator.Validate
4141+ logger logr.Logger
4242+ router *gin.Engine
4343+}
4444+4545+// NewServer creates a new API server instance
4646+func NewServer(client client.Client, hsmClient hsm.Client, mirroringManager *discovery.MirroringManager, logger logr.Logger) *Server {
4747+ s := &Server{
4848+ client: client,
4949+ hsmClient: hsmClient,
5050+ mirroringManager: mirroringManager,
5151+ validator: validator.New(),
5252+ logger: logger.WithName("api-server"),
5353+ }
5454+5555+ s.setupRouter()
5656+ return s
5757+}
5858+5959+// setupRouter configures the HTTP routes
6060+func (s *Server) setupRouter() {
6161+ // Set gin mode to release for production
6262+ gin.SetMode(gin.ReleaseMode)
6363+6464+ s.router = gin.New()
6565+6666+ // Add middleware
6767+ s.router.Use(gin.Recovery())
6868+ s.router.Use(s.loggingMiddleware())
6969+ s.router.Use(s.corsMiddleware())
7070+7171+ // API v1 routes
7272+ v1 := s.router.Group("/api/v1")
7373+ {
7474+ // Health check
7575+ v1.GET("/health", s.handleHealth)
7676+7777+ // HSM secrets management
7878+ hsm := v1.Group("/hsm")
7979+ {
8080+ secrets := hsm.Group("/secrets")
8181+ {
8282+ secrets.POST("", s.handleCreateSecret)
8383+ secrets.GET("", s.handleListSecrets)
8484+ secrets.GET("/:label", s.handleGetSecret)
8585+ secrets.PUT("/:label", s.handleUpdateSecret)
8686+ secrets.DELETE("/:label", s.handleDeleteSecret)
8787+ secrets.POST("/import", s.handleImportSecret)
8888+ }
8989+ }
9090+ }
9191+}
9292+9393+// Start starts the API server on the specified port
9494+func (s *Server) Start(port int) error {
9595+ addr := fmt.Sprintf(":%d", port)
9696+ s.logger.Info("Starting API server", "addr", addr)
9797+ return s.router.Run(addr)
9898+}
9999+100100+// handleHealth handles health check requests
101101+func (s *Server) handleHealth(c *gin.Context) {
102102+103103+ hsmConnected := s.hsmClient != nil && s.hsmClient.IsConnected()
104104+ replicationEnabled := s.mirroringManager != nil
105105+ activeNodes := 0
106106+107107+ if s.mirroringManager != nil {
108108+ // Count active nodes (simplified - in real implementation would check actual node health)
109109+ activeNodes = 1 // Current node
110110+ }
111111+112112+ status := "healthy"
113113+ if !hsmConnected {
114114+ status = "degraded"
115115+ }
116116+117117+ health := HealthStatus{
118118+ Status: status,
119119+ HSMConnected: hsmConnected,
120120+ ReplicationEnabled: replicationEnabled,
121121+ ActiveNodes: activeNodes,
122122+ Timestamp: time.Now(),
123123+ }
124124+125125+ s.sendResponse(c, http.StatusOK, "Health check completed", health)
126126+}
127127+128128+// handleCreateSecret handles secret creation requests
129129+func (s *Server) handleCreateSecret(c *gin.Context) {
130130+ ctx := c.Request.Context()
131131+132132+ var req CreateSecretRequest
133133+ if err := c.ShouldBindJSON(&req); err != nil {
134134+ s.sendError(c, http.StatusBadRequest, "invalid_request", "Invalid JSON payload", map[string]interface{}{
135135+ "parse_error": err.Error(),
136136+ })
137137+ return
138138+ }
139139+140140+ if err := s.validator.Struct(&req); err != nil {
141141+ s.sendError(c, http.StatusBadRequest, "validation_failed", "Request validation failed", map[string]interface{}{
142142+ "validation_errors": err.Error(),
143143+ })
144144+ return
145145+ }
146146+147147+ // Check if HSM client is available
148148+ if s.hsmClient == nil || !s.hsmClient.IsConnected() {
149149+ s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client is not available", nil)
150150+ return
151151+ }
152152+153153+ // Convert request data to HSM format
154154+ hsmData, err := s.convertToHSMData(req.Data, req.Format)
155155+ if err != nil {
156156+ s.sendError(c, http.StatusBadRequest, "data_conversion_error", err.Error(), nil)
157157+ return
158158+ }
159159+160160+ // Create HSM path from label
161161+ hsmPath := s.generateHSMPath(req.Label, req.ID)
162162+163163+ // Store secret in HSM
164164+ if err := s.hsmClient.WriteSecret(ctx, hsmPath, hsmData); err != nil {
165165+ s.logger.Error(err, "Failed to write secret to HSM", "label", req.Label, "id", req.ID)
166166+ s.sendError(c, http.StatusInternalServerError, "hsm_write_error", "Failed to store secret in HSM", map[string]interface{}{
167167+ "hsm_error": err.Error(),
168168+ })
169169+ return
170170+ }
171171+172172+ // Create corresponding HSMSecret resource in Kubernetes
173173+ if err := s.createHSMSecretResource(ctx, req.Label, hsmPath, req.Description, req.Tags); err != nil {
174174+ s.logger.Error(err, "Failed to create HSMSecret resource", "label", req.Label)
175175+ // Continue - the secret is stored in HSM, just log the error
176176+ }
177177+178178+ s.logger.Info("Secret created successfully", "label", req.Label, "id", req.ID)
179179+ s.sendResponse(c, http.StatusCreated, "Secret created successfully", map[string]interface{}{
180180+ "label": req.Label,
181181+ "id": req.ID,
182182+ "path": hsmPath,
183183+ })
184184+}
185185+186186+// handleGetSecret handles secret retrieval requests
187187+func (s *Server) handleGetSecret(c *gin.Context) {
188188+ ctx := c.Request.Context()
189189+ label := c.Param("label")
190190+191191+ if label == "" {
192192+ s.sendError(c, http.StatusBadRequest, "invalid_label", "Label parameter is required", nil)
193193+ return
194194+ }
195195+196196+ // Find HSMSecret resource to get the HSM path
197197+ hsmSecret, err := s.findHSMSecretByLabel(ctx, label)
198198+ if err != nil {
199199+ s.logger.Error(err, "Failed to find HSMSecret resource", "label", label)
200200+ s.sendError(c, http.StatusNotFound, "secret_not_found", "Secret not found", nil)
201201+ return
202202+ }
203203+204204+ // Read from HSM with fallback support
205205+ var hsmData hsm.SecretData
206206+ if s.hsmClient != nil && s.hsmClient.IsConnected() {
207207+ hsmData, err = s.hsmClient.ReadSecret(ctx, hsmSecret.Spec.HSMPath)
208208+ if err != nil && s.mirroringManager != nil {
209209+ // Try readonly fallback
210210+ if hsmDevice, devErr := s.findHSMDevice(ctx); devErr == nil && hsmDevice != nil {
211211+ hsmData, err = s.mirroringManager.GetReadOnlyAccess(ctx, hsmSecret.Spec.HSMPath, hsmDevice)
212212+ }
213213+ }
214214+ } else if s.mirroringManager != nil {
215215+ // Primary HSM unavailable, try readonly access
216216+ if hsmDevice, devErr := s.findHSMDevice(ctx); devErr == nil && hsmDevice != nil {
217217+ hsmData, err = s.mirroringManager.GetReadOnlyAccess(ctx, hsmSecret.Spec.HSMPath, hsmDevice)
218218+ }
219219+ }
220220+221221+ if err != nil {
222222+ s.logger.Error(err, "Failed to read secret from HSM", "label", label)
223223+ s.sendError(c, http.StatusInternalServerError, "hsm_read_error", "Failed to read secret from HSM", nil)
224224+ return
225225+ }
226226+227227+ // Convert HSM data back to API format
228228+ data, err := s.convertFromHSMData(hsmData)
229229+ if err != nil {
230230+ s.sendError(c, http.StatusInternalServerError, "data_conversion_error", err.Error(), nil)
231231+ return
232232+ }
233233+234234+ // Create metadata
235235+ checksum := hsm.CalculateChecksum(hsmData)
236236+ metadata := SecretInfo{
237237+ Label: label,
238238+ Checksum: checksum,
239239+ UpdatedAt: time.Now(),
240240+ IsReplicated: s.mirroringManager != nil,
241241+ }
242242+243243+ secretData := SecretData{
244244+ Data: data,
245245+ Metadata: metadata,
246246+ }
247247+248248+ s.sendResponse(c, http.StatusOK, "Secret retrieved successfully", secretData)
249249+}
250250+251251+// handleUpdateSecret handles secret update requests
252252+func (s *Server) handleUpdateSecret(c *gin.Context) {
253253+ ctx := c.Request.Context()
254254+ label := c.Param("label")
255255+256256+ var req UpdateSecretRequest
257257+ if err := c.ShouldBindJSON(&req); err != nil {
258258+ s.sendError(c, http.StatusBadRequest, "invalid_request", "Invalid JSON payload", nil)
259259+ return
260260+ }
261261+262262+ if err := s.validator.Struct(&req); err != nil {
263263+ s.sendError(c, http.StatusBadRequest, "validation_failed", "Request validation failed", nil)
264264+ return
265265+ }
266266+267267+ // Find existing HSMSecret resource
268268+ hsmSecret, err := s.findHSMSecretByLabel(ctx, label)
269269+ if err != nil {
270270+ s.sendError(c, http.StatusNotFound, "secret_not_found", "Secret not found", nil)
271271+ return
272272+ }
273273+274274+ // Check if HSM client is available for write operations
275275+ if s.hsmClient == nil || !s.hsmClient.IsConnected() {
276276+ s.sendError(c, http.StatusServiceUnavailable, "hsm_unavailable", "HSM client is not available for write operations", nil)
277277+ return
278278+ }
279279+280280+ // Convert request data to HSM format (assume JSON for updates)
281281+ hsmData, err := s.convertToHSMData(req.Data, SecretFormatJSON)
282282+ if err != nil {
283283+ s.sendError(c, http.StatusBadRequest, "data_conversion_error", err.Error(), nil)
284284+ return
285285+ }
286286+287287+ // Update secret in HSM
288288+ if err := s.hsmClient.WriteSecret(ctx, hsmSecret.Spec.HSMPath, hsmData); err != nil {
289289+ s.logger.Error(err, "Failed to update secret in HSM", "label", label)
290290+ s.sendError(c, http.StatusInternalServerError, "hsm_write_error", "Failed to update secret in HSM", nil)
291291+ return
292292+ }
293293+294294+ s.logger.Info("Secret updated successfully", "label", label)
295295+ s.sendResponse(c, http.StatusOK, "Secret updated successfully", map[string]interface{}{
296296+ "label": label,
297297+ "path": hsmSecret.Spec.HSMPath,
298298+ })
299299+}
300300+301301+// handleDeleteSecret handles secret deletion requests
302302+func (s *Server) handleDeleteSecret(c *gin.Context) {
303303+ ctx := c.Request.Context()
304304+ label := c.Param("label")
305305+306306+ // Find existing HSMSecret resource
307307+ hsmSecret, err := s.findHSMSecretByLabel(ctx, label)
308308+ if err != nil {
309309+ s.sendError(c, http.StatusNotFound, "secret_not_found", "Secret not found", nil)
310310+ return
311311+ }
312312+313313+ // Delete the HSMSecret resource (this will trigger cleanup via finalizers)
314314+ if err := s.client.Delete(ctx, hsmSecret); err != nil {
315315+ s.logger.Error(err, "Failed to delete HSMSecret resource", "label", label)
316316+ s.sendError(c, http.StatusInternalServerError, "delete_error", "Failed to delete secret", nil)
317317+ return
318318+ }
319319+320320+ s.logger.Info("Secret deleted successfully", "label", label)
321321+ s.sendResponse(c, http.StatusOK, "Secret deleted successfully", map[string]interface{}{
322322+ "label": label,
323323+ })
324324+}
325325+326326+// handleListSecrets handles secret listing requests
327327+func (s *Server) handleListSecrets(c *gin.Context) {
328328+ ctx := c.Request.Context()
329329+330330+ // Get pagination parameters
331331+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
332332+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
333333+334334+ if page < 1 {
335335+ page = 1
336336+ }
337337+ if pageSize < 1 || pageSize > 100 {
338338+ pageSize = 50
339339+ }
340340+341341+ // List HSMSecret resources
342342+ var hsmSecretList hsmv1alpha1.HSMSecretList
343343+ if err := s.client.List(ctx, &hsmSecretList); err != nil {
344344+ s.logger.Error(err, "Failed to list HSMSecret resources")
345345+ s.sendError(c, http.StatusInternalServerError, "list_error", "Failed to list secrets", nil)
346346+ return
347347+ }
348348+349349+ // Convert to API format with pagination
350350+ secrets := make([]SecretInfo, 0)
351351+ start := (page - 1) * pageSize
352352+ end := start + pageSize
353353+354354+ for i, hsmSecret := range hsmSecretList.Items {
355355+ if i >= start && i < end {
356356+ info := SecretInfo{
357357+ Label: hsmSecret.Name,
358358+ Checksum: hsmSecret.Status.HSMChecksum,
359359+ IsReplicated: s.mirroringManager != nil,
360360+ }
361361+362362+ if hsmSecret.Status.LastSyncTime != nil {
363363+ info.UpdatedAt = hsmSecret.Status.LastSyncTime.Time
364364+ }
365365+366366+ secrets = append(secrets, info)
367367+ }
368368+ }
369369+370370+ secretList := SecretList{
371371+ Secrets: secrets,
372372+ Total: len(hsmSecretList.Items),
373373+ Page: page,
374374+ PageSize: pageSize,
375375+ }
376376+377377+ s.sendResponse(c, http.StatusOK, "Secrets listed successfully", secretList)
378378+}
379379+380380+// handleImportSecret handles secret import requests
381381+func (s *Server) handleImportSecret(c *gin.Context) {
382382+ ctx := c.Request.Context()
383383+384384+ var req ImportSecretRequest
385385+ if err := c.ShouldBindJSON(&req); err != nil {
386386+ s.sendError(c, http.StatusBadRequest, "invalid_request", "Invalid JSON payload", nil)
387387+ return
388388+ }
389389+390390+ if err := s.validator.Struct(&req); err != nil {
391391+ s.sendError(c, http.StatusBadRequest, "validation_failed", "Request validation failed", nil)
392392+ return
393393+ }
394394+395395+ // Import logic depends on source
396396+ var data map[string]interface{}
397397+ var err error
398398+399399+ switch req.Source {
400400+ case "kubernetes":
401401+ data, err = s.importFromKubernetes(ctx, req.SecretName, req.SecretNamespace, req.KeyMapping)
402402+ default:
403403+ s.sendError(c, http.StatusBadRequest, "unsupported_source", fmt.Sprintf("Import source '%s' is not supported", req.Source), nil)
404404+ return
405405+ }
406406+407407+ if err != nil {
408408+ s.logger.Error(err, "Failed to import secret", "source", req.Source, "name", req.SecretName)
409409+ s.sendError(c, http.StatusInternalServerError, "import_error", err.Error(), nil)
410410+ return
411411+ }
412412+413413+ // Create the secret using the imported data
414414+ createReq := CreateSecretRequest{
415415+ Label: req.TargetLabel,
416416+ ID: req.TargetID,
417417+ Format: req.Format,
418418+ Data: data,
419419+ }
420420+421421+ // Use existing creation logic
422422+ c.Set("create_request", createReq)
423423+ s.handleCreateSecret(c)
424424+}
425425+426426+// sendResponse sends a successful API response
427427+func (s *Server) sendResponse(c *gin.Context, statusCode int, message string, data interface{}) {
428428+ response := APIResponse{
429429+ Success: true,
430430+ Message: message,
431431+ Data: data,
432432+ }
433433+ c.JSON(statusCode, response)
434434+}
435435+436436+// sendError sends an error API response
437437+func (s *Server) sendError(c *gin.Context, statusCode int, code, message string, details map[string]interface{}) {
438438+ response := APIResponse{
439439+ Success: false,
440440+ Error: &APIError{
441441+ Code: code,
442442+ Message: message,
443443+ Details: details,
444444+ },
445445+ }
446446+ c.JSON(statusCode, response)
447447+}
448448+449449+// loggingMiddleware provides request logging
450450+func (s *Server) loggingMiddleware() gin.HandlerFunc {
451451+ return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
452452+ return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
453453+ param.ClientIP,
454454+ param.TimeStamp.Format(time.RFC1123),
455455+ param.Method,
456456+ param.Path,
457457+ param.Request.Proto,
458458+ param.StatusCode,
459459+ param.Latency,
460460+ param.Request.UserAgent(),
461461+ param.ErrorMessage,
462462+ )
463463+ })
464464+}
465465+466466+// corsMiddleware provides CORS headers
467467+func (s *Server) corsMiddleware() gin.HandlerFunc {
468468+ return func(c *gin.Context) {
469469+ c.Header("Access-Control-Allow-Origin", "*")
470470+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
471471+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
472472+473473+ if c.Request.Method == "OPTIONS" {
474474+ c.AbortWithStatus(204)
475475+ return
476476+ }
477477+478478+ c.Next()
479479+ }
480480+}
+192
internal/api/types.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 api
1818+1919+import (
2020+ "time"
2121+)
2222+2323+// SecretFormat defines the format of the secret data
2424+type SecretFormat string
2525+2626+const (
2727+ // SecretFormatJSON stores data as JSON key-value pairs
2828+ SecretFormatJSON SecretFormat = "json"
2929+ // SecretFormatBinary stores raw binary data
3030+ SecretFormatBinary SecretFormat = "binary"
3131+ // SecretFormatText stores plain text data
3232+ SecretFormatText SecretFormat = "text"
3333+)
3434+3535+// CreateSecretRequest represents a request to create a new secret
3636+type CreateSecretRequest struct {
3737+ // Label is the human-readable identifier for the secret
3838+ Label string `json:"label" validate:"required,min=1,max=255"`
3939+4040+ // ID is the unique numeric identifier for the secret on the HSM
4141+ ID uint32 `json:"id" validate:"required,min=1"`
4242+4343+ // Format specifies how the data should be stored
4444+ Format SecretFormat `json:"format" validate:"required,oneof=json binary text"`
4545+4646+ // Data contains the actual secret data
4747+ Data map[string]interface{} `json:"data" validate:"required"`
4848+4949+ // Description is an optional description of the secret
5050+ Description string `json:"description,omitempty" validate:"max=1000"`
5151+5252+ // Tags are optional metadata tags
5353+ Tags map[string]string `json:"tags,omitempty"`
5454+}
5555+5656+// UpdateSecretRequest represents a request to update an existing secret
5757+type UpdateSecretRequest struct {
5858+ // Data contains the updated secret data
5959+ Data map[string]interface{} `json:"data" validate:"required"`
6060+6161+ // Description is an optional updated description
6262+ Description string `json:"description,omitempty" validate:"max=1000"`
6363+6464+ // Tags are optional updated metadata tags
6565+ Tags map[string]string `json:"tags,omitempty"`
6666+}
6767+6868+// ImportSecretRequest represents a request to import a secret from external source
6969+type ImportSecretRequest struct {
7070+ // Source specifies where to import from (kubernetes, vault, etc.)
7171+ Source string `json:"source" validate:"required,oneof=kubernetes vault file"`
7272+7373+ // SecretName is the name of the source secret
7474+ SecretName string `json:"secret_name" validate:"required"`
7575+7676+ // SecretNamespace is the namespace for Kubernetes secrets
7777+ SecretNamespace string `json:"secret_namespace,omitempty"`
7878+7979+ // TargetLabel is the label for the imported secret on HSM
8080+ TargetLabel string `json:"target_label" validate:"required,min=1,max=255"`
8181+8282+ // TargetID is the ID for the imported secret on HSM
8383+ TargetID uint32 `json:"target_id" validate:"required,min=1"`
8484+8585+ // Format specifies how the imported data should be stored
8686+ Format SecretFormat `json:"format" validate:"required,oneof=json binary text"`
8787+8888+ // KeyMapping maps source keys to target keys (optional)
8989+ KeyMapping map[string]string `json:"key_mapping,omitempty"`
9090+}
9191+9292+// SecretInfo represents information about a secret
9393+type SecretInfo struct {
9494+ // Label is the human-readable identifier
9595+ Label string `json:"label"`
9696+9797+ // ID is the unique numeric identifier on the HSM
9898+ ID uint32 `json:"id"`
9999+100100+ // Format specifies the data format
101101+ Format SecretFormat `json:"format"`
102102+103103+ // Description is the secret description
104104+ Description string `json:"description,omitempty"`
105105+106106+ // Tags are metadata tags
107107+ Tags map[string]string `json:"tags,omitempty"`
108108+109109+ // CreatedAt is when the secret was created
110110+ CreatedAt time.Time `json:"created_at"`
111111+112112+ // UpdatedAt is when the secret was last updated
113113+ UpdatedAt time.Time `json:"updated_at"`
114114+115115+ // Size is the size of the secret data in bytes
116116+ Size int64 `json:"size"`
117117+118118+ // Checksum is the SHA256 checksum of the data
119119+ Checksum string `json:"checksum"`
120120+121121+ // IsReplicated indicates if the secret is replicated across nodes
122122+ IsReplicated bool `json:"is_replicated"`
123123+}
124124+125125+// SecretData represents the actual secret data
126126+type SecretData struct {
127127+ // Data contains the secret key-value pairs
128128+ Data map[string]interface{} `json:"data"`
129129+130130+ // Metadata contains additional information about the secret
131131+ Metadata SecretInfo `json:"metadata"`
132132+}
133133+134134+// SecretList represents a list of secrets
135135+type SecretList struct {
136136+ // Secrets is the list of secret information
137137+ Secrets []SecretInfo `json:"secrets"`
138138+139139+ // Total is the total number of secrets
140140+ Total int `json:"total"`
141141+142142+ // Page is the current page number (for pagination)
143143+ Page int `json:"page,omitempty"`
144144+145145+ // PageSize is the number of items per page
146146+ PageSize int `json:"page_size,omitempty"`
147147+}
148148+149149+// APIResponse represents a standard API response
150150+type APIResponse struct {
151151+ // Success indicates if the operation was successful
152152+ Success bool `json:"success"`
153153+154154+ // Message provides additional information about the result
155155+ Message string `json:"message,omitempty"`
156156+157157+ // Data contains the response data
158158+ Data interface{} `json:"data,omitempty"`
159159+160160+ // Error contains error details if the operation failed
161161+ Error *APIError `json:"error,omitempty"`
162162+}
163163+164164+// APIError represents an API error
165165+type APIError struct {
166166+ // Code is the error code
167167+ Code string `json:"code"`
168168+169169+ // Message is the human-readable error message
170170+ Message string `json:"message"`
171171+172172+ // Details contains additional error details
173173+ Details map[string]interface{} `json:"details,omitempty"`
174174+}
175175+176176+// HealthStatus represents the health status of the API server
177177+type HealthStatus struct {
178178+ // Status is the overall health status
179179+ Status string `json:"status"`
180180+181181+ // HSMConnected indicates if HSM is connected
182182+ HSMConnected bool `json:"hsm_connected"`
183183+184184+ // ReplicationEnabled indicates if replication is enabled
185185+ ReplicationEnabled bool `json:"replication_enabled"`
186186+187187+ // ActiveNodes is the number of active HSM nodes
188188+ ActiveNodes int `json:"active_nodes"`
189189+190190+ // Timestamp is when the health check was performed
191191+ Timestamp time.Time `json:"timestamp"`
192192+}
+354
internal/controller/hsmdevice_controller.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 controller
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "os"
2323+ "time"
2424+2525+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626+ "k8s.io/apimachinery/pkg/runtime"
2727+ ctrl "sigs.k8s.io/controller-runtime"
2828+ "sigs.k8s.io/controller-runtime/pkg/client"
2929+ "sigs.k8s.io/controller-runtime/pkg/log"
3030+3131+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3232+ "github.com/evanjarrett/hsm-secrets-operator/internal/discovery"
3333+)
3434+3535+const (
3636+ // DefaultDiscoveryInterval is the default interval for device discovery
3737+ DefaultDiscoveryInterval = 30 * time.Second
3838+)
3939+4040+// HSMDeviceReconciler reconciles a HSMDevice object
4141+type HSMDeviceReconciler struct {
4242+ client.Client
4343+ Scheme *runtime.Scheme
4444+ NodeName string
4545+ USBDiscoverer *discovery.USBDiscoverer
4646+ MirroringManager *discovery.MirroringManager
4747+}
4848+4949+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmdevices,verbs=get;list;watch;create;update;patch;delete
5050+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmdevices/status,verbs=get;update;patch
5151+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmdevices/finalizers,verbs=update
5252+5353+// Reconcile handles HSMDevice reconciliation - discovers USB HSM devices on nodes
5454+func (r *HSMDeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
5555+ logger := log.FromContext(ctx)
5656+5757+ // Fetch the HSMDevice instance
5858+ var hsmDevice hsmv1alpha1.HSMDevice
5959+ if err := r.Get(ctx, req.NamespacedName, &hsmDevice); err != nil {
6060+ logger.Error(err, "Unable to fetch HSMDevice")
6161+ return ctrl.Result{}, client.IgnoreNotFound(err)
6262+ }
6363+6464+ // Check if this device should be discovered on this node
6565+ if !r.shouldDiscoverOnNode(&hsmDevice) {
6666+ logger.V(1).Info("Device discovery not required on this node")
6767+ return ctrl.Result{}, nil
6868+ }
6969+7070+ // Set initial phase if not set
7171+ if hsmDevice.Status.Phase == "" {
7272+ hsmDevice.Status.Phase = hsmv1alpha1.HSMDevicePhasePending
7373+ if err := r.Status().Update(ctx, &hsmDevice); err != nil {
7474+ return ctrl.Result{}, err
7575+ }
7676+ }
7777+7878+ // Start device discovery
7979+ return r.reconcileDeviceDiscovery(ctx, &hsmDevice)
8080+}
8181+8282+// reconcileDeviceDiscovery performs the actual device discovery
8383+func (r *HSMDeviceReconciler) reconcileDeviceDiscovery(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) (ctrl.Result, error) {
8484+ logger := log.FromContext(ctx).WithValues("deviceType", hsmDevice.Spec.DeviceType)
8585+8686+ // Update phase to discovering
8787+ if hsmDevice.Status.Phase != hsmv1alpha1.HSMDevicePhaseDiscovering {
8888+ hsmDevice.Status.Phase = hsmv1alpha1.HSMDevicePhaseDiscovering
8989+ if err := r.Status().Update(ctx, hsmDevice); err != nil {
9090+ return ctrl.Result{}, err
9191+ }
9292+ }
9393+9494+ var discoveredDevices []hsmv1alpha1.DiscoveredDevice
9595+ var err error
9696+9797+ // Perform discovery based on specification
9898+ if hsmDevice.Spec.USB != nil {
9999+ discoveredDevices, err = r.discoverUSBDevices(ctx, hsmDevice)
100100+ } else if hsmDevice.Spec.DevicePath != nil {
101101+ discoveredDevices, err = r.discoverPathDevices(ctx, hsmDevice)
102102+ } else {
103103+ // Auto-discovery based on device type
104104+ discoveredDevices, err = r.autoDiscoverDevices(ctx, hsmDevice)
105105+ }
106106+107107+ if err != nil {
108108+ logger.Error(err, "Device discovery failed")
109109+ return r.updateStatus(ctx, hsmDevice, hsmv1alpha1.HSMDevicePhaseError,
110110+ discoveredDevices, err.Error())
111111+ }
112112+113113+ logger.Info("Device discovery completed", "foundDevices", len(discoveredDevices))
114114+115115+ // Update status with discovered devices
116116+ phase := hsmv1alpha1.HSMDevicePhaseReady
117117+ if len(discoveredDevices) == 0 {
118118+ phase = hsmv1alpha1.HSMDevicePhasePending
119119+ }
120120+121121+ result, err := r.updateStatus(ctx, hsmDevice, phase, discoveredDevices, "")
122122+ if err != nil {
123123+ return result, err
124124+ }
125125+126126+ // Handle device mirroring if configured
127127+ if r.MirroringManager != nil && hsmDevice.Spec.Mirroring != nil &&
128128+ hsmDevice.Spec.Mirroring.Policy != hsmv1alpha1.MirroringPolicyNone {
129129+130130+ logger.Info("Starting device mirroring", "policy", hsmDevice.Spec.Mirroring.Policy)
131131+132132+ if err := r.MirroringManager.SyncDevices(ctx, hsmDevice); err != nil {
133133+ logger.Error(err, "Device mirroring failed")
134134+ // Don't fail the reconciliation, just log the error
135135+ // The mirroring will be retried on the next reconcile cycle
136136+ }
137137+ }
138138+139139+ return result, err
140140+}
141141+142142+// discoverUSBDevices discovers devices using USB specifications
143143+func (r *HSMDeviceReconciler) discoverUSBDevices(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) ([]hsmv1alpha1.DiscoveredDevice, error) {
144144+ logger := log.FromContext(ctx)
145145+146146+ if r.USBDiscoverer == nil {
147147+ return nil, fmt.Errorf("USB discoverer not available")
148148+ }
149149+150150+ usbDevices, err := r.USBDiscoverer.DiscoverDevices(ctx, hsmDevice.Spec.USB)
151151+ if err != nil {
152152+ return nil, fmt.Errorf("USB discovery failed: %w", err)
153153+ }
154154+155155+ var devices []hsmv1alpha1.DiscoveredDevice
156156+ for _, usbDev := range usbDevices {
157157+ device := hsmv1alpha1.DiscoveredDevice{
158158+ DevicePath: usbDev.DevicePath,
159159+ SerialNumber: usbDev.SerialNumber,
160160+ NodeName: r.NodeName,
161161+ LastSeen: metav1.Now(),
162162+ Available: true,
163163+ DeviceInfo: map[string]string{
164164+ "vendor-id": usbDev.VendorID,
165165+ "product-id": usbDev.ProductID,
166166+ "manufacturer": usbDev.Manufacturer,
167167+ "product": usbDev.Product,
168168+ "discovery-type": "usb",
169169+ },
170170+ }
171171+172172+ // Add additional device info
173173+ for k, v := range usbDev.DeviceInfo {
174174+ device.DeviceInfo[k] = v
175175+ }
176176+177177+ devices = append(devices, device)
178178+ }
179179+180180+ logger.V(1).Info("USB device discovery completed", "devicesFound", len(devices))
181181+ return devices, nil
182182+}
183183+184184+// discoverPathDevices discovers devices using path-based specifications
185185+func (r *HSMDeviceReconciler) discoverPathDevices(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) ([]hsmv1alpha1.DiscoveredDevice, error) {
186186+ logger := log.FromContext(ctx)
187187+188188+ if r.USBDiscoverer == nil {
189189+ return nil, fmt.Errorf("USB discoverer not available")
190190+ }
191191+192192+ usbDevices, err := r.USBDiscoverer.DiscoverByPath(ctx, hsmDevice.Spec.DevicePath)
193193+ if err != nil {
194194+ return nil, fmt.Errorf("path discovery failed: %w", err)
195195+ }
196196+197197+ var devices []hsmv1alpha1.DiscoveredDevice
198198+ for _, usbDev := range usbDevices {
199199+ device := hsmv1alpha1.DiscoveredDevice{
200200+ DevicePath: usbDev.DevicePath,
201201+ SerialNumber: usbDev.SerialNumber,
202202+ NodeName: r.NodeName,
203203+ LastSeen: metav1.Now(),
204204+ Available: true,
205205+ DeviceInfo: map[string]string{
206206+ "discovery-type": "path",
207207+ "path-pattern": hsmDevice.Spec.DevicePath.Path,
208208+ },
209209+ }
210210+211211+ // Add additional device info
212212+ for k, v := range usbDev.DeviceInfo {
213213+ device.DeviceInfo[k] = v
214214+ }
215215+216216+ devices = append(devices, device)
217217+ }
218218+219219+ logger.V(1).Info("Path device discovery completed", "devicesFound", len(devices))
220220+ return devices, nil
221221+}
222222+223223+// autoDiscoverDevices performs auto-discovery based on device type
224224+func (r *HSMDeviceReconciler) autoDiscoverDevices(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) ([]hsmv1alpha1.DiscoveredDevice, error) {
225225+ logger := log.FromContext(ctx)
226226+227227+ // Get well-known USB specs for device type
228228+ wellKnownSpecs := discovery.GetWellKnownHSMSpecs()
229229+ spec, exists := wellKnownSpecs[hsmDevice.Spec.DeviceType]
230230+231231+ if !exists {
232232+ return nil, fmt.Errorf("no well-known specification for device type %s", hsmDevice.Spec.DeviceType)
233233+ }
234234+235235+ logger.V(1).Info("Using well-known USB specification",
236236+ "deviceType", hsmDevice.Spec.DeviceType,
237237+ "vendorId", spec.VendorID,
238238+ "productId", spec.ProductID)
239239+240240+ // Use the well-known spec for discovery
241241+ tempDevice := *hsmDevice
242242+ tempDevice.Spec.USB = spec
243243+244244+ return r.discoverUSBDevices(ctx, &tempDevice)
245245+}
246246+247247+// shouldDiscoverOnNode determines if device discovery should run on this node
248248+func (r *HSMDeviceReconciler) shouldDiscoverOnNode(hsmDevice *hsmv1alpha1.HSMDevice) bool {
249249+ // If no node selector is specified, discover on all nodes
250250+ if len(hsmDevice.Spec.NodeSelector) == 0 {
251251+ return true
252252+ }
253253+254254+ // Check if this node matches the node selector
255255+ // This is a simplified check - in production, you'd want to fetch
256256+ // the actual node labels and compare them
257257+ nodeName := r.getNodeName()
258258+ for key, value := range hsmDevice.Spec.NodeSelector {
259259+ if key == "kubernetes.io/hostname" && value == nodeName {
260260+ return true
261261+ }
262262+ }
263263+264264+ return false
265265+}
266266+267267+// getNodeName returns the current node name
268268+func (r *HSMDeviceReconciler) getNodeName() string {
269269+ if r.NodeName != "" {
270270+ return r.NodeName
271271+ }
272272+273273+ // Try to get from environment
274274+ if nodeName := os.Getenv("NODE_NAME"); nodeName != "" {
275275+ r.NodeName = nodeName
276276+ return nodeName
277277+ }
278278+279279+ // Fallback to hostname
280280+ if hostname, err := os.Hostname(); err == nil {
281281+ r.NodeName = hostname
282282+ return hostname
283283+ }
284284+285285+ return "unknown"
286286+}
287287+288288+// updateStatus updates the HSMDevice status
289289+func (r *HSMDeviceReconciler) updateStatus(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice, phase hsmv1alpha1.HSMDevicePhase, devices []hsmv1alpha1.DiscoveredDevice, errorMsg string) (ctrl.Result, error) {
290290+ now := metav1.Now()
291291+292292+ // Update basic status fields
293293+ hsmDevice.Status.Phase = phase
294294+ hsmDevice.Status.LastDiscoveryTime = &now
295295+ hsmDevice.Status.DiscoveredDevices = devices
296296+ hsmDevice.Status.TotalDevices = int32(len(devices))
297297+298298+ // Count available devices
299299+ availableCount := int32(0)
300300+ for _, device := range devices {
301301+ if device.Available {
302302+ availableCount++
303303+ }
304304+ }
305305+ hsmDevice.Status.AvailableDevices = availableCount
306306+307307+ // Update conditions
308308+ conditionType := "DeviceDiscovery"
309309+ conditionStatus := metav1.ConditionTrue
310310+ reason := string(phase)
311311+ message := fmt.Sprintf("Discovered %d devices", len(devices))
312312+313313+ if phase == hsmv1alpha1.HSMDevicePhaseError {
314314+ conditionStatus = metav1.ConditionFalse
315315+ message = errorMsg
316316+ }
317317+318318+ condition := metav1.Condition{
319319+ Type: conditionType,
320320+ Status: conditionStatus,
321321+ LastTransitionTime: now,
322322+ Reason: reason,
323323+ Message: message,
324324+ }
325325+326326+ // Update or add condition
327327+ found := false
328328+ for i, cond := range hsmDevice.Status.Conditions {
329329+ if cond.Type == conditionType {
330330+ hsmDevice.Status.Conditions[i] = condition
331331+ found = true
332332+ break
333333+ }
334334+ }
335335+ if !found {
336336+ hsmDevice.Status.Conditions = append(hsmDevice.Status.Conditions, condition)
337337+ }
338338+339339+ // Update status
340340+ if err := r.Status().Update(ctx, hsmDevice); err != nil {
341341+ return ctrl.Result{}, err
342342+ }
343343+344344+ // Requeue for periodic discovery
345345+ return ctrl.Result{RequeueAfter: DefaultDiscoveryInterval}, nil
346346+}
347347+348348+// SetupWithManager sets up the controller with the Manager.
349349+func (r *HSMDeviceReconciler) SetupWithManager(mgr ctrl.Manager) error {
350350+ return ctrl.NewControllerManagedBy(mgr).
351351+ For(&hsmv1alpha1.HSMDevice{}).
352352+ Named("hsmdevice").
353353+ Complete(r)
354354+}
+84
internal/controller/hsmdevice_controller_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 controller
1818+1919+import (
2020+ "context"
2121+2222+ . "github.com/onsi/ginkgo/v2"
2323+ . "github.com/onsi/gomega"
2424+ "k8s.io/apimachinery/pkg/api/errors"
2525+ "k8s.io/apimachinery/pkg/types"
2626+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
2727+2828+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2929+3030+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3131+)
3232+3333+var _ = Describe("HSMDevice Controller", func() {
3434+ Context("When reconciling a resource", func() {
3535+ const resourceName = "test-resource"
3636+3737+ ctx := context.Background()
3838+3939+ typeNamespacedName := types.NamespacedName{
4040+ Name: resourceName,
4141+ Namespace: "default", // TODO(user):Modify as needed
4242+ }
4343+ hsmdevice := &hsmv1alpha1.HSMDevice{}
4444+4545+ BeforeEach(func() {
4646+ By("creating the custom resource for the Kind HSMDevice")
4747+ err := k8sClient.Get(ctx, typeNamespacedName, hsmdevice)
4848+ if err != nil && errors.IsNotFound(err) {
4949+ resource := &hsmv1alpha1.HSMDevice{
5050+ ObjectMeta: metav1.ObjectMeta{
5151+ Name: resourceName,
5252+ Namespace: "default",
5353+ },
5454+ // TODO(user): Specify other spec details if needed.
5555+ }
5656+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
5757+ }
5858+ })
5959+6060+ AfterEach(func() {
6161+ // TODO(user): Cleanup logic after each test, like removing the resource instance.
6262+ resource := &hsmv1alpha1.HSMDevice{}
6363+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
6464+ Expect(err).NotTo(HaveOccurred())
6565+6666+ By("Cleanup the specific resource instance HSMDevice")
6767+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
6868+ })
6969+ It("should successfully reconcile the resource", func() {
7070+ By("Reconciling the created resource")
7171+ controllerReconciler := &HSMDeviceReconciler{
7272+ Client: k8sClient,
7373+ Scheme: k8sClient.Scheme(),
7474+ }
7575+7676+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
7777+ NamespacedName: typeNamespacedName,
7878+ })
7979+ Expect(err).NotTo(HaveOccurred())
8080+ // TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
8181+ // Example: If you expect a certain status condition after reconciliation, verify it here.
8282+ })
8383+ })
8484+})
+379
internal/controller/hsmsecret_controller.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 controller
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "time"
2323+2424+ corev1 "k8s.io/api/core/v1"
2525+ "k8s.io/apimachinery/pkg/api/errors"
2626+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2727+ "k8s.io/apimachinery/pkg/runtime"
2828+ "k8s.io/apimachinery/pkg/types"
2929+ ctrl "sigs.k8s.io/controller-runtime"
3030+ "sigs.k8s.io/controller-runtime/pkg/client"
3131+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3232+ "sigs.k8s.io/controller-runtime/pkg/log"
3333+3434+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3535+ "github.com/evanjarrett/hsm-secrets-operator/internal/discovery"
3636+ "github.com/evanjarrett/hsm-secrets-operator/internal/hsm"
3737+)
3838+3939+const (
4040+ // HSMSecretFinalizer is the finalizer used by the HSMSecret controller
4141+ HSMSecretFinalizer = "hsmsecret.hsm.j5t.io/finalizer"
4242+4343+ // DefaultSyncInterval is the default sync interval in seconds
4444+ DefaultSyncInterval = 300
4545+)
4646+4747+// HSMSecretReconciler reconciles a HSMSecret object
4848+type HSMSecretReconciler struct {
4949+ client.Client
5050+ Scheme *runtime.Scheme
5151+ HSMClient hsm.Client
5252+ MirroringManager *discovery.MirroringManager
5353+}
5454+5555+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmsecrets,verbs=get;list;watch;create;update;patch;delete
5656+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmsecrets/status,verbs=get;update;patch
5757+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmsecrets/finalizers,verbs=update
5858+// +kubebuilder:rbac:groups=hsm.j5t.io,resources=hsmdevices,verbs=get;list;watch
5959+// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
6060+// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
6161+6262+// Reconcile handles HSMSecret reconciliation
6363+func (r *HSMSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
6464+ logger := log.FromContext(ctx)
6565+6666+ // Fetch the HSMSecret instance
6767+ var hsmSecret hsmv1alpha1.HSMSecret
6868+ if err := r.Get(ctx, req.NamespacedName, &hsmSecret); err != nil {
6969+ if errors.IsNotFound(err) {
7070+ logger.Info("HSMSecret resource not found, ignoring since object must be deleted")
7171+ return ctrl.Result{}, nil
7272+ }
7373+ logger.Error(err, "Failed to get HSMSecret")
7474+ return ctrl.Result{}, err
7575+ }
7676+7777+ // Check if HSM client is available
7878+ if r.HSMClient == nil || !r.HSMClient.IsConnected() {
7979+ logger.Error(fmt.Errorf("HSM client not available"), "HSM client not connected")
8080+ return ctrl.Result{RequeueAfter: time.Minute * 5}, nil
8181+ }
8282+8383+ // Handle deletion
8484+ if hsmSecret.DeletionTimestamp != nil {
8585+ return r.reconcileDelete(ctx, &hsmSecret)
8686+ }
8787+8888+ // Add finalizer if not present
8989+ if !controllerutil.ContainsFinalizer(&hsmSecret, HSMSecretFinalizer) {
9090+ controllerutil.AddFinalizer(&hsmSecret, HSMSecretFinalizer)
9191+ if err := r.Update(ctx, &hsmSecret); err != nil {
9292+ logger.Error(err, "Failed to add finalizer")
9393+ return ctrl.Result{}, err
9494+ }
9595+ return ctrl.Result{Requeue: true}, nil
9696+ }
9797+9898+ // Reconcile the HSMSecret
9999+ result, err := r.reconcileNormal(ctx, &hsmSecret)
100100+ if err != nil {
101101+ logger.Error(err, "Failed to reconcile HSMSecret")
102102+ r.updateStatus(ctx, &hsmSecret, hsmv1alpha1.SyncStatusError, err.Error())
103103+ }
104104+105105+ return result, err
106106+}
107107+108108+// reconcileNormal handles normal reconciliation logic
109109+func (r *HSMSecretReconciler) reconcileNormal(ctx context.Context, hsmSecret *hsmv1alpha1.HSMSecret) (ctrl.Result, error) {
110110+ logger := log.FromContext(ctx)
111111+112112+ // Set default values
113113+ secretName := hsmSecret.Spec.SecretName
114114+ if secretName == "" {
115115+ secretName = hsmSecret.Name
116116+ }
117117+118118+ syncInterval := hsmSecret.Spec.SyncInterval
119119+ if syncInterval == 0 {
120120+ syncInterval = DefaultSyncInterval
121121+ }
122122+123123+ // Read secret from HSM with readonly fallback support
124124+ hsmData, err := r.readSecretWithFallback(ctx, hsmSecret)
125125+ if err != nil {
126126+ logger.Error(err, "Failed to read secret from HSM and mirrors", "path", hsmSecret.Spec.HSMPath)
127127+ return ctrl.Result{RequeueAfter: time.Minute * 2}, err
128128+ }
129129+130130+ // Calculate HSM checksum
131131+ hsmChecksum := hsm.CalculateChecksum(hsmData)
132132+133133+ // Get or create Kubernetes Secret
134134+ var k8sSecret corev1.Secret
135135+ secretKey := types.NamespacedName{
136136+ Namespace: hsmSecret.Namespace,
137137+ Name: secretName,
138138+ }
139139+140140+ err = r.Get(ctx, secretKey, &k8sSecret)
141141+ if err != nil {
142142+ if errors.IsNotFound(err) {
143143+ // Create new secret
144144+ k8sSecret = r.buildSecret(hsmSecret, secretName, hsmData)
145145+ if err := r.Create(ctx, &k8sSecret); err != nil {
146146+ logger.Error(err, "Failed to create Secret")
147147+ return ctrl.Result{}, err
148148+ }
149149+ logger.Info("Created new Secret", "secret", secretKey)
150150+ } else {
151151+ logger.Error(err, "Failed to get Secret")
152152+ return ctrl.Result{}, err
153153+ }
154154+ } else {
155155+ // Update existing secret if needed
156156+ k8sSecret.Data = r.convertHSMDataToSecretData(hsmData)
157157+ if err := r.Update(ctx, &k8sSecret); err != nil {
158158+ logger.Error(err, "Failed to update Secret")
159159+ return ctrl.Result{}, err
160160+ }
161161+ logger.V(1).Info("Updated existing Secret", "secret", secretKey)
162162+ }
163163+164164+ // Calculate K8s Secret checksum
165165+ secretChecksum := hsm.CalculateChecksum(r.convertSecretDataToHSMData(k8sSecret.Data))
166166+167167+ // Update status
168168+ syncStatus := hsmv1alpha1.SyncStatusInSync
169169+ if hsmChecksum != secretChecksum {
170170+ syncStatus = hsmv1alpha1.SyncStatusOutOfSync
171171+ }
172172+173173+ r.updateStatus(ctx, hsmSecret, syncStatus, "")
174174+ hsmSecret.Status.HSMChecksum = hsmChecksum
175175+ hsmSecret.Status.SecretChecksum = secretChecksum
176176+ hsmSecret.Status.SecretRef = &corev1.ObjectReference{
177177+ APIVersion: "v1",
178178+ Kind: "Secret",
179179+ Name: k8sSecret.Name,
180180+ Namespace: k8sSecret.Namespace,
181181+ UID: k8sSecret.UID,
182182+ }
183183+184184+ if err := r.Status().Update(ctx, hsmSecret); err != nil {
185185+ logger.Error(err, "Failed to update HSMSecret status")
186186+ return ctrl.Result{}, err
187187+ }
188188+189189+ // Schedule next sync if AutoSync is enabled
190190+ if hsmSecret.Spec.AutoSync {
191191+ return ctrl.Result{RequeueAfter: time.Second * time.Duration(syncInterval)}, nil
192192+ }
193193+194194+ return ctrl.Result{}, nil
195195+}
196196+197197+// reconcileDelete handles HSMSecret deletion
198198+func (r *HSMSecretReconciler) reconcileDelete(ctx context.Context, hsmSecret *hsmv1alpha1.HSMSecret) (ctrl.Result, error) {
199199+ logger := log.FromContext(ctx)
200200+201201+ if controllerutil.ContainsFinalizer(hsmSecret, HSMSecretFinalizer) {
202202+ logger.Info("Cleaning up HSMSecret resources")
203203+204204+ // Optionally delete the Kubernetes Secret
205205+ secretName := hsmSecret.Spec.SecretName
206206+ if secretName == "" {
207207+ secretName = hsmSecret.Name
208208+ }
209209+210210+ secretKey := types.NamespacedName{
211211+ Namespace: hsmSecret.Namespace,
212212+ Name: secretName,
213213+ }
214214+215215+ var k8sSecret corev1.Secret
216216+ if err := r.Get(ctx, secretKey, &k8sSecret); err == nil {
217217+ if err := r.Delete(ctx, &k8sSecret); err != nil {
218218+ logger.Error(err, "Failed to delete associated Secret")
219219+ return ctrl.Result{}, err
220220+ }
221221+ logger.Info("Deleted associated Secret", "secret", secretKey)
222222+ }
223223+224224+ // Remove finalizer
225225+ controllerutil.RemoveFinalizer(hsmSecret, HSMSecretFinalizer)
226226+ if err := r.Update(ctx, hsmSecret); err != nil {
227227+ logger.Error(err, "Failed to remove finalizer")
228228+ return ctrl.Result{}, err
229229+ }
230230+ }
231231+232232+ return ctrl.Result{}, nil
233233+}
234234+235235+// buildSecret creates a new Kubernetes Secret from HSM data
236236+func (r *HSMSecretReconciler) buildSecret(hsmSecret *hsmv1alpha1.HSMSecret, secretName string, hsmData hsm.SecretData) corev1.Secret {
237237+ secretType := hsmSecret.Spec.SecretType
238238+ if secretType == "" {
239239+ secretType = corev1.SecretTypeOpaque
240240+ }
241241+242242+ secret := corev1.Secret{
243243+ ObjectMeta: metav1.ObjectMeta{
244244+ Name: secretName,
245245+ Namespace: hsmSecret.Namespace,
246246+ Labels: map[string]string{
247247+ "managed-by": "hsm-secrets-operator",
248248+ "hsm-path": hsmSecret.Spec.HSMPath,
249249+ },
250250+ },
251251+ Type: secretType,
252252+ Data: r.convertHSMDataToSecretData(hsmData),
253253+ }
254254+255255+ // Set owner reference
256256+ ctrl.SetControllerReference(hsmSecret, &secret, r.Scheme)
257257+258258+ return secret
259259+}
260260+261261+// convertHSMDataToSecretData converts HSM data format to Kubernetes Secret data format
262262+func (r *HSMSecretReconciler) convertHSMDataToSecretData(hsmData hsm.SecretData) map[string][]byte {
263263+ result := make(map[string][]byte)
264264+ for k, v := range hsmData {
265265+ result[k] = v
266266+ }
267267+ return result
268268+}
269269+270270+// convertSecretDataToHSMData converts Kubernetes Secret data format to HSM data format
271271+func (r *HSMSecretReconciler) convertSecretDataToHSMData(secretData map[string][]byte) hsm.SecretData {
272272+ result := make(hsm.SecretData)
273273+ for k, v := range secretData {
274274+ result[k] = v
275275+ }
276276+ return result
277277+}
278278+279279+// updateStatus updates the HSMSecret status
280280+func (r *HSMSecretReconciler) updateStatus(ctx context.Context, hsmSecret *hsmv1alpha1.HSMSecret, status hsmv1alpha1.SyncStatus, errorMsg string) {
281281+ now := metav1.Now()
282282+ hsmSecret.Status.SyncStatus = status
283283+ hsmSecret.Status.LastError = errorMsg
284284+285285+ if status == hsmv1alpha1.SyncStatusInSync {
286286+ hsmSecret.Status.LastSyncTime = &now
287287+ }
288288+289289+ // Update conditions
290290+ condition := metav1.Condition{
291291+ Type: "Ready",
292292+ Status: metav1.ConditionTrue,
293293+ LastTransitionTime: now,
294294+ Reason: string(status),
295295+ Message: errorMsg,
296296+ }
297297+298298+ if status == hsmv1alpha1.SyncStatusError {
299299+ condition.Status = metav1.ConditionFalse
300300+ }
301301+302302+ // Update or add condition
303303+ found := false
304304+ for i, cond := range hsmSecret.Status.Conditions {
305305+ if cond.Type == condition.Type {
306306+ hsmSecret.Status.Conditions[i] = condition
307307+ found = true
308308+ break
309309+ }
310310+ }
311311+ if !found {
312312+ hsmSecret.Status.Conditions = append(hsmSecret.Status.Conditions, condition)
313313+ }
314314+}
315315+316316+// readSecretWithFallback attempts to read a secret from primary HSM, falling back to mirrors if needed
317317+func (r *HSMSecretReconciler) readSecretWithFallback(ctx context.Context, hsmSecret *hsmv1alpha1.HSMSecret) (hsm.SecretData, error) {
318318+ logger := log.FromContext(ctx)
319319+320320+ // Try to read from primary HSM first
321321+ if r.HSMClient != nil && r.HSMClient.IsConnected() {
322322+ data, err := r.HSMClient.ReadSecret(ctx, hsmSecret.Spec.HSMPath)
323323+ if err == nil {
324324+ logger.V(1).Info("Successfully read secret from primary HSM", "path", hsmSecret.Spec.HSMPath)
325325+ return data, nil
326326+ }
327327+ logger.V(1).Info("Failed to read from primary HSM, attempting fallback", "error", err)
328328+ }
329329+330330+ // If primary failed and we have a mirroring manager, try readonly access from mirrors
331331+ if r.MirroringManager != nil {
332332+ // Find relevant HSMDevice for this secret path
333333+ hsmDevice, err := r.findHSMDeviceForSecret(ctx, hsmSecret)
334334+ if err != nil {
335335+ logger.Error(err, "Failed to find HSM device for readonly fallback")
336336+ } else if hsmDevice != nil {
337337+ data, err := r.MirroringManager.GetReadOnlyAccess(ctx, hsmSecret.Spec.HSMPath, hsmDevice)
338338+ if err == nil {
339339+ logger.Info("Successfully read secret from readonly mirror", "path", hsmSecret.Spec.HSMPath)
340340+ return data, nil
341341+ }
342342+ logger.V(1).Info("Failed to read from mirrors", "error", err)
343343+ }
344344+ }
345345+346346+ return nil, fmt.Errorf("secret not accessible from primary HSM or mirrors")
347347+}
348348+349349+// findHSMDeviceForSecret finds the HSMDevice that should contain the secret
350350+func (r *HSMSecretReconciler) findHSMDeviceForSecret(ctx context.Context, hsmSecret *hsmv1alpha1.HSMSecret) (*hsmv1alpha1.HSMDevice, error) {
351351+ // List all HSMDevices in the same namespace
352352+ var hsmDeviceList hsmv1alpha1.HSMDeviceList
353353+ if err := r.List(ctx, &hsmDeviceList, client.InNamespace(hsmSecret.Namespace)); err != nil {
354354+ return nil, fmt.Errorf("failed to list HSM devices: %w", err)
355355+ }
356356+357357+ // Look for devices that have mirroring enabled and are in a ready state
358358+ for _, device := range hsmDeviceList.Items {
359359+ if device.Spec.Mirroring != nil &&
360360+ device.Spec.Mirroring.Policy != hsmv1alpha1.MirroringPolicyNone &&
361361+ device.Status.Phase == hsmv1alpha1.HSMDevicePhaseReady &&
362362+ len(device.Status.DiscoveredDevices) > 0 {
363363+364364+ // This is a suitable device for readonly access
365365+ return &device, nil
366366+ }
367367+ }
368368+369369+ return nil, fmt.Errorf("no suitable HSM device found with mirroring enabled")
370370+}
371371+372372+// SetupWithManager sets up the controller with the Manager.
373373+func (r *HSMSecretReconciler) SetupWithManager(mgr ctrl.Manager) error {
374374+ return ctrl.NewControllerManagedBy(mgr).
375375+ For(&hsmv1alpha1.HSMSecret{}).
376376+ Owns(&corev1.Secret{}).
377377+ Named("hsmsecret").
378378+ Complete(r)
379379+}
+84
internal/controller/hsmsecret_controller_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 controller
1818+1919+import (
2020+ "context"
2121+2222+ . "github.com/onsi/ginkgo/v2"
2323+ . "github.com/onsi/gomega"
2424+ "k8s.io/apimachinery/pkg/api/errors"
2525+ "k8s.io/apimachinery/pkg/types"
2626+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
2727+2828+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2929+3030+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3131+)
3232+3333+var _ = Describe("HSMSecret Controller", func() {
3434+ Context("When reconciling a resource", func() {
3535+ const resourceName = "test-resource"
3636+3737+ ctx := context.Background()
3838+3939+ typeNamespacedName := types.NamespacedName{
4040+ Name: resourceName,
4141+ Namespace: "default", // TODO(user):Modify as needed
4242+ }
4343+ hsmsecret := &hsmv1alpha1.HSMSecret{}
4444+4545+ BeforeEach(func() {
4646+ By("creating the custom resource for the Kind HSMSecret")
4747+ err := k8sClient.Get(ctx, typeNamespacedName, hsmsecret)
4848+ if err != nil && errors.IsNotFound(err) {
4949+ resource := &hsmv1alpha1.HSMSecret{
5050+ ObjectMeta: metav1.ObjectMeta{
5151+ Name: resourceName,
5252+ Namespace: "default",
5353+ },
5454+ // TODO(user): Specify other spec details if needed.
5555+ }
5656+ Expect(k8sClient.Create(ctx, resource)).To(Succeed())
5757+ }
5858+ })
5959+6060+ AfterEach(func() {
6161+ // TODO(user): Cleanup logic after each test, like removing the resource instance.
6262+ resource := &hsmv1alpha1.HSMSecret{}
6363+ err := k8sClient.Get(ctx, typeNamespacedName, resource)
6464+ Expect(err).NotTo(HaveOccurred())
6565+6666+ By("Cleanup the specific resource instance HSMSecret")
6767+ Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
6868+ })
6969+ It("should successfully reconcile the resource", func() {
7070+ By("Reconciling the created resource")
7171+ controllerReconciler := &HSMSecretReconciler{
7272+ Client: k8sClient,
7373+ Scheme: k8sClient.Scheme(),
7474+ }
7575+7676+ _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
7777+ NamespacedName: typeNamespacedName,
7878+ })
7979+ Expect(err).NotTo(HaveOccurred())
8080+ // TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
8181+ // Example: If you expect a certain status condition after reconciliation, verify it here.
8282+ })
8383+ })
8484+})
+116
internal/controller/suite_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 controller
1818+1919+import (
2020+ "context"
2121+ "os"
2222+ "path/filepath"
2323+ "testing"
2424+2525+ . "github.com/onsi/ginkgo/v2"
2626+ . "github.com/onsi/gomega"
2727+2828+ "k8s.io/client-go/kubernetes/scheme"
2929+ "k8s.io/client-go/rest"
3030+ "sigs.k8s.io/controller-runtime/pkg/client"
3131+ "sigs.k8s.io/controller-runtime/pkg/envtest"
3232+ logf "sigs.k8s.io/controller-runtime/pkg/log"
3333+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
3434+3535+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3636+ // +kubebuilder:scaffold:imports
3737+)
3838+3939+// These tests use Ginkgo (BDD-style Go testing framework). Refer to
4040+// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
4141+4242+var (
4343+ ctx context.Context
4444+ cancel context.CancelFunc
4545+ testEnv *envtest.Environment
4646+ cfg *rest.Config
4747+ k8sClient client.Client
4848+)
4949+5050+func TestControllers(t *testing.T) {
5151+ RegisterFailHandler(Fail)
5252+5353+ RunSpecs(t, "Controller Suite")
5454+}
5555+5656+var _ = BeforeSuite(func() {
5757+ logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
5858+5959+ ctx, cancel = context.WithCancel(context.TODO())
6060+6161+ var err error
6262+ err = hsmv1alpha1.AddToScheme(scheme.Scheme)
6363+ Expect(err).NotTo(HaveOccurred())
6464+6565+ // +kubebuilder:scaffold:scheme
6666+6767+ By("bootstrapping test environment")
6868+ testEnv = &envtest.Environment{
6969+ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
7070+ ErrorIfCRDPathMissing: true,
7171+ }
7272+7373+ // Retrieve the first found binary directory to allow running tests from IDEs
7474+ if getFirstFoundEnvTestBinaryDir() != "" {
7575+ testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()
7676+ }
7777+7878+ // cfg is defined in this file globally.
7979+ cfg, err = testEnv.Start()
8080+ Expect(err).NotTo(HaveOccurred())
8181+ Expect(cfg).NotTo(BeNil())
8282+8383+ k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
8484+ Expect(err).NotTo(HaveOccurred())
8585+ Expect(k8sClient).NotTo(BeNil())
8686+})
8787+8888+var _ = AfterSuite(func() {
8989+ By("tearing down the test environment")
9090+ cancel()
9191+ err := testEnv.Stop()
9292+ Expect(err).NotTo(HaveOccurred())
9393+})
9494+9595+// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.
9696+// ENVTEST-based tests depend on specific binaries, usually located in paths set by
9797+// controller-runtime. When running tests directly (e.g., via an IDE) without using
9898+// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.
9999+//
100100+// This function streamlines the process by finding the required binaries, similar to
101101+// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are
102102+// properly set up, run 'make setup-envtest' beforehand.
103103+func getFirstFoundEnvTestBinaryDir() string {
104104+ basePath := filepath.Join("..", "..", "bin", "k8s")
105105+ entries, err := os.ReadDir(basePath)
106106+ if err != nil {
107107+ logf.Log.Error(err, "Failed to read directory", "path", basePath)
108108+ return ""
109109+ }
110110+ for _, entry := range entries {
111111+ if entry.IsDir() {
112112+ return filepath.Join(basePath, entry.Name())
113113+ }
114114+ }
115115+ return ""
116116+}
+151
internal/discovery/deviceplugin.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 discovery
1818+1919+import (
2020+ "fmt"
2121+ "strings"
2222+ "sync"
2323+2424+ "github.com/go-logr/logr"
2525+ ctrl "sigs.k8s.io/controller-runtime"
2626+2727+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
2828+)
2929+3030+const (
3131+ // ResourceNamePrefix is the prefix for HSM device resources
3232+ ResourceNamePrefix = "hsm.j5t.io"
3333+)
3434+3535+// Device represents a managed HSM device
3636+type Device struct {
3737+ ID string
3838+ DevicePath string
3939+ SerialNumber string
4040+ Available bool
4141+ NodeName string
4242+ DeviceInfo map[string]string
4343+}
4444+4545+// HSMDeviceManager manages HSM devices for Kubernetes integration
4646+type HSMDeviceManager struct {
4747+ logger logr.Logger
4848+ resourceName string
4949+ deviceType hsmv1alpha1.HSMDeviceType
5050+ devices map[string]*Device
5151+ devicesMutex sync.RWMutex
5252+}
5353+5454+// NewHSMDeviceManager creates a new HSM device manager
5555+func NewHSMDeviceManager(deviceType hsmv1alpha1.HSMDeviceType, resourceName string) *HSMDeviceManager {
5656+ return &HSMDeviceManager{
5757+ logger: ctrl.Log.WithName("hsm-device-manager").WithValues("deviceType", deviceType),
5858+ resourceName: resourceName,
5959+ deviceType: deviceType,
6060+ devices: make(map[string]*Device),
6161+ }
6262+}
6363+6464+// UpdateDevices updates the list of managed devices
6565+func (m *HSMDeviceManager) UpdateDevices(discoveredDevices []hsmv1alpha1.DiscoveredDevice) {
6666+ m.devicesMutex.Lock()
6767+ defer m.devicesMutex.Unlock()
6868+6969+ // Clear existing devices
7070+ m.devices = make(map[string]*Device)
7171+7272+ // Add discovered devices
7373+ for _, discovered := range discoveredDevices {
7474+ deviceID := m.generateDeviceID(discovered)
7575+7676+ m.devices[deviceID] = &Device{
7777+ ID: deviceID,
7878+ DevicePath: discovered.DevicePath,
7979+ SerialNumber: discovered.SerialNumber,
8080+ Available: discovered.Available,
8181+ NodeName: discovered.NodeName,
8282+ DeviceInfo: discovered.DeviceInfo,
8383+ }
8484+8585+ m.logger.V(1).Info("Updated device",
8686+ "deviceId", deviceID,
8787+ "path", discovered.DevicePath,
8888+ "available", discovered.Available)
8989+ }
9090+9191+ m.logger.Info("Updated device list", "deviceCount", len(m.devices))
9292+}
9393+9494+// GetAvailableDevices returns a list of available devices
9595+func (m *HSMDeviceManager) GetAvailableDevices() []*Device {
9696+ m.devicesMutex.RLock()
9797+ defer m.devicesMutex.RUnlock()
9898+9999+ var available []*Device
100100+ for _, device := range m.devices {
101101+ if device.Available {
102102+ available = append(available, device)
103103+ }
104104+ }
105105+106106+ return available
107107+}
108108+109109+// GetDevice returns a device by ID
110110+func (m *HSMDeviceManager) GetDevice(deviceID string) (*Device, bool) {
111111+ m.devicesMutex.RLock()
112112+ defer m.devicesMutex.RUnlock()
113113+114114+ device, exists := m.devices[deviceID]
115115+ return device, exists
116116+}
117117+118118+// GetDevicesForNode returns all devices for a specific node
119119+func (m *HSMDeviceManager) GetDevicesForNode(nodeName string) []*Device {
120120+ m.devicesMutex.RLock()
121121+ defer m.devicesMutex.RUnlock()
122122+123123+ var nodeDevices []*Device
124124+ for _, device := range m.devices {
125125+ if device.NodeName == nodeName {
126126+ nodeDevices = append(nodeDevices, device)
127127+ }
128128+ }
129129+130130+ return nodeDevices
131131+}
132132+133133+// GetResourceName returns the Kubernetes resource name for this device type
134134+func (m *HSMDeviceManager) GetResourceName() string {
135135+ return fmt.Sprintf("%s/%s", ResourceNamePrefix, strings.ToLower(string(m.deviceType)))
136136+}
137137+138138+// generateDeviceID generates a unique device ID
139139+func (m *HSMDeviceManager) generateDeviceID(device hsmv1alpha1.DiscoveredDevice) string {
140140+ // Create a unique ID based on node name, device path, and serial
141141+ parts := []string{
142142+ device.NodeName,
143143+ strings.ReplaceAll(device.DevicePath, "/", "_"),
144144+ }
145145+146146+ if device.SerialNumber != "" {
147147+ parts = append(parts, device.SerialNumber)
148148+ }
149149+150150+ return strings.Join(parts, "-")
151151+}
+426
internal/discovery/mirroring.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 discovery
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "sort"
2323+ "sync"
2424+ "time"
2525+2626+ "github.com/go-logr/logr"
2727+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2828+ ctrl "sigs.k8s.io/controller-runtime"
2929+ "sigs.k8s.io/controller-runtime/pkg/client"
3030+3131+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3232+ "github.com/evanjarrett/hsm-secrets-operator/internal/hsm"
3333+)
3434+3535+// MirroredSecretData represents secret data with metadata for mirroring
3636+type MirroredSecretData struct {
3737+ Path string `json:"path"`
3838+ Data hsm.SecretData `json:"data"`
3939+ Checksum string `json:"checksum"`
4040+ LastModified time.Time `json:"lastModified"`
4141+ SourceNode string `json:"sourceNode"`
4242+ Metadata map[string]string `json:"metadata"`
4343+}
4444+4545+// MirroringManager handles HSM device mirroring and cross-node synchronization
4646+type MirroringManager struct {
4747+ client client.Client
4848+ logger logr.Logger
4949+ mutex sync.RWMutex
5050+ hsmClients map[string]hsm.Client
5151+ mirrorCache map[string]*MirroredSecretData
5252+ nodeHealth map[string]time.Time
5353+ currentNode string
5454+}
5555+5656+// NewMirroringManager creates a new mirroring manager
5757+func NewMirroringManager(client client.Client, nodeName string) *MirroringManager {
5858+ return &MirroringManager{
5959+ client: client,
6060+ logger: ctrl.Log.WithName("hsm-mirroring-manager"),
6161+ hsmClients: make(map[string]hsm.Client),
6262+ mirrorCache: make(map[string]*MirroredSecretData),
6363+ nodeHealth: make(map[string]time.Time),
6464+ currentNode: nodeName,
6565+ }
6666+}
6767+6868+// RegisterHSMClient registers an HSM client for a specific node
6969+func (m *MirroringManager) RegisterHSMClient(nodeName string, client hsm.Client) {
7070+ m.mutex.Lock()
7171+ defer m.mutex.Unlock()
7272+7373+ m.hsmClients[nodeName] = client
7474+ m.nodeHealth[nodeName] = time.Now()
7575+ m.logger.Info("Registered HSM client for node", "node", nodeName)
7676+}
7777+7878+// SyncDevices synchronizes secrets across mirrored HSM devices
7979+func (m *MirroringManager) SyncDevices(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) error {
8080+ if hsmDevice.Spec.Mirroring == nil || hsmDevice.Spec.Mirroring.Policy == hsmv1alpha1.MirroringPolicyNone {
8181+ return nil
8282+ }
8383+8484+ m.logger.Info("Starting device synchronization",
8585+ "device", hsmDevice.Name,
8686+ "policy", hsmDevice.Spec.Mirroring.Policy)
8787+8888+ // Determine primary and mirror nodes
8989+ primaryNode, mirrorNodes, err := m.determineMirrorTopology(ctx, hsmDevice)
9090+ if err != nil {
9191+ return fmt.Errorf("failed to determine mirror topology: %w", err)
9292+ }
9393+9494+ // Sync secrets from primary to mirrors
9595+ if err := m.syncFromPrimary(ctx, hsmDevice, primaryNode, mirrorNodes); err != nil {
9696+ return fmt.Errorf("failed to sync from primary: %w", err)
9797+ }
9898+9999+ // Update mirroring status
100100+ if err := m.updateMirroringStatus(ctx, hsmDevice, primaryNode, mirrorNodes); err != nil {
101101+ return fmt.Errorf("failed to update mirroring status: %w", err)
102102+ }
103103+104104+ return nil
105105+}
106106+107107+// determineMirrorTopology determines which nodes should be primary vs mirrors
108108+func (m *MirroringManager) determineMirrorTopology(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) (string, []string, error) {
109109+ availableNodes := make([]string, 0)
110110+111111+ // Collect nodes with available devices
112112+ for _, device := range hsmDevice.Status.DiscoveredDevices {
113113+ if device.Available && device.Health != "Unhealthy" {
114114+ availableNodes = append(availableNodes, device.NodeName)
115115+ }
116116+ }
117117+118118+ if len(availableNodes) == 0 {
119119+ return "", nil, fmt.Errorf("no healthy devices available for mirroring")
120120+ }
121121+122122+ // Determine primary node
123123+ primaryNode := ""
124124+ if hsmDevice.Spec.Mirroring.PrimaryNode != "" {
125125+ // Use specified primary if available and healthy
126126+ for _, node := range availableNodes {
127127+ if node == hsmDevice.Spec.Mirroring.PrimaryNode {
128128+ primaryNode = node
129129+ break
130130+ }
131131+ }
132132+ }
133133+134134+ if primaryNode == "" {
135135+ // Choose primary based on health and availability
136136+ sort.Strings(availableNodes) // Deterministic selection
137137+ primaryNode = availableNodes[0]
138138+ }
139139+140140+ // Determine mirror nodes
141141+ mirrorNodes := make([]string, 0)
142142+ for _, node := range availableNodes {
143143+ if node != primaryNode {
144144+ // Check if node should be a mirror target
145145+ if len(hsmDevice.Spec.Mirroring.TargetNodes) == 0 {
146146+ // Mirror to all available nodes if no targets specified
147147+ mirrorNodes = append(mirrorNodes, node)
148148+ } else {
149149+ // Only mirror to specified target nodes
150150+ for _, target := range hsmDevice.Spec.Mirroring.TargetNodes {
151151+ if node == target {
152152+ mirrorNodes = append(mirrorNodes, node)
153153+ break
154154+ }
155155+ }
156156+ }
157157+ }
158158+ }
159159+160160+ m.logger.V(1).Info("Determined mirror topology",
161161+ "primary", primaryNode,
162162+ "mirrors", mirrorNodes)
163163+164164+ return primaryNode, mirrorNodes, nil
165165+}
166166+167167+// syncFromPrimary synchronizes secrets from the primary node to mirror nodes
168168+func (m *MirroringManager) syncFromPrimary(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice, primaryNode string, mirrorNodes []string) error {
169169+ m.mutex.RLock()
170170+ primaryClient, exists := m.hsmClients[primaryNode]
171171+ m.mutex.RUnlock()
172172+173173+ if !exists || !primaryClient.IsConnected() {
174174+ return fmt.Errorf("primary HSM client not available on node %s", primaryNode)
175175+ }
176176+177177+ // List all secrets on the primary device
178178+ secrets, err := m.listSecretsFromHSM(ctx, primaryClient, hsmDevice)
179179+ if err != nil {
180180+ return fmt.Errorf("failed to list secrets from primary: %w", err)
181181+ }
182182+183183+ m.logger.Info("Found secrets on primary", "count", len(secrets), "primary", primaryNode)
184184+185185+ // Sync each secret to mirror nodes
186186+ for _, secretPath := range secrets {
187187+ if err := m.syncSecretToMirrors(ctx, secretPath, primaryClient, primaryNode, mirrorNodes); err != nil {
188188+ m.logger.Error(err, "Failed to sync secret to mirrors",
189189+ "secret", secretPath, "primary", primaryNode)
190190+ continue
191191+ }
192192+ }
193193+194194+ return nil
195195+}
196196+197197+// syncSecretToMirrors syncs a single secret to all mirror nodes
198198+func (m *MirroringManager) syncSecretToMirrors(ctx context.Context, secretPath string, primaryClient hsm.Client, primaryNode string, mirrorNodes []string) error {
199199+ // Read secret from primary
200200+ secretData, err := primaryClient.ReadSecret(ctx, secretPath)
201201+ if err != nil {
202202+ return fmt.Errorf("failed to read secret from primary: %w", err)
203203+ }
204204+205205+ // Calculate checksum
206206+ checksum := hsm.CalculateChecksum(secretData)
207207+208208+ // Create mirrored data entry
209209+ mirroredData := &MirroredSecretData{
210210+ Path: secretPath,
211211+ Data: secretData,
212212+ Checksum: checksum,
213213+ LastModified: time.Now(),
214214+ SourceNode: primaryNode,
215215+ Metadata: map[string]string{
216216+ "source-node": primaryNode,
217217+ "sync-time": time.Now().Format(time.RFC3339),
218218+ },
219219+ }
220220+221221+ // Update cache
222222+ m.mutex.Lock()
223223+ m.mirrorCache[secretPath] = mirroredData
224224+ m.mutex.Unlock()
225225+226226+ // Sync to mirror nodes (readonly)
227227+ for _, mirrorNode := range mirrorNodes {
228228+ if err := m.syncToMirrorNode(ctx, mirroredData, mirrorNode); err != nil {
229229+ m.logger.Error(err, "Failed to sync to mirror node",
230230+ "secret", secretPath, "mirror", mirrorNode)
231231+ }
232232+ }
233233+234234+ return nil
235235+}
236236+237237+// syncToMirrorNode syncs secret data to a specific mirror node
238238+func (m *MirroringManager) syncToMirrorNode(ctx context.Context, data *MirroredSecretData, mirrorNode string) error {
239239+ m.mutex.RLock()
240240+ mirrorClient, exists := m.hsmClients[mirrorNode]
241241+ m.mutex.RUnlock()
242242+243243+ if !exists || !mirrorClient.IsConnected() {
244244+ return fmt.Errorf("mirror HSM client not available on node %s", mirrorNode)
245245+ }
246246+247247+ // For readonly mirrors, we store the secret data in a readonly format
248248+ // In a real implementation, this might involve writing to a readonly partition
249249+ // or using HSM-specific mirroring capabilities
250250+251251+ // Check if secret already exists and is up to date
252252+ existingChecksum, err := mirrorClient.GetChecksum(ctx, data.Path)
253253+ if err == nil && existingChecksum == data.Checksum {
254254+ // Secret is already up to date
255255+ m.logger.V(2).Info("Secret already up to date on mirror",
256256+ "secret", data.Path, "mirror", mirrorNode)
257257+ return nil
258258+ }
259259+260260+ // Write secret to mirror (readonly)
261261+ if err := mirrorClient.WriteSecret(ctx, data.Path, data.Data); err != nil {
262262+ return fmt.Errorf("failed to write secret to mirror: %w", err)
263263+ }
264264+265265+ m.logger.V(1).Info("Successfully synced secret to mirror",
266266+ "secret", data.Path, "mirror", mirrorNode)
267267+268268+ return nil
269269+}
270270+271271+// listSecretsFromHSM lists all secrets from an HSM client
272272+func (m *MirroringManager) listSecretsFromHSM(ctx context.Context, client hsm.Client, hsmDevice *hsmv1alpha1.HSMDevice) ([]string, error) {
273273+ // This is a simplified implementation
274274+ // In a real implementation, you would use HSM-specific APIs to list secrets
275275+276276+ var secrets []string
277277+278278+ // For demo purposes, we'll simulate some secret paths
279279+ // In reality, this would query the HSM for all available secret paths
280280+ basePaths := []string{
281281+ "secrets/default/database-credentials",
282282+ "secrets/production/api-keys",
283283+ "secrets/staging/certificates",
284284+ }
285285+286286+ for _, path := range basePaths {
287287+ // Check if secret exists
288288+ if _, err := client.ReadSecret(ctx, path); err == nil {
289289+ secrets = append(secrets, path)
290290+ }
291291+ }
292292+293293+ return secrets, nil
294294+}
295295+296296+// GetReadOnlyAccess provides readonly access to secrets from mirrors when primary is down
297297+func (m *MirroringManager) GetReadOnlyAccess(ctx context.Context, secretPath string, hsmDevice *hsmv1alpha1.HSMDevice) (hsm.SecretData, error) {
298298+ m.logger.Info("Attempting readonly access", "secret", secretPath)
299299+300300+ // Check cache first
301301+ m.mutex.RLock()
302302+ cachedData, exists := m.mirrorCache[secretPath]
303303+ m.mutex.RUnlock()
304304+305305+ if exists && time.Since(cachedData.LastModified) < time.Hour {
306306+ m.logger.V(1).Info("Using cached mirror data", "secret", secretPath)
307307+ return cachedData.Data, nil
308308+ }
309309+310310+ // Try to read from available mirror nodes
311311+ for _, device := range hsmDevice.Status.DiscoveredDevices {
312312+ if device.Role == hsmv1alpha1.DeviceRoleReadOnly && device.Available {
313313+ m.mutex.RLock()
314314+ mirrorClient, exists := m.hsmClients[device.NodeName]
315315+ m.mutex.RUnlock()
316316+317317+ if exists && mirrorClient.IsConnected() {
318318+ if data, err := mirrorClient.ReadSecret(ctx, secretPath); err == nil {
319319+ m.logger.Info("Successfully read from mirror",
320320+ "secret", secretPath, "mirror", device.NodeName)
321321+ return data, nil
322322+ }
323323+ }
324324+ }
325325+ }
326326+327327+ return nil, fmt.Errorf("no readable mirrors available for secret %s", secretPath)
328328+}
329329+330330+// HandleFailover handles failover from a failed primary to a healthy mirror
331331+func (m *MirroringManager) HandleFailover(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice) error {
332332+ if hsmDevice.Spec.Mirroring == nil || !hsmDevice.Spec.Mirroring.AutoFailover {
333333+ return nil
334334+ }
335335+336336+ m.logger.Info("Handling device failover", "device", hsmDevice.Name)
337337+338338+ // Find a healthy mirror to promote to primary
339339+ var newPrimary string
340340+ for _, device := range hsmDevice.Status.DiscoveredDevices {
341341+ if device.Role == hsmv1alpha1.DeviceRoleReadOnly && device.Available {
342342+ newPrimary = device.NodeName
343343+ break
344344+ }
345345+ }
346346+347347+ if newPrimary == "" {
348348+ return fmt.Errorf("no healthy mirrors available for failover")
349349+ }
350350+351351+ // Update device roles
352352+ for i, device := range hsmDevice.Status.DiscoveredDevices {
353353+ if device.NodeName == newPrimary {
354354+ hsmDevice.Status.DiscoveredDevices[i].Role = hsmv1alpha1.DeviceRolePrimary
355355+ }
356356+ }
357357+358358+ // Update mirroring status
359359+ if hsmDevice.Status.Mirroring != nil {
360360+ hsmDevice.Status.Mirroring.PrimaryNode = newPrimary
361361+ hsmDevice.Status.Mirroring.FailoverCount++
362362+ }
363363+364364+ // Update the HSMDevice status
365365+ if err := m.client.Status().Update(ctx, hsmDevice); err != nil {
366366+ return fmt.Errorf("failed to update device status after failover: %w", err)
367367+ }
368368+369369+ m.logger.Info("Successfully failed over to new primary",
370370+ "device", hsmDevice.Name, "newPrimary", newPrimary)
371371+372372+ return nil
373373+}
374374+375375+// updateMirroringStatus updates the mirroring status in the HSMDevice
376376+func (m *MirroringManager) updateMirroringStatus(ctx context.Context, hsmDevice *hsmv1alpha1.HSMDevice, primaryNode string, mirrorNodes []string) error {
377377+ now := metav1.Now()
378378+379379+ if hsmDevice.Status.Mirroring == nil {
380380+ hsmDevice.Status.Mirroring = &hsmv1alpha1.MirroringStatus{}
381381+ }
382382+383383+ hsmDevice.Status.Mirroring.Enabled = true
384384+ hsmDevice.Status.Mirroring.PrimaryNode = primaryNode
385385+ hsmDevice.Status.Mirroring.MirroredNodes = mirrorNodes
386386+ hsmDevice.Status.Mirroring.LastSyncTime = &now
387387+388388+ // Update device roles
389389+ for i, device := range hsmDevice.Status.DiscoveredDevices {
390390+ if device.NodeName == primaryNode {
391391+ hsmDevice.Status.DiscoveredDevices[i].Role = hsmv1alpha1.DeviceRolePrimary
392392+ } else {
393393+ for _, mirrorNode := range mirrorNodes {
394394+ if device.NodeName == mirrorNode {
395395+ hsmDevice.Status.DiscoveredDevices[i].Role = hsmv1alpha1.DeviceRoleReadOnly
396396+ hsmDevice.Status.DiscoveredDevices[i].MirroredFrom = primaryNode
397397+ hsmDevice.Status.DiscoveredDevices[i].LastSyncTime = &now
398398+ break
399399+ }
400400+ }
401401+ }
402402+ }
403403+404404+ return nil
405405+}
406406+407407+// IsNodeHealthy checks if a node is healthy based on last seen time
408408+func (m *MirroringManager) IsNodeHealthy(nodeName string, maxAge time.Duration) bool {
409409+ m.mutex.RLock()
410410+ defer m.mutex.RUnlock()
411411+412412+ lastSeen, exists := m.nodeHealth[nodeName]
413413+ if !exists {
414414+ return false
415415+ }
416416+417417+ return time.Since(lastSeen) <= maxAge
418418+}
419419+420420+// UpdateNodeHealth updates the health status of a node
421421+func (m *MirroringManager) UpdateNodeHealth(nodeName string) {
422422+ m.mutex.Lock()
423423+ defer m.mutex.Unlock()
424424+425425+ m.nodeHealth[nodeName] = time.Now()
426426+}
+336
internal/discovery/usb.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 discovery
1818+1919+import (
2020+ "bufio"
2121+ "context"
2222+ "fmt"
2323+ "io/fs"
2424+ "os"
2525+ "path/filepath"
2626+ "regexp"
2727+ "strings"
2828+ "sync"
2929+3030+ "github.com/go-logr/logr"
3131+ ctrl "sigs.k8s.io/controller-runtime"
3232+3333+ hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
3434+)
3535+3636+// USBDevice represents a discovered USB device
3737+type USBDevice struct {
3838+ VendorID string
3939+ ProductID string
4040+ SerialNumber string
4141+ DevicePath string
4242+ Manufacturer string
4343+ Product string
4444+ DeviceInfo map[string]string
4545+}
4646+4747+// USBDiscoverer handles USB device discovery
4848+type USBDiscoverer struct {
4949+ logger logr.Logger
5050+ mutex sync.RWMutex
5151+5252+ // Known USB paths to scan
5353+ usbSysPaths []string
5454+ devicePaths []string
5555+}
5656+5757+// NewUSBDiscoverer creates a new USB device discoverer
5858+func NewUSBDiscoverer() *USBDiscoverer {
5959+ return &USBDiscoverer{
6060+ logger: ctrl.Log.WithName("usb-discoverer"),
6161+ usbSysPaths: []string{
6262+ "/sys/bus/usb/devices",
6363+ "/sys/class/usbmisc",
6464+ },
6565+ devicePaths: []string{
6666+ "/dev",
6767+ },
6868+ }
6969+}
7070+7171+// DiscoverDevices finds USB devices matching the given specification
7272+func (u *USBDiscoverer) DiscoverDevices(ctx context.Context, spec *hsmv1alpha1.USBDeviceSpec) ([]USBDevice, error) {
7373+ u.logger.V(1).Info("Starting USB device discovery",
7474+ "vendorId", spec.VendorID,
7575+ "productId", spec.ProductID)
7676+7777+ var devices []USBDevice
7878+7979+ // Scan USB devices in sysfs
8080+ usbDevices, err := u.scanUSBDevices(ctx)
8181+ if err != nil {
8282+ return nil, fmt.Errorf("failed to scan USB devices: %w", err)
8383+ }
8484+8585+ // Filter devices based on spec
8686+ for _, device := range usbDevices {
8787+ if u.matchesSpec(device, spec) {
8888+ u.logger.V(1).Info("Found matching USB device",
8989+ "vendorId", device.VendorID,
9090+ "productId", device.ProductID,
9191+ "serial", device.SerialNumber,
9292+ "path", device.DevicePath)
9393+ devices = append(devices, device)
9494+ }
9595+ }
9696+9797+ u.logger.Info("USB device discovery completed",
9898+ "matchedDevices", len(devices))
9999+100100+ return devices, nil
101101+}
102102+103103+// DiscoverByPath finds devices using path-based discovery
104104+func (u *USBDiscoverer) DiscoverByPath(ctx context.Context, pathSpec *hsmv1alpha1.DevicePathSpec) ([]USBDevice, error) {
105105+ u.logger.V(1).Info("Starting path-based device discovery", "path", pathSpec.Path)
106106+107107+ var devices []USBDevice
108108+109109+ // Handle glob patterns
110110+ matches, err := filepath.Glob(pathSpec.Path)
111111+ if err != nil {
112112+ return nil, fmt.Errorf("failed to glob path %s: %w", pathSpec.Path, err)
113113+ }
114114+115115+ for _, match := range matches {
116116+ // Check if the path exists and is accessible
117117+ if _, err := os.Stat(match); err != nil {
118118+ u.logger.V(2).Info("Skipping inaccessible device path", "path", match, "error", err)
119119+ continue
120120+ }
121121+122122+ // Create USB device entry for path-based discovery
123123+ device := USBDevice{
124124+ DevicePath: match,
125125+ DeviceInfo: map[string]string{
126126+ "discovery-method": "path",
127127+ "permissions": pathSpec.Permissions,
128128+ },
129129+ }
130130+131131+ // Try to get additional device info if possible
132132+ if info := u.getDeviceInfoFromPath(match); info != nil {
133133+ device.VendorID = info["vendor_id"]
134134+ device.ProductID = info["product_id"]
135135+ device.SerialNumber = info["serial"]
136136+ device.Manufacturer = info["manufacturer"]
137137+ device.Product = info["product"]
138138+ for k, v := range info {
139139+ device.DeviceInfo[k] = v
140140+ }
141141+ }
142142+143143+ devices = append(devices, device)
144144+ }
145145+146146+ u.logger.Info("Path-based device discovery completed",
147147+ "matchedDevices", len(devices))
148148+149149+ return devices, nil
150150+}
151151+152152+// scanUSBDevices scans the USB subsystem for devices
153153+func (u *USBDiscoverer) scanUSBDevices(ctx context.Context) ([]USBDevice, error) {
154154+ var devices []USBDevice
155155+156156+ usbSysPath := "/sys/bus/usb/devices"
157157+ if _, err := os.Stat(usbSysPath); err != nil {
158158+ u.logger.V(1).Info("USB sysfs path not available", "path", usbSysPath)
159159+ return devices, nil
160160+ }
161161+162162+ err := filepath.WalkDir(usbSysPath, func(path string, d fs.DirEntry, err error) error {
163163+ if err != nil {
164164+ return err
165165+ }
166166+167167+ // Skip if not a directory or if it doesn't look like a USB device
168168+ if !d.IsDir() {
169169+ return nil
170170+ }
171171+172172+ // Check if this is a USB device directory (e.g., 1-1.2)
173173+ name := d.Name()
174174+ if !regexp.MustCompile(`^\d+-[\d.]+$`).MatchString(name) {
175175+ return nil
176176+ }
177177+178178+ device, err := u.parseUSBDevice(path)
179179+ if err != nil {
180180+ u.logger.V(2).Info("Failed to parse USB device", "path", path, "error", err)
181181+ return nil
182182+ }
183183+184184+ if device != nil {
185185+ devices = append(devices, *device)
186186+ }
187187+188188+ return nil
189189+ })
190190+191191+ if err != nil {
192192+ return nil, fmt.Errorf("failed to walk USB devices: %w", err)
193193+ }
194194+195195+ return devices, nil
196196+}
197197+198198+// parseUSBDevice parses USB device information from sysfs
199199+func (u *USBDiscoverer) parseUSBDevice(devicePath string) (*USBDevice, error) {
200200+ device := &USBDevice{
201201+ DeviceInfo: make(map[string]string),
202202+ }
203203+204204+ // Read vendor ID
205205+ if vendorID, err := u.readSysfsFile(filepath.Join(devicePath, "idVendor")); err == nil {
206206+ device.VendorID = strings.TrimSpace(vendorID)
207207+ }
208208+209209+ // Read product ID
210210+ if productID, err := u.readSysfsFile(filepath.Join(devicePath, "idProduct")); err == nil {
211211+ device.ProductID = strings.TrimSpace(productID)
212212+ }
213213+214214+ // Read serial number
215215+ if serial, err := u.readSysfsFile(filepath.Join(devicePath, "serial")); err == nil {
216216+ device.SerialNumber = strings.TrimSpace(serial)
217217+ }
218218+219219+ // Read manufacturer
220220+ if manufacturer, err := u.readSysfsFile(filepath.Join(devicePath, "manufacturer")); err == nil {
221221+ device.Manufacturer = strings.TrimSpace(manufacturer)
222222+ }
223223+224224+ // Read product name
225225+ if product, err := u.readSysfsFile(filepath.Join(devicePath, "product")); err == nil {
226226+ device.Product = strings.TrimSpace(product)
227227+ }
228228+229229+ // Skip devices without vendor/product IDs
230230+ if device.VendorID == "" || device.ProductID == "" {
231231+ return nil, nil
232232+ }
233233+234234+ // Try to find associated device paths
235235+ device.DevicePath = u.findDevicePaths(device.VendorID, device.ProductID, device.SerialNumber)
236236+237237+ // Add additional device info
238238+ device.DeviceInfo["sysfs-path"] = devicePath
239239+ device.DeviceInfo["discovery-method"] = "usb"
240240+241241+ return device, nil
242242+}
243243+244244+// findDevicePaths attempts to find device paths for a USB device
245245+func (u *USBDiscoverer) findDevicePaths(vendorID, productID, serial string) string {
246246+ // This is a simplified implementation
247247+ // In a real implementation, you'd want to scan /dev and match devices
248248+ // For now, we'll look for common HSM device paths
249249+250250+ commonPaths := []string{
251251+ "/dev/ttyUSB0",
252252+ "/dev/ttyUSB1",
253253+ "/dev/ttyACM0",
254254+ "/dev/ttyACM1",
255255+ "/dev/sc-hsm",
256256+ "/dev/pkcs11",
257257+ }
258258+259259+ for _, path := range commonPaths {
260260+ if _, err := os.Stat(path); err == nil {
261261+ // In a real implementation, you'd verify this device matches
262262+ // the USB device we found
263263+ return path
264264+ }
265265+ }
266266+267267+ return ""
268268+}
269269+270270+// readSysfsFile reads a single-line file from sysfs
271271+func (u *USBDiscoverer) readSysfsFile(path string) (string, error) {
272272+ file, err := os.Open(path)
273273+ if err != nil {
274274+ return "", err
275275+ }
276276+ defer file.Close()
277277+278278+ scanner := bufio.NewScanner(file)
279279+ if scanner.Scan() {
280280+ return scanner.Text(), nil
281281+ }
282282+283283+ return "", fmt.Errorf("empty file or read error")
284284+}
285285+286286+// getDeviceInfoFromPath attempts to get device info from a device path
287287+func (u *USBDiscoverer) getDeviceInfoFromPath(devicePath string) map[string]string {
288288+ // This is a placeholder implementation
289289+ // In a real implementation, you'd use udev or similar to get device info
290290+ info := make(map[string]string)
291291+292292+ // Try to determine device type from path
293293+ if strings.Contains(devicePath, "ttyUSB") || strings.Contains(devicePath, "ttyACM") {
294294+ info["device_type"] = "serial"
295295+ } else if strings.Contains(devicePath, "sc-hsm") {
296296+ info["device_type"] = "hsm"
297297+ info["vendor_id"] = "20a0" // Example: Pico HSM vendor ID
298298+ info["product_id"] = "4230" // Example: Pico HSM product ID
299299+ }
300300+301301+ return info
302302+}
303303+304304+// matchesSpec checks if a USB device matches the given specification
305305+func (u *USBDiscoverer) matchesSpec(device USBDevice, spec *hsmv1alpha1.USBDeviceSpec) bool {
306306+ // Check vendor ID
307307+ if spec.VendorID != "" && !strings.EqualFold(device.VendorID, spec.VendorID) {
308308+ return false
309309+ }
310310+311311+ // Check product ID
312312+ if spec.ProductID != "" && !strings.EqualFold(device.ProductID, spec.ProductID) {
313313+ return false
314314+ }
315315+316316+ // Check serial number if specified
317317+ if spec.SerialNumber != "" && device.SerialNumber != spec.SerialNumber {
318318+ return false
319319+ }
320320+321321+ return true
322322+}
323323+324324+// GetWellKnownHSMSpecs returns USB specifications for well-known HSM devices
325325+func GetWellKnownHSMSpecs() map[hsmv1alpha1.HSMDeviceType]*hsmv1alpha1.USBDeviceSpec {
326326+ return map[hsmv1alpha1.HSMDeviceType]*hsmv1alpha1.USBDeviceSpec{
327327+ hsmv1alpha1.HSMDeviceTypePicoHSM: {
328328+ VendorID: "20a0", // Pico HSM vendor ID
329329+ ProductID: "4230", // Pico HSM product ID
330330+ },
331331+ hsmv1alpha1.HSMDeviceTypeSmartCardHSM: {
332332+ VendorID: "04e6", // Example SmartCard-HSM vendor ID
333333+ ProductID: "5816", // Example SmartCard-HSM product ID
334334+ },
335335+ }
336336+}
+128
internal/hsm/client.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 hsm
1818+1919+import (
2020+ "context"
2121+ "crypto/sha256"
2222+ "fmt"
2323+ "time"
2424+)
2525+2626+// SecretData represents secret key-value pairs
2727+type SecretData map[string][]byte
2828+2929+// HSMInfo contains information about the HSM device
3030+type HSMInfo struct {
3131+ Label string
3232+ Manufacturer string
3333+ Model string
3434+ SerialNumber string
3535+ FirmwareVersion string
3636+}
3737+3838+// Client defines the interface for HSM operations
3939+type Client interface {
4040+ // Initialize establishes connection to the HSM
4141+ Initialize(ctx context.Context, config Config) error
4242+4343+ // Close terminates the HSM connection
4444+ Close() error
4545+4646+ // GetInfo returns information about the HSM device
4747+ GetInfo(ctx context.Context) (*HSMInfo, error)
4848+4949+ // ReadSecret reads secret data from the specified HSM path
5050+ ReadSecret(ctx context.Context, path string) (SecretData, error)
5151+5252+ // WriteSecret writes secret data to the specified HSM path
5353+ WriteSecret(ctx context.Context, path string, data SecretData) error
5454+5555+ // DeleteSecret removes secret data from the specified HSM path
5656+ DeleteSecret(ctx context.Context, path string) error
5757+5858+ // ListSecrets returns a list of secret paths
5959+ ListSecrets(ctx context.Context, prefix string) ([]string, error)
6060+6161+ // GetChecksum returns the SHA256 checksum of the secret data at the given path
6262+ GetChecksum(ctx context.Context, path string) (string, error)
6363+6464+ // IsConnected returns true if the HSM is connected and responsive
6565+ IsConnected() bool
6666+}
6767+6868+// Config holds HSM client configuration
6969+type Config struct {
7070+ // PKCS11LibraryPath is the path to the PKCS#11 library
7171+ PKCS11LibraryPath string
7272+7373+ // SlotID is the HSM slot identifier
7474+ SlotID uint
7575+7676+ // PIN is the user PIN for authentication
7777+ PIN string
7878+7979+ // TokenLabel is the token label to use
8080+ TokenLabel string
8181+8282+ // ConnectionTimeout for HSM operations
8383+ ConnectionTimeout time.Duration
8484+8585+ // RetryAttempts for failed operations
8686+ RetryAttempts int
8787+8888+ // RetryDelay between retry attempts
8989+ RetryDelay time.Duration
9090+}
9191+9292+// DefaultConfig returns a default HSM configuration
9393+func DefaultConfig() Config {
9494+ return Config{
9595+ PKCS11LibraryPath: "/usr/lib/opensc-pkcs11.so",
9696+ SlotID: 0,
9797+ ConnectionTimeout: 30 * time.Second,
9898+ RetryAttempts: 3,
9999+ RetryDelay: 2 * time.Second,
100100+ }
101101+}
102102+103103+// CalculateChecksum calculates SHA256 checksum of secret data
104104+func CalculateChecksum(data SecretData) string {
105105+ h := sha256.New()
106106+107107+ // Sort keys for consistent checksum
108108+ keys := make([]string, 0, len(data))
109109+ for k := range data {
110110+ keys = append(keys, k)
111111+ }
112112+113113+ // Simple sort (for production, use sort.Strings)
114114+ for i := 0; i < len(keys)-1; i++ {
115115+ for j := i + 1; j < len(keys); j++ {
116116+ if keys[i] > keys[j] {
117117+ keys[i], keys[j] = keys[j], keys[i]
118118+ }
119119+ }
120120+ }
121121+122122+ for _, key := range keys {
123123+ h.Write([]byte(key))
124124+ h.Write(data[key])
125125+ }
126126+127127+ return fmt.Sprintf("sha256:%x", h.Sum(nil))
128128+}
+234
internal/hsm/mock_client.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 hsm
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "strings"
2323+ "sync"
2424+2525+ "github.com/go-logr/logr"
2626+ ctrl "sigs.k8s.io/controller-runtime"
2727+)
2828+2929+// MockClient implements the Client interface for testing
3030+type MockClient struct {
3131+ logger logr.Logger
3232+ mutex sync.RWMutex
3333+ connected bool
3434+ secrets map[string]SecretData
3535+ config Config
3636+}
3737+3838+// NewMockClient creates a new mock HSM client for testing
3939+func NewMockClient() *MockClient {
4040+ return &MockClient{
4141+ logger: ctrl.Log.WithName("hsm-mock-client"),
4242+ secrets: make(map[string]SecretData),
4343+ }
4444+}
4545+4646+// Initialize simulates HSM connection
4747+func (m *MockClient) Initialize(ctx context.Context, config Config) error {
4848+ m.mutex.Lock()
4949+ defer m.mutex.Unlock()
5050+5151+ m.config = config
5252+ m.connected = true
5353+5454+ // Pre-populate with some test data
5555+ m.secrets["secrets/default/test-secret"] = SecretData{
5656+ "username": []byte("testuser"),
5757+ "password": []byte("testpass123"),
5858+ }
5959+6060+ m.secrets["secrets/production/database-credentials"] = SecretData{
6161+ "host": []byte("db.example.com"),
6262+ "username": []byte("produser"),
6363+ "password": []byte("prod-secret-password"),
6464+ "database": []byte("application"),
6565+ }
6666+6767+ m.logger.Info("Mock HSM client initialized", "secretCount", len(m.secrets))
6868+ return nil
6969+}
7070+7171+// Close simulates HSM disconnection
7272+func (m *MockClient) Close() error {
7373+ m.mutex.Lock()
7474+ defer m.mutex.Unlock()
7575+7676+ m.connected = false
7777+ m.logger.Info("Mock HSM client closed")
7878+ return nil
7979+}
8080+8181+// GetInfo returns mock HSM device information
8282+func (m *MockClient) GetInfo(ctx context.Context) (*HSMInfo, error) {
8383+ m.mutex.RLock()
8484+ defer m.mutex.RUnlock()
8585+8686+ if !m.connected {
8787+ return nil, fmt.Errorf("HSM not connected")
8888+ }
8989+9090+ return &HSMInfo{
9191+ Label: "Mock HSM Token",
9292+ Manufacturer: "Test Manufacturer",
9393+ Model: "Mock HSM v1.0",
9494+ SerialNumber: "MOCK123456",
9595+ FirmwareVersion: "1.0.0",
9696+ }, nil
9797+}
9898+9999+// ReadSecret reads secret data from mock storage
100100+func (m *MockClient) ReadSecret(ctx context.Context, path string) (SecretData, error) {
101101+ m.mutex.RLock()
102102+ defer m.mutex.RUnlock()
103103+104104+ if !m.connected {
105105+ return nil, fmt.Errorf("HSM not connected")
106106+ }
107107+108108+ data, exists := m.secrets[path]
109109+ if !exists {
110110+ return nil, fmt.Errorf("secret not found at path: %s", path)
111111+ }
112112+113113+ // Return a copy to avoid data races
114114+ result := make(SecretData)
115115+ for k, v := range data {
116116+ result[k] = append([]byte(nil), v...)
117117+ }
118118+119119+ m.logger.V(1).Info("Read secret from mock HSM",
120120+ "path", path, "keys", len(result))
121121+ return result, nil
122122+}
123123+124124+// WriteSecret writes secret data to mock storage
125125+func (m *MockClient) WriteSecret(ctx context.Context, path string, data SecretData) error {
126126+ m.mutex.Lock()
127127+ defer m.mutex.Unlock()
128128+129129+ if !m.connected {
130130+ return fmt.Errorf("HSM not connected")
131131+ }
132132+133133+ // Store a copy to avoid data races
134134+ stored := make(SecretData)
135135+ for k, v := range data {
136136+ stored[k] = append([]byte(nil), v...)
137137+ }
138138+139139+ m.secrets[path] = stored
140140+ m.logger.Info("Wrote secret to mock HSM",
141141+ "path", path, "keys", len(data))
142142+ return nil
143143+}
144144+145145+// DeleteSecret removes secret data from mock storage
146146+func (m *MockClient) DeleteSecret(ctx context.Context, path string) error {
147147+ m.mutex.Lock()
148148+ defer m.mutex.Unlock()
149149+150150+ if !m.connected {
151151+ return fmt.Errorf("HSM not connected")
152152+ }
153153+154154+ if _, exists := m.secrets[path]; !exists {
155155+ return fmt.Errorf("secret not found at path: %s", path)
156156+ }
157157+158158+ delete(m.secrets, path)
159159+ m.logger.Info("Deleted secret from mock HSM", "path", path)
160160+ return nil
161161+}
162162+163163+// ListSecrets returns a list of secret paths with the given prefix
164164+func (m *MockClient) ListSecrets(ctx context.Context, prefix string) ([]string, error) {
165165+ m.mutex.RLock()
166166+ defer m.mutex.RUnlock()
167167+168168+ if !m.connected {
169169+ return nil, fmt.Errorf("HSM not connected")
170170+ }
171171+172172+ var paths []string
173173+ for path := range m.secrets {
174174+ if strings.HasPrefix(path, prefix) {
175175+ paths = append(paths, path)
176176+ }
177177+ }
178178+179179+ m.logger.V(1).Info("Listed secrets from mock HSM",
180180+ "prefix", prefix, "count", len(paths))
181181+ return paths, nil
182182+}
183183+184184+// GetChecksum returns the SHA256 checksum of the secret data at the given path
185185+func (m *MockClient) GetChecksum(ctx context.Context, path string) (string, error) {
186186+ data, err := m.ReadSecret(ctx, path)
187187+ if err != nil {
188188+ return "", fmt.Errorf("failed to read secret for checksum: %w", err)
189189+ }
190190+191191+ checksum := CalculateChecksum(data)
192192+ m.logger.V(2).Info("Calculated checksum for mock secret",
193193+ "path", path, "checksum", checksum)
194194+195195+ return checksum, nil
196196+}
197197+198198+// IsConnected returns the mock connection status
199199+func (m *MockClient) IsConnected() bool {
200200+ m.mutex.RLock()
201201+ defer m.mutex.RUnlock()
202202+203203+ return m.connected
204204+}
205205+206206+// AddSecret adds a secret to the mock storage for testing
207207+func (m *MockClient) AddSecret(path string, data SecretData) {
208208+ m.mutex.Lock()
209209+ defer m.mutex.Unlock()
210210+211211+ stored := make(SecretData)
212212+ for k, v := range data {
213213+ stored[k] = append([]byte(nil), v...)
214214+ }
215215+216216+ m.secrets[path] = stored
217217+}
218218+219219+// GetAllSecrets returns all secrets in mock storage for testing
220220+func (m *MockClient) GetAllSecrets() map[string]SecretData {
221221+ m.mutex.RLock()
222222+ defer m.mutex.RUnlock()
223223+224224+ result := make(map[string]SecretData)
225225+ for path, data := range m.secrets {
226226+ copied := make(SecretData)
227227+ for k, v := range data {
228228+ copied[k] = append([]byte(nil), v...)
229229+ }
230230+ result[path] = copied
231231+ }
232232+233233+ return result
234234+}
+267
internal/hsm/pkcs11_client.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 hsm
1818+1919+import (
2020+ "context"
2121+ "fmt"
2222+ "strings"
2323+ "sync"
2424+ "time"
2525+2626+ "github.com/go-logr/logr"
2727+ ctrl "sigs.k8s.io/controller-runtime"
2828+)
2929+3030+// PKCS11Client implements the Client interface using PKCS#11
3131+type PKCS11Client struct {
3232+ config Config
3333+ logger logr.Logger
3434+ mutex sync.RWMutex
3535+3636+ // These would be actual PKCS#11 objects in a real implementation
3737+ session interface{}
3838+ connected bool
3939+}
4040+4141+// NewPKCS11Client creates a new PKCS#11 HSM client
4242+func NewPKCS11Client() *PKCS11Client {
4343+ return &PKCS11Client{
4444+ logger: ctrl.Log.WithName("hsm-pkcs11-client"),
4545+ }
4646+}
4747+4848+// Initialize establishes connection to the HSM via PKCS#11
4949+func (c *PKCS11Client) Initialize(ctx context.Context, config Config) error {
5050+ c.mutex.Lock()
5151+ defer c.mutex.Unlock()
5252+5353+ c.config = config
5454+ c.logger.Info("Initializing HSM connection",
5555+ "library", config.PKCS11LibraryPath,
5656+ "slot", config.SlotID,
5757+ "tokenLabel", config.TokenLabel)
5858+5959+ // TODO: Implement actual PKCS#11 initialization
6060+ // For now, we'll simulate the connection
6161+6262+ // Validate configuration
6363+ if config.PKCS11LibraryPath == "" {
6464+ return fmt.Errorf("PKCS11LibraryPath is required")
6565+ }
6666+6767+ if config.PIN == "" {
6868+ return fmt.Errorf("PIN is required for HSM authentication")
6969+ }
7070+7171+ // Simulate connection establishment
7272+ select {
7373+ case <-ctx.Done():
7474+ return ctx.Err()
7575+ case <-time.After(100 * time.Millisecond): // Simulate connection time
7676+ c.connected = true
7777+ c.logger.Info("HSM connection established successfully")
7878+ return nil
7979+ }
8080+}
8181+8282+// Close terminates the HSM connection
8383+func (c *PKCS11Client) Close() error {
8484+ c.mutex.Lock()
8585+ defer c.mutex.Unlock()
8686+8787+ if !c.connected {
8888+ return nil
8989+ }
9090+9191+ c.logger.Info("Closing HSM connection")
9292+9393+ // TODO: Implement actual PKCS#11 cleanup
9494+ c.connected = false
9595+ c.session = nil
9696+9797+ return nil
9898+}
9999+100100+// GetInfo returns information about the HSM device
101101+func (c *PKCS11Client) GetInfo(ctx context.Context) (*HSMInfo, error) {
102102+ c.mutex.RLock()
103103+ defer c.mutex.RUnlock()
104104+105105+ if !c.connected {
106106+ return nil, fmt.Errorf("HSM not connected")
107107+ }
108108+109109+ // TODO: Implement actual device info retrieval via PKCS#11
110110+ info := &HSMInfo{
111111+ Label: c.config.TokenLabel,
112112+ Manufacturer: "SmartCard-HSM",
113113+ Model: "Pico HSM",
114114+ SerialNumber: "000000000000",
115115+ FirmwareVersion: "3.5",
116116+ }
117117+118118+ return info, nil
119119+}
120120+121121+// ReadSecret reads secret data from the specified HSM path
122122+func (c *PKCS11Client) ReadSecret(ctx context.Context, path string) (SecretData, error) {
123123+ c.mutex.RLock()
124124+ defer c.mutex.RUnlock()
125125+126126+ if !c.connected {
127127+ return nil, fmt.Errorf("HSM not connected")
128128+ }
129129+130130+ c.logger.V(1).Info("Reading secret from HSM", "path", path)
131131+132132+ // TODO: Implement actual PKCS#11 secret reading
133133+ // For now, return simulated data based on path
134134+ data := make(SecretData)
135135+136136+ // Simulate different secret types based on path
137137+ if strings.Contains(path, "database") {
138138+ data["username"] = []byte("dbuser")
139139+ data["password"] = []byte("dbpass123")
140140+ } else if strings.Contains(path, "api") {
141141+ data["api-key"] = []byte("sk-1234567890abcdef")
142142+ data["api-secret"] = []byte("secret-abcdef1234567890")
143143+ } else {
144144+ // Default secret structure
145145+ data["data"] = []byte("secret-value")
146146+ }
147147+148148+ c.logger.V(1).Info("Successfully read secret from HSM",
149149+ "path", path, "keys", len(data))
150150+151151+ return data, nil
152152+}
153153+154154+// WriteSecret writes secret data to the specified HSM path
155155+func (c *PKCS11Client) WriteSecret(ctx context.Context, path string, data SecretData) error {
156156+ c.mutex.Lock()
157157+ defer c.mutex.Unlock()
158158+159159+ if !c.connected {
160160+ return fmt.Errorf("HSM not connected")
161161+ }
162162+163163+ c.logger.V(1).Info("Writing secret to HSM",
164164+ "path", path, "keys", len(data))
165165+166166+ // TODO: Implement actual PKCS#11 secret writing
167167+ // For now, just log the operation
168168+ for key := range data {
169169+ c.logger.V(2).Info("Writing secret key", "path", path, "key", key)
170170+ }
171171+172172+ c.logger.Info("Successfully wrote secret to HSM", "path", path)
173173+ return nil
174174+}
175175+176176+// DeleteSecret removes secret data from the specified HSM path
177177+func (c *PKCS11Client) DeleteSecret(ctx context.Context, path string) error {
178178+ c.mutex.Lock()
179179+ defer c.mutex.Unlock()
180180+181181+ if !c.connected {
182182+ return fmt.Errorf("HSM not connected")
183183+ }
184184+185185+ c.logger.Info("Deleting secret from HSM", "path", path)
186186+187187+ // TODO: Implement actual PKCS#11 secret deletion
188188+ c.logger.Info("Successfully deleted secret from HSM", "path", path)
189189+ return nil
190190+}
191191+192192+// ListSecrets returns a list of secret paths with the given prefix
193193+func (c *PKCS11Client) ListSecrets(ctx context.Context, prefix string) ([]string, error) {
194194+ c.mutex.RLock()
195195+ defer c.mutex.RUnlock()
196196+197197+ if !c.connected {
198198+ return nil, fmt.Errorf("HSM not connected")
199199+ }
200200+201201+ c.logger.V(1).Info("Listing secrets from HSM", "prefix", prefix)
202202+203203+ // TODO: Implement actual PKCS#11 secret listing
204204+ // For now, return some simulated paths
205205+ paths := []string{
206206+ prefix + "/database-credentials",
207207+ prefix + "/api-keys",
208208+ prefix + "/certificates",
209209+ }
210210+211211+ c.logger.V(1).Info("Successfully listed secrets from HSM",
212212+ "prefix", prefix, "count", len(paths))
213213+214214+ return paths, nil
215215+}
216216+217217+// GetChecksum returns the SHA256 checksum of the secret data at the given path
218218+func (c *PKCS11Client) GetChecksum(ctx context.Context, path string) (string, error) {
219219+ data, err := c.ReadSecret(ctx, path)
220220+ if err != nil {
221221+ return "", fmt.Errorf("failed to read secret for checksum: %w", err)
222222+ }
223223+224224+ checksum := CalculateChecksum(data)
225225+ c.logger.V(2).Info("Calculated checksum for secret",
226226+ "path", path, "checksum", checksum)
227227+228228+ return checksum, nil
229229+}
230230+231231+// IsConnected returns true if the HSM is connected and responsive
232232+func (c *PKCS11Client) IsConnected() bool {
233233+ c.mutex.RLock()
234234+ defer c.mutex.RUnlock()
235235+236236+ return c.connected
237237+}
238238+239239+// WithRetry wraps an operation with retry logic
240240+func (c *PKCS11Client) WithRetry(ctx context.Context, operation func() error) error {
241241+ var lastErr error
242242+243243+ for attempt := 0; attempt <= c.config.RetryAttempts; attempt++ {
244244+ if attempt > 0 {
245245+ c.logger.V(1).Info("Retrying HSM operation",
246246+ "attempt", attempt, "maxAttempts", c.config.RetryAttempts)
247247+248248+ select {
249249+ case <-ctx.Done():
250250+ return ctx.Err()
251251+ case <-time.After(c.config.RetryDelay):
252252+ }
253253+ }
254254+255255+ if err := operation(); err != nil {
256256+ lastErr = err
257257+ c.logger.V(1).Info("HSM operation failed",
258258+ "attempt", attempt, "error", err)
259259+ continue
260260+ }
261261+262262+ return nil
263263+ }
264264+265265+ return fmt.Errorf("operation failed after %d attempts: %w",
266266+ c.config.RetryAttempts, lastErr)
267267+}
+273
scripts/build-talos.sh
···11+#!/bin/bash
22+33+# Build script for HSM Secrets Operator on Talos Linux
44+# This script builds a custom operator image with PKCS#11 libraries included
55+66+set -e
77+88+# Configuration
99+REGISTRY=${REGISTRY:-"localhost:5000"}
1010+IMAGE_NAME=${IMAGE_NAME:-"hsm-secrets-operator"}
1111+TAG=${TAG:-"talos-$(date +%Y%m%d-%H%M)"}
1212+DOCKERFILE=${DOCKERFILE:-"Dockerfile.talos"}
1313+1414+# Colors for output
1515+RED='\033[0;31m'
1616+GREEN='\033[0;32m'
1717+YELLOW='\033[1;33m'
1818+BLUE='\033[0;34m'
1919+NC='\033[0m' # No Color
2020+2121+print_status() {
2222+ echo -e "${BLUE}[INFO]${NC} $1"
2323+}
2424+2525+print_success() {
2626+ echo -e "${GREEN}[SUCCESS]${NC} $1"
2727+}
2828+2929+print_warning() {
3030+ echo -e "${YELLOW}[WARNING]${NC} $1"
3131+}
3232+3333+print_error() {
3434+ echo -e "${RED}[ERROR]${NC} $1"
3535+}
3636+3737+# Check prerequisites
3838+check_prerequisites() {
3939+ print_status "Checking prerequisites..."
4040+4141+ if ! command -v docker &> /dev/null; then
4242+ print_error "Docker is required but not installed"
4343+ exit 1
4444+ fi
4545+4646+ if ! command -v kubectl &> /dev/null; then
4747+ print_warning "kubectl not found - you won't be able to deploy directly"
4848+ fi
4949+5050+ if [ ! -f "$DOCKERFILE" ]; then
5151+ print_error "Dockerfile not found: $DOCKERFILE"
5252+ exit 1
5353+ fi
5454+5555+ print_success "Prerequisites check completed"
5656+}
5757+5858+# Build the custom operator image
5959+build_image() {
6060+ print_status "Building HSM Secrets Operator for Talos Linux..."
6161+ print_status "Registry: $REGISTRY"
6262+ print_status "Image: $IMAGE_NAME"
6363+ print_status "Tag: $TAG"
6464+ print_status "Dockerfile: $DOCKERFILE"
6565+6666+ # Build multi-arch image if buildx is available
6767+ if docker buildx version &> /dev/null; then
6868+ print_status "Building multi-architecture image with buildx..."
6969+ docker buildx build \
7070+ --platform linux/amd64,linux/arm64 \
7171+ -f "$DOCKERFILE" \
7272+ -t "$REGISTRY/$IMAGE_NAME:$TAG" \
7373+ -t "$REGISTRY/$IMAGE_NAME:talos-latest" \
7474+ --load \
7575+ .
7676+ else
7777+ print_status "Building single-architecture image..."
7878+ docker build \
7979+ -f "$DOCKERFILE" \
8080+ -t "$REGISTRY/$IMAGE_NAME:$TAG" \
8181+ -t "$REGISTRY/$IMAGE_NAME:talos-latest" \
8282+ .
8383+ fi
8484+8585+ print_success "Image build completed successfully!"
8686+}
8787+8888+# Test the built image
8989+test_image() {
9090+ print_status "Testing the built image..."
9191+9292+ # Test library availability
9393+ print_status "Checking PKCS#11 libraries in image..."
9494+ docker run --rm "$REGISTRY/$IMAGE_NAME:$TAG" /bin/sh -c '
9595+ echo "=== Library Path ==="
9696+ echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"
9797+ echo "PKCS11_MODULE_PATH: $PKCS11_MODULE_PATH"
9898+ echo ""
9999+100100+ echo "=== Available Libraries ==="
101101+ if [ -d "/usr/local/lib/pkcs11" ]; then
102102+ ls -la /usr/local/lib/pkcs11/
103103+ else
104104+ echo "PKCS#11 directory not found"
105105+ fi
106106+ echo ""
107107+108108+ echo "=== Library Dependencies ==="
109109+ for lib in /usr/local/lib/pkcs11/*.so 2>/dev/null; do
110110+ if [ -f "$lib" ]; then
111111+ echo "Testing: $lib"
112112+ if command -v ldd >/dev/null; then
113113+ ldd "$lib" 2>/dev/null || echo " Dependencies check not available"
114114+ else
115115+ echo " ldd not available in distroless image"
116116+ fi
117117+ fi
118118+ done
119119+ ' || print_warning "Library test had issues (this may be expected with distroless images)"
120120+121121+ # Test basic operator startup (dry run)
122122+ print_status "Testing operator startup..."
123123+ timeout 10 docker run --rm "$REGISTRY/$IMAGE_NAME:$TAG" --help || true
124124+125125+ print_success "Image testing completed"
126126+}
127127+128128+# Push image to registry
129129+push_image() {
130130+ if [ "$1" = "--push" ] || [ "$1" = "-p" ]; then
131131+ print_status "Pushing images to registry..."
132132+ docker push "$REGISTRY/$IMAGE_NAME:$TAG"
133133+ docker push "$REGISTRY/$IMAGE_NAME:talos-latest"
134134+ print_success "Images pushed successfully!"
135135+ else
136136+ print_status "Skipping push (use --push flag to push to registry)"
137137+ print_status "To push manually:"
138138+ print_status " docker push $REGISTRY/$IMAGE_NAME:$TAG"
139139+ print_status " docker push $REGISTRY/$IMAGE_NAME:talos-latest"
140140+ fi
141141+}
142142+143143+# Generate deployment manifests
144144+generate_manifests() {
145145+ print_status "Generating Talos deployment manifests..."
146146+147147+ MANIFEST_DIR="deploy/talos"
148148+ mkdir -p "$MANIFEST_DIR"
149149+150150+ # Update deployment manifest with new image
151151+ cat > "$MANIFEST_DIR/kustomization.yaml" << EOF
152152+apiVersion: kustomize.config.k8s.io/v1beta1
153153+kind: Kustomization
154154+155155+resources:
156156+ - ../default
157157+158158+images:
159159+ - name: controller
160160+ newName: $REGISTRY/$IMAGE_NAME
161161+ newTag: $TAG
162162+163163+patches:
164164+ - patch: |-
165165+ - op: add
166166+ path: /spec/template/spec/nodeSelector
167167+ value:
168168+ kubernetes.io/arch: amd64
169169+ hsm.j5t.io/enabled: "true"
170170+ - op: add
171171+ path: /spec/template/metadata/labels/talos
172172+ value: "enabled"
173173+ target:
174174+ kind: Deployment
175175+ name: hsm-secrets-operator-controller-manager
176176+EOF
177177+178178+ print_success "Deployment manifests generated in $MANIFEST_DIR/"
179179+}
180180+181181+# Display usage information
182182+show_usage() {
183183+ echo "Usage: $0 [OPTIONS]"
184184+ echo ""
185185+ echo "Build HSM Secrets Operator image optimized for Talos Linux"
186186+ echo ""
187187+ echo "Options:"
188188+ echo " --push, -p Push images to registry after building"
189189+ echo " --test, -t Run additional image tests"
190190+ echo " --manifests, -m Generate Talos deployment manifests"
191191+ echo " --help, -h Show this help message"
192192+ echo ""
193193+ echo "Environment variables:"
194194+ echo " REGISTRY Container registry (default: localhost:5000)"
195195+ echo " IMAGE_NAME Image name (default: hsm-secrets-operator)"
196196+ echo " TAG Image tag (default: talos-YYYYMMDD-HHMM)"
197197+ echo " DOCKERFILE Dockerfile to use (default: Dockerfile.talos)"
198198+ echo ""
199199+ echo "Examples:"
200200+ echo " $0 # Build image locally"
201201+ echo " $0 --push # Build and push to registry"
202202+ echo " $0 --test --manifests # Build, test, and generate manifests"
203203+ echo " REGISTRY=myregistry.com $0 --push # Use custom registry"
204204+}
205205+206206+# Main execution
207207+main() {
208208+ print_status "HSM Secrets Operator Talos Build Script"
209209+ print_status "========================================"
210210+211211+ # Parse command line arguments
212212+ PUSH=false
213213+ TEST=false
214214+ MANIFESTS=false
215215+216216+ while [[ $# -gt 0 ]]; do
217217+ case $1 in
218218+ --push|-p)
219219+ PUSH=true
220220+ shift
221221+ ;;
222222+ --test|-t)
223223+ TEST=true
224224+ shift
225225+ ;;
226226+ --manifests|-m)
227227+ MANIFESTS=true
228228+ shift
229229+ ;;
230230+ --help|-h)
231231+ show_usage
232232+ exit 0
233233+ ;;
234234+ *)
235235+ print_error "Unknown option: $1"
236236+ show_usage
237237+ exit 1
238238+ ;;
239239+ esac
240240+ done
241241+242242+ # Execute build steps
243243+ check_prerequisites
244244+ build_image
245245+246246+ if [ "$TEST" = true ]; then
247247+ test_image
248248+ fi
249249+250250+ if [ "$PUSH" = true ]; then
251251+ push_image --push
252252+ else
253253+ push_image
254254+ fi
255255+256256+ if [ "$MANIFESTS" = true ]; then
257257+ generate_manifests
258258+ fi
259259+260260+ print_success "Build process completed!"
261261+ print_status "Image: $REGISTRY/$IMAGE_NAME:$TAG"
262262+263263+ if command -v kubectl &> /dev/null && [ "$MANIFESTS" = true ]; then
264264+ print_status "To deploy on Talos:"
265265+ print_status " kubectl apply -k deploy/talos/"
266266+ elif command -v kubectl &> /dev/null; then
267267+ print_status "To deploy on Talos:"
268268+ print_status " kubectl apply -f examples/advanced/talos-deployment.yaml"
269269+ fi
270270+}
271271+272272+# Run main function with all arguments
273273+main "$@"
+89
test/e2e/e2e_suite_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 e2e
1818+1919+import (
2020+ "fmt"
2121+ "os"
2222+ "os/exec"
2323+ "testing"
2424+2525+ . "github.com/onsi/ginkgo/v2"
2626+ . "github.com/onsi/gomega"
2727+2828+ "github.com/evanjarrett/hsm-secrets-operator/test/utils"
2929+)
3030+3131+var (
3232+ // Optional Environment Variables:
3333+ // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup.
3434+ // These variables are useful if CertManager is already installed, avoiding
3535+ // re-installation and conflicts.
3636+ skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true"
3737+ // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster
3838+ isCertManagerAlreadyInstalled = false
3939+4040+ // projectImage is the name of the image which will be build and loaded
4141+ // with the code source changes to be tested.
4242+ projectImage = "example.com/hsm-secrets-operator:v0.0.1"
4343+)
4444+4545+// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated,
4646+// temporary environment to validate project changes with the purposed to be used in CI jobs.
4747+// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs
4848+// CertManager.
4949+func TestE2E(t *testing.T) {
5050+ RegisterFailHandler(Fail)
5151+ _, _ = fmt.Fprintf(GinkgoWriter, "Starting hsm-secrets-operator integration test suite\n")
5252+ RunSpecs(t, "e2e suite")
5353+}
5454+5555+var _ = BeforeSuite(func() {
5656+ By("building the manager(Operator) image")
5757+ cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage))
5858+ _, err := utils.Run(cmd)
5959+ ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image")
6060+6161+ // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is
6262+ // built and available before running the tests. Also, remove the following block.
6363+ By("loading the manager(Operator) image on Kind")
6464+ err = utils.LoadImageToKindClusterWithName(projectImage)
6565+ ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind")
6666+6767+ // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing.
6868+ // To prevent errors when tests run in environments with CertManager already installed,
6969+ // we check for its presence before execution.
7070+ // Setup CertManager before the suite if not skipped and if not already installed
7171+ if !skipCertManagerInstall {
7272+ By("checking if cert manager is installed already")
7373+ isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled()
7474+ if !isCertManagerAlreadyInstalled {
7575+ _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n")
7676+ Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager")
7777+ } else {
7878+ _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n")
7979+ }
8080+ }
8181+})
8282+8383+var _ = AfterSuite(func() {
8484+ // Teardown CertManager after the suite if not skipped and if it was not already installed
8585+ if !skipCertManagerInstall && !isCertManagerAlreadyInstalled {
8686+ _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n")
8787+ utils.UninstallCertManager()
8888+ }
8989+})
+329
test/e2e/e2e_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 e2e
1818+1919+import (
2020+ "encoding/json"
2121+ "fmt"
2222+ "os"
2323+ "os/exec"
2424+ "path/filepath"
2525+ "time"
2626+2727+ . "github.com/onsi/ginkgo/v2"
2828+ . "github.com/onsi/gomega"
2929+3030+ "github.com/evanjarrett/hsm-secrets-operator/test/utils"
3131+)
3232+3333+// namespace where the project is deployed in
3434+const namespace = "hsm-secrets-operator-system"
3535+3636+// serviceAccountName created for the project
3737+const serviceAccountName = "hsm-secrets-operator-controller-manager"
3838+3939+// metricsServiceName is the name of the metrics service of the project
4040+const metricsServiceName = "hsm-secrets-operator-controller-manager-metrics-service"
4141+4242+// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data
4343+const metricsRoleBindingName = "hsm-secrets-operator-metrics-binding"
4444+4545+var _ = Describe("Manager", Ordered, func() {
4646+ var controllerPodName string
4747+4848+ // Before running the tests, set up the environment by creating the namespace,
4949+ // enforce the restricted security policy to the namespace, installing CRDs,
5050+ // and deploying the controller.
5151+ BeforeAll(func() {
5252+ By("creating manager namespace")
5353+ cmd := exec.Command("kubectl", "create", "ns", namespace)
5454+ _, err := utils.Run(cmd)
5555+ Expect(err).NotTo(HaveOccurred(), "Failed to create namespace")
5656+5757+ By("labeling the namespace to enforce the restricted security policy")
5858+ cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace,
5959+ "pod-security.kubernetes.io/enforce=restricted")
6060+ _, err = utils.Run(cmd)
6161+ Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy")
6262+6363+ By("installing CRDs")
6464+ cmd = exec.Command("make", "install")
6565+ _, err = utils.Run(cmd)
6666+ Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs")
6767+6868+ By("deploying the controller-manager")
6969+ cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage))
7070+ _, err = utils.Run(cmd)
7171+ Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
7272+ })
7373+7474+ // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
7575+ // and deleting the namespace.
7676+ AfterAll(func() {
7777+ By("cleaning up the curl pod for metrics")
7878+ cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace)
7979+ _, _ = utils.Run(cmd)
8080+8181+ By("undeploying the controller-manager")
8282+ cmd = exec.Command("make", "undeploy")
8383+ _, _ = utils.Run(cmd)
8484+8585+ By("uninstalling CRDs")
8686+ cmd = exec.Command("make", "uninstall")
8787+ _, _ = utils.Run(cmd)
8888+8989+ By("removing manager namespace")
9090+ cmd = exec.Command("kubectl", "delete", "ns", namespace)
9191+ _, _ = utils.Run(cmd)
9292+ })
9393+9494+ // After each test, check for failures and collect logs, events,
9595+ // and pod descriptions for debugging.
9696+ AfterEach(func() {
9797+ specReport := CurrentSpecReport()
9898+ if specReport.Failed() {
9999+ By("Fetching controller manager pod logs")
100100+ cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
101101+ controllerLogs, err := utils.Run(cmd)
102102+ if err == nil {
103103+ _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs)
104104+ } else {
105105+ _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err)
106106+ }
107107+108108+ By("Fetching Kubernetes events")
109109+ cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp")
110110+ eventsOutput, err := utils.Run(cmd)
111111+ if err == nil {
112112+ _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput)
113113+ } else {
114114+ _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err)
115115+ }
116116+117117+ By("Fetching curl-metrics logs")
118118+ cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
119119+ metricsOutput, err := utils.Run(cmd)
120120+ if err == nil {
121121+ _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput)
122122+ } else {
123123+ _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err)
124124+ }
125125+126126+ By("Fetching controller manager pod description")
127127+ cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace)
128128+ podDescription, err := utils.Run(cmd)
129129+ if err == nil {
130130+ fmt.Println("Pod description:\n", podDescription)
131131+ } else {
132132+ fmt.Println("Failed to describe controller pod")
133133+ }
134134+ }
135135+ })
136136+137137+ SetDefaultEventuallyTimeout(2 * time.Minute)
138138+ SetDefaultEventuallyPollingInterval(time.Second)
139139+140140+ Context("Manager", func() {
141141+ It("should run successfully", func() {
142142+ By("validating that the controller-manager pod is running as expected")
143143+ verifyControllerUp := func(g Gomega) {
144144+ // Get the name of the controller-manager pod
145145+ cmd := exec.Command("kubectl", "get",
146146+ "pods", "-l", "control-plane=controller-manager",
147147+ "-o", "go-template={{ range .items }}"+
148148+ "{{ if not .metadata.deletionTimestamp }}"+
149149+ "{{ .metadata.name }}"+
150150+ "{{ \"\\n\" }}{{ end }}{{ end }}",
151151+ "-n", namespace,
152152+ )
153153+154154+ podOutput, err := utils.Run(cmd)
155155+ g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information")
156156+ podNames := utils.GetNonEmptyLines(podOutput)
157157+ g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running")
158158+ controllerPodName = podNames[0]
159159+ g.Expect(controllerPodName).To(ContainSubstring("controller-manager"))
160160+161161+ // Validate the pod's status
162162+ cmd = exec.Command("kubectl", "get",
163163+ "pods", controllerPodName, "-o", "jsonpath={.status.phase}",
164164+ "-n", namespace,
165165+ )
166166+ output, err := utils.Run(cmd)
167167+ g.Expect(err).NotTo(HaveOccurred())
168168+ g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status")
169169+ }
170170+ Eventually(verifyControllerUp).Should(Succeed())
171171+ })
172172+173173+ It("should ensure the metrics endpoint is serving metrics", func() {
174174+ By("creating a ClusterRoleBinding for the service account to allow access to metrics")
175175+ cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName,
176176+ "--clusterrole=hsm-secrets-operator-metrics-reader",
177177+ fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName),
178178+ )
179179+ _, err := utils.Run(cmd)
180180+ Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding")
181181+182182+ By("validating that the metrics service is available")
183183+ cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace)
184184+ _, err = utils.Run(cmd)
185185+ Expect(err).NotTo(HaveOccurred(), "Metrics service should exist")
186186+187187+ By("getting the service account token")
188188+ token, err := serviceAccountToken()
189189+ Expect(err).NotTo(HaveOccurred())
190190+ Expect(token).NotTo(BeEmpty())
191191+192192+ By("waiting for the metrics endpoint to be ready")
193193+ verifyMetricsEndpointReady := func(g Gomega) {
194194+ cmd := exec.Command("kubectl", "get", "endpoints", metricsServiceName, "-n", namespace)
195195+ output, err := utils.Run(cmd)
196196+ g.Expect(err).NotTo(HaveOccurred())
197197+ g.Expect(output).To(ContainSubstring("8443"), "Metrics endpoint is not ready")
198198+ }
199199+ Eventually(verifyMetricsEndpointReady).Should(Succeed())
200200+201201+ By("verifying that the controller manager is serving the metrics server")
202202+ verifyMetricsServerStarted := func(g Gomega) {
203203+ cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
204204+ output, err := utils.Run(cmd)
205205+ g.Expect(err).NotTo(HaveOccurred())
206206+ g.Expect(output).To(ContainSubstring("controller-runtime.metrics\tServing metrics server"),
207207+ "Metrics server not yet started")
208208+ }
209209+ Eventually(verifyMetricsServerStarted).Should(Succeed())
210210+211211+ By("creating the curl-metrics pod to access the metrics endpoint")
212212+ cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never",
213213+ "--namespace", namespace,
214214+ "--image=curlimages/curl:latest",
215215+ "--overrides",
216216+ fmt.Sprintf(`{
217217+ "spec": {
218218+ "containers": [{
219219+ "name": "curl",
220220+ "image": "curlimages/curl:latest",
221221+ "command": ["/bin/sh", "-c"],
222222+ "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"],
223223+ "securityContext": {
224224+ "allowPrivilegeEscalation": false,
225225+ "capabilities": {
226226+ "drop": ["ALL"]
227227+ },
228228+ "runAsNonRoot": true,
229229+ "runAsUser": 1000,
230230+ "seccompProfile": {
231231+ "type": "RuntimeDefault"
232232+ }
233233+ }
234234+ }],
235235+ "serviceAccount": "%s"
236236+ }
237237+ }`, token, metricsServiceName, namespace, serviceAccountName))
238238+ _, err = utils.Run(cmd)
239239+ Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod")
240240+241241+ By("waiting for the curl-metrics pod to complete.")
242242+ verifyCurlUp := func(g Gomega) {
243243+ cmd := exec.Command("kubectl", "get", "pods", "curl-metrics",
244244+ "-o", "jsonpath={.status.phase}",
245245+ "-n", namespace)
246246+ output, err := utils.Run(cmd)
247247+ g.Expect(err).NotTo(HaveOccurred())
248248+ g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status")
249249+ }
250250+ Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed())
251251+252252+ By("getting the metrics by checking curl-metrics logs")
253253+ metricsOutput := getMetricsOutput()
254254+ Expect(metricsOutput).To(ContainSubstring(
255255+ "controller_runtime_reconcile_total",
256256+ ))
257257+ })
258258+259259+ // +kubebuilder:scaffold:e2e-webhooks-checks
260260+261261+ // TODO: Customize the e2e test suite with scenarios specific to your project.
262262+ // Consider applying sample/CR(s) and check their status and/or verifying
263263+ // the reconciliation by using the metrics, i.e.:
264264+ // metricsOutput := getMetricsOutput()
265265+ // Expect(metricsOutput).To(ContainSubstring(
266266+ // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`,
267267+ // strings.ToLower(<Kind>),
268268+ // ))
269269+ })
270270+})
271271+272272+// serviceAccountToken returns a token for the specified service account in the given namespace.
273273+// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
274274+// and parsing the resulting token from the API response.
275275+func serviceAccountToken() (string, error) {
276276+ const tokenRequestRawString = `{
277277+ "apiVersion": "authentication.k8s.io/v1",
278278+ "kind": "TokenRequest"
279279+ }`
280280+281281+ // Temporary file to store the token request
282282+ secretName := fmt.Sprintf("%s-token-request", serviceAccountName)
283283+ tokenRequestFile := filepath.Join("/tmp", secretName)
284284+ err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644))
285285+ if err != nil {
286286+ return "", err
287287+ }
288288+289289+ var out string
290290+ verifyTokenCreation := func(g Gomega) {
291291+ // Execute kubectl command to create the token
292292+ cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf(
293293+ "/api/v1/namespaces/%s/serviceaccounts/%s/token",
294294+ namespace,
295295+ serviceAccountName,
296296+ ), "-f", tokenRequestFile)
297297+298298+ output, err := cmd.CombinedOutput()
299299+ g.Expect(err).NotTo(HaveOccurred())
300300+301301+ // Parse the JSON output to extract the token
302302+ var token tokenRequest
303303+ err = json.Unmarshal(output, &token)
304304+ g.Expect(err).NotTo(HaveOccurred())
305305+306306+ out = token.Status.Token
307307+ }
308308+ Eventually(verifyTokenCreation).Should(Succeed())
309309+310310+ return out, err
311311+}
312312+313313+// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint.
314314+func getMetricsOutput() string {
315315+ By("getting the curl-metrics logs")
316316+ cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
317317+ metricsOutput, err := utils.Run(cmd)
318318+ Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
319319+ Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK"))
320320+ return metricsOutput
321321+}
322322+323323+// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response,
324324+// containing only the token field that we need to extract.
325325+type tokenRequest struct {
326326+ Status struct {
327327+ Token string `json:"token"`
328328+ } `json:"status"`
329329+}
+254
test/utils/utils.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 utils
1818+1919+import (
2020+ "bufio"
2121+ "bytes"
2222+ "fmt"
2323+ "os"
2424+ "os/exec"
2525+ "strings"
2626+2727+ . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck
2828+)
2929+3030+const (
3131+ prometheusOperatorVersion = "v0.77.1"
3232+ prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" +
3333+ "releases/download/%s/bundle.yaml"
3434+3535+ certmanagerVersion = "v1.16.3"
3636+ certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml"
3737+)
3838+3939+func warnError(err error) {
4040+ _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err)
4141+}
4242+4343+// Run executes the provided command within this context
4444+func Run(cmd *exec.Cmd) (string, error) {
4545+ dir, _ := GetProjectDir()
4646+ cmd.Dir = dir
4747+4848+ if err := os.Chdir(cmd.Dir); err != nil {
4949+ _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err)
5050+ }
5151+5252+ cmd.Env = append(os.Environ(), "GO111MODULE=on")
5353+ command := strings.Join(cmd.Args, " ")
5454+ _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command)
5555+ output, err := cmd.CombinedOutput()
5656+ if err != nil {
5757+ return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err)
5858+ }
5959+6060+ return string(output), nil
6161+}
6262+6363+// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics.
6464+func InstallPrometheusOperator() error {
6565+ url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion)
6666+ cmd := exec.Command("kubectl", "create", "-f", url)
6767+ _, err := Run(cmd)
6868+ return err
6969+}
7070+7171+// UninstallPrometheusOperator uninstalls the prometheus
7272+func UninstallPrometheusOperator() {
7373+ url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion)
7474+ cmd := exec.Command("kubectl", "delete", "-f", url)
7575+ if _, err := Run(cmd); err != nil {
7676+ warnError(err)
7777+ }
7878+}
7979+8080+// IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed
8181+// by verifying the existence of key CRDs related to Prometheus.
8282+func IsPrometheusCRDsInstalled() bool {
8383+ // List of common Prometheus CRDs
8484+ prometheusCRDs := []string{
8585+ "prometheuses.monitoring.coreos.com",
8686+ "prometheusrules.monitoring.coreos.com",
8787+ "prometheusagents.monitoring.coreos.com",
8888+ }
8989+9090+ cmd := exec.Command("kubectl", "get", "crds", "-o", "custom-columns=NAME:.metadata.name")
9191+ output, err := Run(cmd)
9292+ if err != nil {
9393+ return false
9494+ }
9595+ crdList := GetNonEmptyLines(output)
9696+ for _, crd := range prometheusCRDs {
9797+ for _, line := range crdList {
9898+ if strings.Contains(line, crd) {
9999+ return true
100100+ }
101101+ }
102102+ }
103103+104104+ return false
105105+}
106106+107107+// UninstallCertManager uninstalls the cert manager
108108+func UninstallCertManager() {
109109+ url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)
110110+ cmd := exec.Command("kubectl", "delete", "-f", url)
111111+ if _, err := Run(cmd); err != nil {
112112+ warnError(err)
113113+ }
114114+}
115115+116116+// InstallCertManager installs the cert manager bundle.
117117+func InstallCertManager() error {
118118+ url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)
119119+ cmd := exec.Command("kubectl", "apply", "-f", url)
120120+ if _, err := Run(cmd); err != nil {
121121+ return err
122122+ }
123123+ // Wait for cert-manager-webhook to be ready, which can take time if cert-manager
124124+ // was re-installed after uninstalling on a cluster.
125125+ cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook",
126126+ "--for", "condition=Available",
127127+ "--namespace", "cert-manager",
128128+ "--timeout", "5m",
129129+ )
130130+131131+ _, err := Run(cmd)
132132+ return err
133133+}
134134+135135+// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed
136136+// by verifying the existence of key CRDs related to Cert Manager.
137137+func IsCertManagerCRDsInstalled() bool {
138138+ // List of common Cert Manager CRDs
139139+ certManagerCRDs := []string{
140140+ "certificates.cert-manager.io",
141141+ "issuers.cert-manager.io",
142142+ "clusterissuers.cert-manager.io",
143143+ "certificaterequests.cert-manager.io",
144144+ "orders.acme.cert-manager.io",
145145+ "challenges.acme.cert-manager.io",
146146+ }
147147+148148+ // Execute the kubectl command to get all CRDs
149149+ cmd := exec.Command("kubectl", "get", "crds")
150150+ output, err := Run(cmd)
151151+ if err != nil {
152152+ return false
153153+ }
154154+155155+ // Check if any of the Cert Manager CRDs are present
156156+ crdList := GetNonEmptyLines(output)
157157+ for _, crd := range certManagerCRDs {
158158+ for _, line := range crdList {
159159+ if strings.Contains(line, crd) {
160160+ return true
161161+ }
162162+ }
163163+ }
164164+165165+ return false
166166+}
167167+168168+// LoadImageToKindClusterWithName loads a local docker image to the kind cluster
169169+func LoadImageToKindClusterWithName(name string) error {
170170+ cluster := "kind"
171171+ if v, ok := os.LookupEnv("KIND_CLUSTER"); ok {
172172+ cluster = v
173173+ }
174174+ kindOptions := []string{"load", "docker-image", name, "--name", cluster}
175175+ cmd := exec.Command("kind", kindOptions...)
176176+ _, err := Run(cmd)
177177+ return err
178178+}
179179+180180+// GetNonEmptyLines converts given command output string into individual objects
181181+// according to line breakers, and ignores the empty elements in it.
182182+func GetNonEmptyLines(output string) []string {
183183+ var res []string
184184+ elements := strings.Split(output, "\n")
185185+ for _, element := range elements {
186186+ if element != "" {
187187+ res = append(res, element)
188188+ }
189189+ }
190190+191191+ return res
192192+}
193193+194194+// GetProjectDir will return the directory where the project is
195195+func GetProjectDir() (string, error) {
196196+ wd, err := os.Getwd()
197197+ if err != nil {
198198+ return wd, fmt.Errorf("failed to get current working directory: %w", err)
199199+ }
200200+ wd = strings.ReplaceAll(wd, "/test/e2e", "")
201201+ return wd, nil
202202+}
203203+204204+// UncommentCode searches for target in the file and remove the comment prefix
205205+// of the target content. The target content may span multiple lines.
206206+func UncommentCode(filename, target, prefix string) error {
207207+ // false positive
208208+ // nolint:gosec
209209+ content, err := os.ReadFile(filename)
210210+ if err != nil {
211211+ return fmt.Errorf("failed to read file %q: %w", filename, err)
212212+ }
213213+ strContent := string(content)
214214+215215+ idx := strings.Index(strContent, target)
216216+ if idx < 0 {
217217+ return fmt.Errorf("unable to find the code %q to be uncomment", target)
218218+ }
219219+220220+ out := new(bytes.Buffer)
221221+ _, err = out.Write(content[:idx])
222222+ if err != nil {
223223+ return fmt.Errorf("failed to write to output: %w", err)
224224+ }
225225+226226+ scanner := bufio.NewScanner(bytes.NewBufferString(target))
227227+ if !scanner.Scan() {
228228+ return nil
229229+ }
230230+ for {
231231+ if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil {
232232+ return fmt.Errorf("failed to write to output: %w", err)
233233+ }
234234+ // Avoid writing a newline in case the previous line was the last in target.
235235+ if !scanner.Scan() {
236236+ break
237237+ }
238238+ if _, err = out.WriteString("\n"); err != nil {
239239+ return fmt.Errorf("failed to write to output: %w", err)
240240+ }
241241+ }
242242+243243+ if _, err = out.Write(content[idx+len(target):]); err != nil {
244244+ return fmt.Errorf("failed to write to output: %w", err)
245245+ }
246246+247247+ // false positive
248248+ // nolint:gosec
249249+ if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil {
250250+ return fmt.Errorf("failed to write file %q: %w", filename, err)
251251+ }
252252+253253+ return nil
254254+}