Skip to content

Commit b6fa22e

Browse files
committed
✨ feat(security): add Windows VM support and Secret-based cloud-init storage
Implement comprehensive Windows VM support and enhance security through unified Kubernetes Secret storage for all VM credentials and initialization data. **Windows VM Support:** - Add Cloudbase-Init support for Windows VMs (pwsh shell) - Shell-based OS detection: bash → Linux, pwsh → Windows - OS-specific cloud-init generation (Linux: bcrypt, Windows: plaintext) - Cloudbase-Init plugin configuration and troubleshooting guide **Security Enhancements:** - Unified Kubernetes Secret storage for SSH credentials + cloud-init userdata - Use UserDataSecretRef to reference cloud-init from Secret (no inline userdata) - Passwords never visible in VM specs (kubectl describe vmi) - Secret cleanup in garbage collection (gc.go) to prevent accumulation - Secret cleanup in job cleanup phase (cleanup.go) **Testing & Documentation:** - Add comprehensive test suite (security_test.go) with 20+ sub-tests - Test coverage: password generation, bcrypt hashing, Linux/Windows cloud-init - Update README with Windows configuration examples and requirements - Document Secret structure and security benefits **Backward Compatibility:** - Zero breaking changes - existing Linux configs work unchanged - CreateSSHSecret maintained as backward-compatible alias
1 parent 1b510b7 commit b6fa22e

8 files changed

Lines changed: 715 additions & 10 deletions

File tree

README.md

Lines changed: 210 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ GitLab Runner custom executor for running CI/CD jobs in VMs on Kubernetes using
1212
- **KubeVirt Native**: Leverages Kubernetes for VM orchestration
1313
- **Custom Executor**: Integrates seamlessly with GitLab Runner
1414
- **Security Hardened**: Regular dependency updates and CVE scanning
15+
- **Secure Credential Management**: Kubernetes Secrets with RBAC protection
1516

1617
## Prerequisites
1718

@@ -44,8 +45,7 @@ runners:
4445
"--default-image", "registry.example.com/runner:latest",
4546
"--default-machine-type", "microvm",
4647
"--default-architecture", "x86_64",
47-
"--ssh-user", "runner",
48-
"--ssh-password", "runner"
48+
"--ssh-user", "runner"
4949
]
5050
run_exec = "/bin/gitlab-runner-kubevirt"
5151
run_args = ["run"]
@@ -124,13 +124,13 @@ build-heavy:
124124

125125
### Garbage Collection
126126

127-
Orphaned VMs are automatically tagged with TTL labels for cleanup:
127+
Orphaned VMs are automatically tagged with TTL labels for cleanup. The garbage collector automatically deletes both VMs and their associated credential Secrets (containing SSH credentials and cloud-init data):
128128

129129
```bash
130130
# List VMs with their expiration info
131131
kubectl get vmi -n gitlab-runner -l io.kubevirt.gitlab-runner/id --show-labels
132132
133-
# Manual cleanup of expired VMs
133+
# Manual cleanup of expired VMs (also deletes associated Secrets)
134134
gitlab-runner-kubevirt gc --namespace gitlab-runner
135135
136136
# Dry-run mode (see what would be deleted)
@@ -140,6 +140,8 @@ gitlab-runner-kubevirt gc --dry-run
140140
gitlab-runner-kubevirt gc --max-age 1h
141141
```
142142

143+
**Note:** Garbage collection cleans up both VirtualMachineInstances and their credential Secrets (containing SSH credentials and cloud-init userdata) to prevent Secret accumulation.
144+
143145
**Automated cleanup** with CronJob:
144146

145147
```yaml
@@ -180,6 +182,210 @@ The container image includes both `gitlab-runner` (standard GitLab Runner) and `
180182

181183
**Note:** When using the GitLab Runner Helm chart with custom executor configuration, you don't need to set this variable as the executor directly calls `/bin/gitlab-runner-kubevirt`.
182184

185+
### Security
186+
187+
#### SSH Credential Management
188+
189+
GitLab Runner KubeVirt uses a secure credential management system to protect SSH access to VMs:
190+
191+
**Automatic Security Features:**
192+
193+
1. **Unique Random Passwords**: Each VM receives a cryptographically secure 32-character random password
194+
2. **Kubernetes Secrets**: Credentials and cloud-init data stored in RBAC-protected Secrets (not plaintext in VM specs)
195+
3. **Cloud-init Injection**: Cloud-init userdata stored as Secret, referenced by VM (passwords never visible in VM objects)
196+
4. **Automatic Cleanup**: Secrets automatically deleted when VMs are cleaned up
197+
5. **No Plaintext Storage**: Passwords never stored in VM annotations, logs, or VM specifications
198+
199+
**How it works:**
200+
201+
```
202+
Prepare Phase:
203+
1. Generate random password (crypto/rand)
204+
2. Generate cloud-init with bcrypt-hashed password (Linux) or plaintext (Windows)
205+
3. Create Kubernetes Secret with:
206+
- SSH username and password (for Run phase)
207+
- Cloud-init userdata (for VM initialization)
208+
4. Create VM with Secret reference (not inline userdata)
209+
5. Store only Secret reference in VM annotation
210+
211+
Run Phase:
212+
1. Retrieve Secret reference from VM annotation
213+
2. Fetch SSH credentials from Kubernetes Secret
214+
3. Connect to VM via SSH
215+
216+
Cleanup Phase:
217+
1. Retrieve Secret reference from VM annotation
218+
2. Delete Kubernetes Secret (removes both SSH creds and cloud-init data)
219+
3. Delete VM
220+
```
221+
222+
**RBAC Requirements:**
223+
224+
The GitLab Runner service account requires these permissions for Secret management:
225+
226+
```yaml
227+
apiVersion: rbac.authorization.k8s.io/v1
228+
kind: Role
229+
metadata:
230+
name: gitlab-runner-kubevirt
231+
namespace: gitlab-runner
232+
rules:
233+
# VirtualMachineInstance permissions
234+
- apiGroups: ["kubevirt.io"]
235+
resources: ["virtualmachineinstances"]
236+
verbs: ["get", "list", "create", "delete", "watch"]
237+
238+
# Secret permissions for SSH credentials
239+
- apiGroups: [""]
240+
resources: ["secrets"]
241+
verbs: ["create", "get", "delete"]
242+
---
243+
apiVersion: rbac.authorization.k8s.io/v1
244+
kind: RoleBinding
245+
metadata:
246+
name: gitlab-runner-kubevirt
247+
namespace: gitlab-runner
248+
roleRef:
249+
apiGroup: rbac.authorization.k8s.io
250+
kind: Role
251+
name: gitlab-runner-kubevirt
252+
subjects:
253+
- kind: ServiceAccount
254+
name: gitlab-runner
255+
namespace: gitlab-runner
256+
```
257+
258+
**Secret Structure:**
259+
260+
Each VM gets a single Kubernetes Secret containing:
261+
262+
```yaml
263+
apiVersion: v1
264+
kind: Secret
265+
metadata:
266+
name: vm-creds-<job-id>
267+
labels:
268+
io.kubevirt.gitlab-runner/id: <job-id>
269+
io.kubevirt.gitlab-runner/type: vm-credentials
270+
stringData:
271+
user: runner # SSH username
272+
password: <random-32> # SSH password
273+
userdata: | # Cloud-init YAML
274+
#cloud-config
275+
users:
276+
- name: runner
277+
passwd: <bcrypt-hash> # Linux: hashed, Windows: plaintext
278+
...
279+
```
280+
281+
**Security Benefits:**
282+
283+
- **Single Secret Per VM**: One Secret contains all credentials (SSH + cloud-init)
284+
- **RBAC Protected**: Secret access controlled via Kubernetes RBAC policies
285+
- **No Plaintext in VM Specs**: Password never visible in `kubectl describe vmi`
286+
- **Scoped to Jobs**: Each CI/CD job gets unique credentials
287+
- **Automatic Cleanup**: Secret deleted when VM is cleaned up
288+
- **Audit Trail**: All Secret access logged by Kubernetes audit logs
289+
290+
#### Windows VM Support
291+
292+
GitLab Runner KubeVirt supports both Linux and Windows VMs with automatic OS detection based on the shell parameter:
293+
294+
**OS Detection:**
295+
296+
- `--shell bash` → Linux VM (cloud-init with bcrypt-hashed passwords)
297+
- `--shell pwsh` → Windows VM (Cloudbase-Init with plaintext passwords)
298+
299+
**Windows Requirements:**
300+
301+
Your Windows container disk images must include:
302+
303+
1. **Cloudbase-Init**: Windows port of cloud-init for VM initialization
304+
305+
- Download: https://cloudbase-init.readthedocs.io/en/latest/
306+
- Minimum version: 1.1.0+
307+
- **Required Configuration**: Must enable `SetUserPasswordPlugin` in `cloudbase-init.conf`:
308+
309+
```ini
310+
[DEFAULT]
311+
username=Administrator
312+
inject_user_password=true
313+
314+
plugins=cloudbaseinit.plugins.common.mtu.MTUPlugin,
315+
cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,
316+
cloudbaseinit.plugins.windows.createuser.CreateUserPlugin,
317+
cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin
318+
```
319+
320+
2. **OpenSSH Server**: For remote access via SSH
321+
322+
- Built-in on Windows Server 2019+ and Windows 10 1809+
323+
- Must be installed and configured to start automatically
324+
- Port 22 must be accessible
325+
326+
3. **PowerShell**: For script execution
327+
- PowerShell 5.1+ or PowerShell Core 7+
328+
329+
**Windows Configuration Example:**
330+
331+
```yaml
332+
runners:
333+
executor: custom
334+
config: |
335+
[[runners]]
336+
name = "kubevirt-windows"
337+
executor = "custom"
338+
339+
[runners.custom]
340+
config_exec = "/bin/gitlab-runner-kubevirt"
341+
config_args = ["config"]
342+
prepare_exec = "/bin/gitlab-runner-kubevirt"
343+
prepare_args = [
344+
"prepare",
345+
"--shell", "pwsh", # Windows indicator
346+
"--default-image", "registry.example.com/windows-server-2022:latest",
347+
"--ssh-user", "runner"
348+
]
349+
run_exec = "/bin/gitlab-runner-kubevirt"
350+
run_args = ["run"]
351+
cleanup_exec = "/bin/gitlab-runner-kubevirt"
352+
cleanup_args = ["cleanup"]
353+
```
354+
355+
**Security Notes for Windows:**
356+
357+
- Cloudbase-Init uses plaintext passwords (industry standard)
358+
- Credentials still protected by Kubernetes Secret RBAC
359+
- Random 32-character passwords meet Windows complexity requirements
360+
- Administrator group membership required for CI/CD operations
361+
362+
**Supported Windows Versions:**
363+
364+
- Windows Server 2019, 2022
365+
- Windows 10 version 1809+
366+
- Windows 11
367+
368+
**Troubleshooting Windows VM Initialization:**
369+
370+
If password authentication fails on Windows VMs, verify Cloudbase-Init configuration:
371+
372+
```powershell
373+
# Check Cloudbase-Init logs
374+
Get-Content "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log"
375+
376+
# Verify SetUserPasswordPlugin is enabled
377+
Get-Content "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf" | Select-String "SetUserPasswordPlugin"
378+
379+
# Check if inject_user_password is enabled
380+
Get-Content "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf" | Select-String "inject_user_password"
381+
```
382+
383+
Common issues:
384+
385+
- **Password not set**: `SetUserPasswordPlugin` not enabled in plugins list
386+
- **Random password instead of cloud-config password**: `inject_user_password=false` or not set
387+
- **User not created**: `CreateUserPlugin` not enabled in plugins list
388+
183389
## Development
184390

185391
### Quick Start

cleanup.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"strings"
@@ -43,6 +44,25 @@ func (cmd *CleanupCmd) Run(ctx context.Context, client kubevirt.KubevirtClient,
4344

4445
fmt.Fprintf(os.Stderr, "Deleting Virtual Machine instance %v\n", vm.ObjectMeta.Name)
4546

47+
// Delete SSH credentials Secret before deleting the VM
48+
var rc RunConfig
49+
if err := json.Unmarshal([]byte(vm.Annotations[RunConfigKey]), &rc); err == nil {
50+
if rc.SSH.SecretRef != "" {
51+
cfg, err := KubeConfig()
52+
if err != nil {
53+
fmt.Fprintf(os.Stderr, "Warning: failed to get kubernetes config for secret cleanup: %v\n", err)
54+
} else {
55+
if err := DeleteSSHSecret(ctx, cfg, jctx.Namespace, rc.SSH.SecretRef); err != nil {
56+
fmt.Fprintf(os.Stderr, "Warning: failed to delete SSH secret %s: %v\n", rc.SSH.SecretRef, err)
57+
} else {
58+
fmt.Fprintf(os.Stderr, "Deleted SSH credentials secret: %s\n", rc.SSH.SecretRef)
59+
}
60+
}
61+
}
62+
} else {
63+
fmt.Fprintf(os.Stderr, "Warning: failed to unmarshal RunConfig for secret cleanup: %v\n", err)
64+
}
65+
4666
deleteOptions := &metav1.DeleteOptions{}
4767

4868
if err := client.VirtualMachineInstance(jctx.Namespace).Delete(ctx, vm.ObjectMeta.Name, *deleteOptions); err != nil {

gc.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"time"
@@ -84,6 +85,21 @@ func (cmd *GCCmd) Run(ctx context.Context, jctx *JobContext) error {
8485
fmt.Fprintf(os.Stderr, "🗑️ [DRY-RUN] Would delete VM %s (age: %s, ttl: %s)\n", vm.Name, age.Round(time.Second), ttl)
8586
} else {
8687
fmt.Fprintf(os.Stderr, "🗑️ Deleting expired VM %s (age: %s, ttl: %s)\n", vm.Name, age.Round(time.Second), ttl)
88+
89+
// Delete SSH credentials Secret before deleting the VM
90+
var rc RunConfig
91+
if err := json.Unmarshal([]byte(vm.Annotations[RunConfigKey]), &rc); err == nil {
92+
if rc.SSH.SecretRef != "" {
93+
if err := DeleteSSHSecret(ctx, config, namespace, rc.SSH.SecretRef); err != nil {
94+
fmt.Fprintf(os.Stderr, "⚠️ Warning: failed to delete SSH secret %s: %v\n", rc.SSH.SecretRef, err)
95+
} else {
96+
fmt.Fprintf(os.Stderr, " Deleted SSH credentials secret: %s\n", rc.SSH.SecretRef)
97+
}
98+
}
99+
} else if vm.Annotations[RunConfigKey] != "" {
100+
fmt.Fprintf(os.Stderr, "⚠️ Warning: failed to unmarshal RunConfig for secret cleanup: %v\n", err)
101+
}
102+
87103
err := client.VirtualMachineInstance(namespace).Delete(ctx, vm.Name, metav1.DeleteOptions{})
88104
if err != nil {
89105
fmt.Fprintf(os.Stderr, "❌ Failed to delete VM %s: %v\n", vm.Name, err)

0 commit comments

Comments
 (0)