diff --git a/community/modules/scripts/vdi-setup/README.md b/community/modules/scripts/vdi-setup/README.md new file mode 100644 index 0000000000..18cd2fee32 --- /dev/null +++ b/community/modules/scripts/vdi-setup/README.md @@ -0,0 +1,248 @@ +## Description + +Creates a containerised Guacamole instance. Currently designed to work mainly with the Rocky image in the blueprint example below. Ubuntu/Debian support previously worked too but fixes may be required due to updates. + +## Features + +- **VDI Tool Support**: Currently supports Guacamole for web-based VDI access +- **User Provisioning**: Supports local user creation with secure password management and group management +- **VNC Integration**: Configures VNC servers for desktop access with port change support +- **Secret Manager Integration**: Secure password storage and retrieval for VDI users and webapp admin +- **VDI Monitoring System**: Automatic monitoring and reconfiguration capabilities with password reset flags and deployment status updates. [More info](roles/vdi_monitor/README.md). +- **Debug Mode**: Comprehensive logging when enabled via the `debug` variable +- **Port Change Support**: Handles both webapp port changes (container recreation) and user VNC port changes (SQL updates) +- **Resolution Control**: Configurable VDI resolution with browser scaling control via `vdi_resolution_locked` parameter + +## Secret Manager Integration + +The module integrates with Google Cloud Secret Manager for secure password handling: + +- **VDI Users**: Passwords are stored in Secret Manager with the pattern `vdi-user-password-{username}-{deployment_name}` +- **Webapp Admin**: Password is stored in Secret Manager with the pattern `webapp-server-password-{deployment_name}` (always stored, not just when reset is enabled) +- **Password Sources**: Users can specify `secret_name` to fetch existing passwords, provide `password` directly, or let the system generate random passwords +- **Reset Functionality**: Both individual user passwords (`reset_password`) and webapp admin password (`reset_webapp_admin_password`) can be forced to regenerate +- **Database Updates**: Password resets update existing database records without re-initializing the entire database + +## Port Change and User Group Management + +The module supports dynamic configuration changes without full redeployment: + +### **Webapp Port Changes** +- **Container Recreation**: When `vdi_webapp_port` changes, the Guacamole webapp container is recreated with the new port +- **Database Synchronization**: Database passwords are automatically synchronized during port changes +- **Service Continuity**: VNC services remain unaffected during webapp port changes + +### **User VNC Port Changes** +- **SQL Updates**: User VNC port changes are handled via database updates without container recreation +- **VNC Service Management**: VNC services are automatically stopped and restarted with new ports +- **X Server Cleanup**: Old X server processes are properly terminated before starting new ones + +### **User Group Management** +- **Dynamic Group Changes**: When `vdi_user_group` changes, users are automatically migrated to the new group +- **Group Removal**: Users are removed from the old group and added to the new group +- **Idempotent Operations**: Group changes are safe to apply multiple times + +## VDI Monitoring System + +The module includes a monitoring system that: + +- **Deployment Status**: Updates instance metadata to reflect deployment state (`available`, `reconfiguring`, `failed`) +- **Targeted Updates**: Performs database-only updates for password resets and user changes without container recreation +- **Password Reset Flags**: Supports `reset_password` for individual users and `reset_webapp_admin_password` for the webapp admin account +- **Automatic Reconfiguration**: Detects changes and triggers reconfiguration when needed +- **Status Tracking**: Maintains deployment status through instance metadata + +## Usage + +### Basic Configuration + +```yaml +blueprint_name: vdi-test + +vars: + deployment_name: vdi-test + project_id: * project name here * + region: us-central1 + zone: us-central1-a + +deployment_groups: +- group: primary + modules: + - id: network1 + source: modules/network/vpc + settings: + extra_iap_ports: [8080] + firewall_rules: + - name: allow-guacamole-8080-ext + description: Allow external ingress to Guacamole on TCP port 8080 + direction: INGRESS + ranges: ["0.0.0.0/0"] + allow: + - protocol: tcp + ports: ["8080"] + + - id: enable-apis + source: community/modules/project/service-enablement + settings: + gcp_service_list: + - secretmanager.googleapis.com + - storage.googleapis.com + - compute.googleapis.com + + - id: vdi-setup + source: community/modules/scripts/vdi-setup + settings: + vnc_flavor: tigervnc + vdi_tool: guacamole + vdi_user_group: vdiusers + vdi_resolution: 1920x1080 + vdi_resolution_locked: true + # Enable debug mode for verbose logging + debug: true + # Force reset of webapp admin password (optional) + reset_webapp_admin_password: false + user_provision: local_users + vdi_users: + # Alice: password generated and saved to Secret Manager in deployment project + - username: alice + port: 5901 + # Bob: existing password is retrieved from Secret Manager in deployment project + - username: bob + port: 5902 + secret_name: a-password-for-bob + # Charlie: password retrieved from Secret Manager in a different project + - username: charlie + port: 5903 + secret_name: charlie-password + secret_project: another-project-id + # David: auto-generated password with reset flag (triggers password regeneration) + - username: david + port: 5904 + reset_password: true + + - id: guac_vm + source: modules/compute/vm-instance + settings: + instance_image: + family: hpc-rocky-linux-8 + project: cloud-hpc-image-public + #family: debian-11 + #project: debian-cloud + #family: ubuntu-2204-lts + #project: ubuntu-os-cloud + name_prefix: guacamole + add_deployment_name_before_prefix: true + machine_type: e2-highcpu-8 + tags: ["guacamole"] + use: + - network1 + - vdi-setup +``` + +[NVIDIA vWS drivers](https://cloud.google.com/compute/docs/gpus/grid-drivers-table) will be automatically installed on [supported machine types](https://cloud.google.com/compute/docs/gpus#gpu-virtual-workstations). + +Important note: Before deploying the above example you would need to ensure the `secret_name` exists in your local project, and that your service account has sufficient access permissions if retrieving the secret from a separate `secret_project`. The following two example commands show how you can create the two example user's respective secrets: + +```bash +# Create secrets for each user in the deployment project +echo -n "BobPassword123" | gcloud secrets create a-password-for-bob --data-file=- + +# Or create secrets in a different project (if using secret_project) +echo -n "CharliePassword123" | gcloud secrets create charlie-password --data-file=- --project=another-project-id +``` + +### Accessing VDI + +After deployment, you can access the VDI in several ways: + +1. **Web Interface** (for Guacamole): + - Access web interface: + - http://$VM_PUBLIC_IP:8080/guacamole/#/ + - Note: It is not advisable to serve the web interface directly from a public IP in production environments. You should consider placing the VDI behind a reverse proxy, load balancer, or tunnel to it directly over IAP (see below). + + - Admin credentials: + - Username: `guacadmin` (for Guacamole) + - Password: Retrieve the `webapp-server-password-{deployment_name}` secret from Secret Manager + +2. **User VDI Access**: + - Each user's credentials are stored in Secret Manager + - Secret names follow the pattern: `vdi-user-password-{username}-{deployment_name}` for auto-generated passwords + - Or use the custom secret name specified in the `vdi_users` configuration + +3. **IAP Tunnel** (for development/testing): + + ```bash + gcloud compute start-iap-tunnel vdi-test-0 8080 \ + --local-host-port=localhost:8080 \ + --zone=us-central1-a + ``` + + - The web interface will then be accessible from http://localhost:8080/guacamole/ (for Guacamole) + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0 | +| [archive](#requirement\_archive) | ~> 2.0 | +| [google](#requirement\_google) | >= 3.83 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [archive](#provider\_archive) | ~> 2.0 | +| [google](#provider\_google) | >= 3.83 | +| [random](#provider\_random) | ~> 3.0 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [startup\_script](#module\_startup\_script) | ../../../../modules/scripts/startup-script | n/a | + +## Resources + +| Name | Type | +|------|------| +| [google_storage_bucket.bucket](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket) | resource | +| [random_id.resource_name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [terraform_data.input_validation](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [archive_file.roles_tar](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [debug](#input\_debug) | Enable debug mode for verbose logging during VDI setup. | `bool` | `false` | no | +| [deployment\_name](#input\_deployment\_name) | The name of the deployment. | `string` | n/a | yes | +| [force\_rerun](#input\_force\_rerun) | Force complete container recreation and database re-initialization, bypassing all idempotency checks. Use only when troubleshooting or when the system is in a broken state. | `bool` | `false` | no | +| [labels](#input\_labels) | Key-value pairs of labels to be added to created resources. | `map(string)` | n/a | yes | +| [project\_id](#input\_project\_id) | Project in which the HPC deployment will be created. | `string` | n/a | yes | +| [region](#input\_region) | Region to place bucket containing startup script. | `string` | n/a | yes | +| [reset\_webapp\_admin\_password](#input\_reset\_webapp\_admin\_password) | Force reset of the webapp admin password during reconfiguration. If true, a new password will be generated and stored in Secret Manager, even if an existing password exists. | `bool` | `false` | no | +| [user\_provision](#input\_user\_provision) | User type to create (local\_users supported. os-login to do. | `string` | `"local_users"` | no | +| [vdi\_resolution](#input\_vdi\_resolution) | Desktop resolution for VNC sessions (e.g. 1920x1080). | `string` | `"1920x1080"` | no | +| [vdi\_resolution\_locked](#input\_vdi\_resolution\_locked) | Disable resize of remote display in Guacamole connections. When true, VDI displays at native resolution without browser scaling. | `bool` | `true` | no | +| [vdi\_tool](#input\_vdi\_tool) | VDI tool to deploy (guacamole currently supported). | `string` | `"guacamole"` | no | +| [vdi\_user\_group](#input\_vdi\_user\_group) | Unix group to create/use for VDI users. | `string` | `"vdiusers"` | no | +| [vdi\_users](#input\_vdi\_users) | List of VDI users to configure. Passwords are handled securely by the Ansible roles: if secret\_name is provided, the password is fetched from Secret Manager; if neither password nor secret\_name is provided, a random password is generated and stored in Secret Manager. If secret\_project is provided, it specifies the GCP project where the secret is stored (defaults to the deployment project). Set reset\_password to true to trigger password regeneration for auto-generated passwords. |
list(object({
username = string
port = number
secret_name = optional(string)
secret_project = optional(string)
reset_password = optional(bool)
})) | `[]` | no |
+| [vdi\_webapp\_port](#input\_vdi\_webapp\_port) | Port to serve the Webapp interface from if applicable (note: containers will be recreated if changed) | `string` | `"8080"` | no |
+| [vnc\_flavor](#input\_vnc\_flavor) | The VNC server flavor to use (tigervnc currently supported) | `string` | `"tigervnc"` | no |
+| [vnc\_port\_max](#input\_vnc\_port\_max) | Maximum valid VNC port. | `number` | `5999` | no |
+| [vnc\_port\_min](#input\_vnc\_port\_min) | Minimum valid VNC port. | `number` | `5901` | no |
+| [zone](#input\_zone) | Zone in which the VDI instances are created. | `string` | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [guacamole\_admin\_password\_secret](#output\_guacamole\_admin\_password\_secret) | The name of the Secret Manager secret containing the Guacamole admin password |
+| [guacamole\_admin\_username](#output\_guacamole\_admin\_username) | The admin username for Guacamole |
+| [startup\_script](#output\_startup\_script) | Combined startup script that installs VDI (VNC, Guacamole, users). |
+| [vdi\_runner](#output\_vdi\_runner) | Shell runner wrapping Ansible playbook + roles (for custom-image or direct use). |
+| [vdi\_user\_credentials](#output\_vdi\_user\_credentials) | Map of VDI user credentials stored in Secret Manager |
+
diff --git a/community/modules/scripts/vdi-setup/main.tf b/community/modules/scripts/vdi-setup/main.tf
new file mode 100644
index 0000000000..4ddb5a5215
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/main.tf
@@ -0,0 +1,153 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+locals {
+ # This label allows for billing report tracking based on module.
+ labels = merge(var.labels, { ghpc_module = "vdi-setup", ghpc_role = "scripts" })
+}
+
+# Create a tar.gz of roles/ directory
+data "archive_file" "roles_tar" {
+ type = "tar.gz"
+ source_dir = "${path.module}/roles"
+ output_path = "${path.module}/roles.tar.gz"
+}
+
+resource "random_id" "resource_name_suffix" {
+ byte_length = 4
+}
+
+# Generate vars content using templatefile
+locals {
+ vdi_vars_content = templatefile("${path.module}/templates/vars.yaml.tftpl", {
+ deployment_name = var.deployment_name
+ project_id = var.project_id
+ user_provision = var.user_provision
+ vnc_flavor = var.vnc_flavor
+ vdi_tool = var.vdi_tool
+ vdi_user_group = var.vdi_user_group
+ vdi_webapp_port = var.vdi_webapp_port
+ vdi_resolution = var.vdi_resolution
+ vdi_resolution_locked = var.vdi_resolution_locked
+ vdi_users = var.vdi_users
+ debug = var.debug
+ reset_webapp_admin_password = var.reset_webapp_admin_password
+ force_rerun = var.force_rerun
+ vdi_bucket_name = local.bucket_name
+ zone = var.zone
+ })
+}
+
+# Assemble runners
+locals {
+ runners = [
+ # Install dependencies
+ {
+ type = "shell"
+ destination = "install-deps.sh"
+ content = <<-EOT
+ #!/bin/bash
+ set -eux
+ /usr/local/ghpc-venv/bin/python3 -m pip install requests google-auth docker
+ ansible-galaxy collection install google.cloud
+ EOT
+ },
+ # Stage roles.tar.gz to temporary location
+ {
+ type = "data"
+ source = data.archive_file.roles_tar.output_path
+ destination = "/tmp/vdi/roles.tar.gz"
+ },
+
+ # Unpack into /opt/vdi-setup/roles (final location)
+ {
+ type = "shell"
+ destination = "unpack_roles.sh"
+ content = <<-EOT
+ #!/bin/bash
+ set -eux
+ mkdir -p /opt/vdi-setup/roles
+ tar xzf /tmp/vdi/roles.tar.gz -C /opt/vdi-setup/roles
+ # Clean up temporary file
+ rm -f /tmp/vdi/roles.tar.gz
+ EOT
+ },
+
+ # write out vars file as YAML to final location
+ {
+ type = "data"
+ content = local.vdi_vars_content
+ destination = "/opt/vdi-setup/vars.yaml"
+ },
+
+ # Run the rendered playbook via ansible-local from final location
+ {
+ type = "ansible-local"
+ content = templatefile("${path.module}/templates/install.yaml.tftpl",
+ {
+ roles = ["lock_manager", "base_os", "secret_manager", "user_provision", "vnc", "vdi_tool", "vdi_monitor"],
+ }
+ )
+ destination = "/opt/vdi-setup/install.yaml"
+ args = var.debug ? "--extra-vars @/opt/vdi-setup/vars.yaml -v --extra-vars debug=true" : "--extra-vars @/opt/vdi-setup/vars.yaml"
+ },
+ # Clean up temporary directory
+ {
+ type = "shell"
+ destination = "cleanup.sh"
+ content = <<-EOT
+ #!/bin/bash
+ set -eux
+ rm -rf /tmp/vdi
+ EOT
+ },
+ ]
+
+ bucket_name = "${substr(var.deployment_name, 0, 39)}-vdi-scripts-${random_id.resource_name_suffix.hex}"
+}
+
+# Bucket to stage runners
+resource "google_storage_bucket" "bucket" {
+ labels = local.labels
+ project = var.project_id
+ name = local.bucket_name
+ location = var.region
+ uniform_bucket_level_access = true
+ storage_class = "REGIONAL"
+}
+
+# Use the startup-script module to push and execute them
+module "startup_script" {
+ labels = local.labels
+ source = "../../../../modules/scripts/startup-script"
+ project_id = var.project_id
+ deployment_name = var.deployment_name
+ region = var.region
+
+ runners = local.runners
+ gcs_bucket_path = "gs://${google_storage_bucket.bucket.name}"
+
+ docker = {
+ enabled = true
+ }
+}
+
+# Expose the combined startup script
+locals {
+ combined_runner = {
+ type = "shell"
+ content = module.startup_script.startup_script
+ destination = "install-vdi-and-setup.sh"
+ }
+}
diff --git a/community/modules/scripts/vdi-setup/outputs.tf b/community/modules/scripts/vdi-setup/outputs.tf
new file mode 100644
index 0000000000..4e8fa9f21e
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/outputs.tf
@@ -0,0 +1,44 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+output "startup_script" {
+ description = "Combined startup script that installs VDI (VNC, Guacamole, users)."
+ value = module.startup_script.startup_script
+}
+
+output "vdi_runner" {
+ description = "Shell runner wrapping Ansible playbook + roles (for custom-image or direct use)."
+ value = local.combined_runner
+}
+
+output "guacamole_admin_username" {
+ description = "The admin username for Guacamole"
+ value = "guacadmin"
+}
+
+output "guacamole_admin_password_secret" {
+ description = "The name of the Secret Manager secret containing the Guacamole admin password"
+ value = "webapp-server-password-${var.deployment_name}"
+}
+
+output "vdi_user_credentials" {
+ description = "Map of VDI user credentials stored in Secret Manager"
+ value = {
+ for user in var.vdi_users : user.username => {
+ username = user.username
+ port = user.port
+ secret_name = user.secret_name != null ? user.secret_name : "vdi-user-password-${user.username}-${var.deployment_name}"
+ }
+ }
+}
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/debian.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/debian.yaml
new file mode 100644
index 0000000000..b2999e86ec
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/debian.yaml
@@ -0,0 +1,41 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Set Debian specific variables
+ ansible.builtin.set_fact:
+ gui_packages: ['xfce4', 'xserver-xorg-core', 'dbus-x11']
+ vnc_pkg: >-
+ {%- if vnc_flavor|lower == 'tigervnc' -%}
+ tigervnc-standalone-server
+ {%- elif vnc_flavor|lower == 'tightvnc' -%}
+ tightvnc-standalone-server
+ {%- else -%}
+ tightvnc-standalone-server
+ {%- endif %}
+ xorg_dev_packages: ['pkg-config', 'xserver-xorg-dev', 'libx11-dev', 'libxext-dev', 'libglvnd-dev', 'build-essential']
+
+- name: Install desktop prerequisites & VNC package (Debian)
+ ansible.builtin.package:
+ name: "{{ gui_packages + [vnc_pkg] + xorg_dev_packages }}"
+ state: present
+ retries: 10
+ delay: 3
+
+- name: Install kernel headers for running kernel (Debian)
+ ansible.builtin.package:
+ name: "linux-headers-{{ ansible_kernel }}"
+ state: present
+ retries: 10
+ delay: 3
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup.yaml
new file mode 100644
index 0000000000..3300920687
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup.yaml
@@ -0,0 +1,69 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# NVIDIA vWS (GRID) driver setup. See:
+# https://cloud.google.com/compute/docs/gpus#gpu-virtual-workstations
+# https://cloud.google.com/compute/docs/gpus/grid-drivers-table
+
+---
+
+- name: Get GCP machine type from metadata server
+ ansible.builtin.uri:
+ url: http://metadata.google.internal/computeMetadata/v1/instance/machine-type
+ method: GET
+ headers:
+ Metadata-Flavor: Google
+ return_content: yes
+ register: gcp_machine_type
+
+- name: Extract short machine type
+ ansible.builtin.set_fact:
+ gcp_machine_type: "{{ gcp_machine_type.content.split('/')[-1] }}"
+
+- name: Extract machine family from machine type
+ ansible.builtin.set_fact:
+ machine_family: "{{ gcp_machine_type.split('-')[0] }}"
+
+- name: Check if machine type is supported for NVIDIA vWS
+ ansible.builtin.set_fact:
+ is_nvidia_vws_supported: "{{ machine_family in ['g2', 'n1'] }}"
+
+# Additional drivers for other machine type families can go here
+- name: Set driver download URL based on machine type
+ ansible.builtin.set_fact:
+ driver_url: >-
+ {%- if is_nvidia_vws_supported -%}
+ https://storage.googleapis.com/nvidia-drivers-us-public/GRID/vGPU18.1/NVIDIA-Linux-x86_64-570.133.20-grid.run
+ {%- else -%}
+
+ {%- endif %}
+
+# Include distribution-specific GPU driver setup
+- name: Include Ubuntu GPU driver setup
+ ansible.builtin.include_tasks: gpu_driver_setup/ubuntu.yaml
+ when:
+ - ansible_facts.distribution == 'Ubuntu'
+ - is_nvidia_vws_supported
+
+- name: Include Rocky Linux GPU driver setup
+ ansible.builtin.include_tasks: gpu_driver_setup/rocky.yaml
+ when:
+ - ansible_facts.distribution == 'Rocky'
+ - is_nvidia_vws_supported
+
+- name: Include Debian GPU driver setup
+ ansible.builtin.include_tasks: gpu_driver_setup/debian.yaml
+ when:
+ - ansible_facts.distribution == 'Debian'
+ - is_nvidia_vws_supported
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/debian.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/debian.yaml
new file mode 100644
index 0000000000..b99917397f
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/debian.yaml
@@ -0,0 +1,30 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+# Block for NVIDIA vWS driver setup (Debian)
+- block:
+ - name: Download NVIDIA vWS driver
+ ansible.builtin.uri:
+ url: "{{ driver_url }}"
+ method: GET
+ dest: /tmp/nvidia-driver.run
+ mode: '0755'
+ status_code:
+ - 200
+ - 304
+ - name: Install NVIDIA vWS driver (Debian)
+ ansible.builtin.command:
+ cmd: /tmp/nvidia-driver.run --dkms --silent
+ creates: /usr/bin/nvidia-smi
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/rocky.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/rocky.yaml
new file mode 100644
index 0000000000..0c78bc330f
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/rocky.yaml
@@ -0,0 +1,36 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+# Block for NVIDIA vWS driver setup (Rocky Linux)
+- block:
+ - name: Install NVIDIA Vulkan ICD loader (Rocky Linux)
+ ansible.builtin.package:
+ name: vulkan-loader
+ state: present
+ retries: 10
+ delay: 3
+ - name: Download NVIDIA vWS driver
+ ansible.builtin.uri:
+ url: "{{ driver_url }}"
+ method: GET
+ dest: /tmp/nvidia-driver.run
+ mode: '0755'
+ status_code:
+ - 200
+ - 304
+ - name: Install NVIDIA vWS driver (Rocky Linux)
+ ansible.builtin.command:
+ cmd: /tmp/nvidia-driver.run --dkms --silent
+ creates: /usr/bin/nvidia-smi
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/ubuntu.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/ubuntu.yaml
new file mode 100644
index 0000000000..fe7cae4439
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/gpu_driver_setup/ubuntu.yaml
@@ -0,0 +1,73 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+# Block for NVIDIA vWS driver setup (Ubuntu)
+- block:
+ - name: Download NVIDIA vWS driver
+ ansible.builtin.uri:
+ url: "{{ driver_url }}"
+ method: GET
+ dest: /tmp/nvidia-driver.run
+ mode: '0755'
+ status_code:
+ - 200
+ - 304
+
+ - name: Verify GCC version before driver installation
+ ansible.builtin.shell: gcc --version | head -1
+ register: gcc_version_check
+ changed_when: false
+
+ - name: Debug GCC version before driver installation
+ ansible.builtin.debug:
+ msg: "GCC version before driver installation: {{ gcc_version_check.stdout }}"
+
+ - name: Install NVIDIA vWS driver (Ubuntu)
+ ansible.builtin.command:
+ cmd: /tmp/nvidia-driver.run --dkms --silent
+ creates: /usr/bin/nvidia-smi
+ register: nvidia_install_result
+ failed_when: false
+
+ - name: Check if NVIDIA driver installation succeeded
+ ansible.builtin.stat:
+ path: /usr/bin/nvidia-smi
+ register: nvidia_smi_check
+
+ - name: Debug NVIDIA installation result
+ ansible.builtin.debug:
+ msg:
+ - "NVIDIA installer exit code: {{ nvidia_install_result.rc }}"
+ - "nvidia-smi exists: {{ nvidia_smi_check.stat.exists }}"
+ - "Installation stdout: {{ nvidia_install_result.stdout }}"
+ - "Installation stderr: {{ nvidia_install_result.stderr }}"
+
+ - name: Fail if NVIDIA driver installation failed
+ ansible.builtin.fail:
+ msg: "NVIDIA driver installation failed. Check /var/log/nvidia-installer.log for details."
+ when:
+ - nvidia_install_result.rc != 0
+ - not nvidia_smi_check.stat.exists
+
+ - name: Verify NVIDIA driver installation
+ ansible.builtin.command: nvidia-smi
+ register: nvidia_smi_test
+ changed_when: false
+ when: nvidia_smi_check.stat.exists
+
+ - name: Debug NVIDIA driver verification
+ ansible.builtin.debug:
+ msg: "NVIDIA driver verification: {{ nvidia_smi_test.stdout }}"
+ when: nvidia_smi_check.stat.exists
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/main.yaml
new file mode 100644
index 0000000000..232a33abc5
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/main.yaml
@@ -0,0 +1,119 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: DEBUG About to import lock_manager for base_os
+ debug:
+ msg: "About to import lock_manager for base_os"
+
+# Check if this role should run
+- name: Set current role for lock manager check
+ ansible.builtin.set_fact:
+ current_role: "base_os"
+
+- name: Check if base_os role should run
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: check_lock
+
+- name: DEBUG Finished import_role for base_os
+ debug:
+ msg: "Finished import_role for base_os"
+
+- name: Set role variables from lock manager facts
+ ansible.builtin.set_fact:
+ role_should_run: "{{ role_should_run | default(false) }}"
+ is_fresh_deployment: "{{ is_fresh_deployment | default(false) }}"
+ deployment_hash: "{{ current_deployment_hash | default('none') }}"
+ user_hash: "{{ current_user_secrets_hash | default('none') }}"
+ lock_file_stat: "{{ lock_file_stat | default({}) }}"
+ vdi_setup_status: "{{ vdi_setup_status | default({}) }}"
+
+- name: Debug role variables
+ ansible.builtin.debug:
+ msg: |
+ Base OS role variables:
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - deployment_hash: {{ deployment_hash }}
+ - user_hash: {{ user_hash }}
+ - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }}
+ when: debug
+
+# Skip all tasks if role should not run
+- name: Skip base_os tasks if role should not run
+ ansible.builtin.debug:
+ msg: "Skipping base_os role - already completed or not needed"
+ when: not role_should_run
+
+# Run base_os tasks only if needed
+- name: Run base_os tasks
+ block:
+
+ - name: Gather distribution facts
+ ansible.builtin.setup:
+ gather_subset: platform
+
+ # GPU / vWS driver setup
+ - name: Check if NVIDIA GPU is present
+ ansible.builtin.shell: lspci | grep -i nvidia
+ register: nvidia_gpu_present
+ failed_when: false
+
+ # Include distribution-specific tasks
+ - name: Include Ubuntu-specific tasks
+ ansible.builtin.include_tasks: ubuntu.yaml
+ when: ansible_facts.distribution == 'Ubuntu'
+
+ - name: Include Rocky Linux specific tasks
+ ansible.builtin.include_tasks: rocky.yaml
+ when: ansible_facts.distribution == 'Rocky'
+
+ - name: Include Debian-specific tasks
+ ansible.builtin.include_tasks: debian.yaml
+ when: ansible_facts.distribution == 'Debian'
+
+ # Common tasks for all distributions
+ - name: Ensure requests package is installed
+ pip:
+ name: requests
+ state: present
+
+ - name: Ensure Python & tooling are installed
+ ansible.builtin.package:
+ name:
+ - python3
+ - python3-pip
+ - jq
+ state: present
+
+ # GPU / vWS driver setup
+ - name: Provision GPU setup
+ ansible.builtin.include_tasks: gpu_driver_setup.yaml
+ when: nvidia_gpu_present.rc == 0
+
+ when: role_should_run
+
+# Mark role as completed
+- name: Set current role for lock manager completion
+ ansible.builtin.set_fact:
+ current_role: "base_os"
+ role_completed: true
+ when: role_should_run
+
+- name: Mark base_os role as completed
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: create_lock
+ when: role_should_run
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/rocky.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/rocky.yaml
new file mode 100644
index 0000000000..c7d1b8c9a6
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/rocky.yaml
@@ -0,0 +1,42 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Set Rocky Linux specific variables
+ ansible.builtin.set_fact:
+ gui_packages: ['@Xfce', '@base-x']
+ vnc_pkg: >-
+ {%- if vnc_flavor|lower == 'tigervnc' -%}
+ tigervnc-server
+ {%- elif vnc_flavor|lower == 'tightvnc' -%}
+ tightvnc-server
+ {%- else -%}
+ tightvnc-standalone-server
+ {%- endif %}
+ xorg_dev_packages: ['pkgconfig', 'xorg-x11-server-devel', 'xorg-x11-util-macros', 'libglvnd-devel']
+
+- name: Update repository cache & install EPEL (Rocky Linux only)
+ ansible.builtin.package:
+ name: epel-release
+ state: present
+ update_cache: yes
+ retries: 10
+ delay: 3
+
+- name: Install desktop prerequisites & VNC package (Rocky Linux)
+ ansible.builtin.package:
+ name: "{{ gui_packages + [vnc_pkg] + xorg_dev_packages }}"
+ state: present
+ retries: 10
+ delay: 3
diff --git a/community/modules/scripts/vdi-setup/roles/base_os/tasks/ubuntu.yaml b/community/modules/scripts/vdi-setup/roles/base_os/tasks/ubuntu.yaml
new file mode 100644
index 0000000000..4f1a2ec070
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/base_os/tasks/ubuntu.yaml
@@ -0,0 +1,138 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Set Ubuntu specific variables
+ ansible.builtin.set_fact:
+ gui_packages: ['xfce4', 'xserver-xorg-core']
+ vnc_pkg: >-
+ {%- if vnc_flavor|lower == 'tigervnc' -%}
+ tigervnc-standalone-server
+ {%- elif vnc_flavor|lower == 'tightvnc' -%}
+ tightvnc-standalone-server
+ {%- else -%}
+ tightvnc-standalone-server
+ {%- endif %}
+ xorg_dev_packages: ['pkg-config', 'xserver-xorg-dev', 'libx11-dev', 'libxext-dev', 'libglvnd-dev']
+
+- name: Install desktop prerequisites & VNC package (Ubuntu)
+ ansible.builtin.package:
+ name: "{{ gui_packages + [vnc_pkg] + xorg_dev_packages }}"
+ state: present
+ retries: 10
+ delay: 3
+
+- name: Install kernel headers for running kernel (Ubuntu)
+ ansible.builtin.package:
+ name: "linux-headers-{{ ansible_kernel }}"
+ state: present
+ retries: 10
+ delay: 3
+
+# GCC version detection and installation for NVIDIA driver compatibility
+- name: Get kernel build GCC version
+ ansible.builtin.shell: cat /proc/version | grep -o 'gcc-[0-9]*' | head -1
+ register: kernel_gcc_version
+ changed_when: false
+
+- name: Extract GCC major version from kernel
+ ansible.builtin.set_fact:
+ required_gcc_version: "{{ kernel_gcc_version.stdout.split('-')[1] if kernel_gcc_version.stdout else '11' }}"
+
+- name: Debug kernel GCC version extraction
+ ansible.builtin.debug:
+ msg:
+ - "Raw kernel GCC version: {{ kernel_gcc_version.stdout }}"
+ - "Extracted required GCC version: {{ required_gcc_version }}"
+
+- name: Get current GCC version
+ ansible.builtin.shell: gcc --version | head -1 | grep -o '[0-9]*\.[0-9]*' | head -1
+ register: current_gcc_version
+ changed_when: false
+
+- name: Extract current GCC major version
+ ansible.builtin.set_fact:
+ current_gcc_major: "{{ current_gcc_version.stdout.split('.')[0] if current_gcc_version.stdout else '11' }}"
+
+- name: Debug current GCC version extraction
+ ansible.builtin.debug:
+ msg:
+ - "Raw current GCC version: {{ current_gcc_version.stdout }}"
+ - "Extracted current GCC major: {{ current_gcc_major }}"
+
+- name: Debug GCC version information
+ ansible.builtin.debug:
+ msg:
+ - "Kernel was built with GCC {{ required_gcc_version }}"
+ - "Current system GCC version: {{ current_gcc_major }}"
+ - "GCC update needed: {{ required_gcc_version != current_gcc_major }}"
+
+- name: Install required GCC version if different from current
+ block:
+ - name: Install GCC {{ required_gcc_version }}
+ ansible.builtin.package:
+ name: "gcc-{{ required_gcc_version }}"
+ state: present
+ retries: 10
+ delay: 3
+
+ - name: Install G++ {{ required_gcc_version }}
+ ansible.builtin.package:
+ name: "g++-{{ required_gcc_version }}"
+ state: present
+ retries: 10
+ delay: 3
+
+ - name: Set GCC {{ required_gcc_version }} as default
+ ansible.builtin.alternatives:
+ name: gcc
+ path: "/usr/bin/gcc-{{ required_gcc_version }}"
+ priority: "{{ required_gcc_version }}"
+ link: /usr/bin/gcc
+
+ - name: Set G++ {{ required_gcc_version }} as default
+ ansible.builtin.alternatives:
+ name: g++
+ path: "/usr/bin/g++-{{ required_gcc_version }}"
+ priority: "{{ required_gcc_version }}"
+ link: /usr/bin/g++
+
+ - name: Verify GCC version after update
+ ansible.builtin.shell: gcc --version | head -1
+ register: updated_gcc_version
+ changed_when: false
+
+ - name: Debug updated GCC version
+ ansible.builtin.debug:
+ msg: "Updated GCC version: {{ updated_gcc_version.stdout }}"
+
+ - name: Verify G++ version after update
+ ansible.builtin.shell: g++ --version | head -1
+ register: updated_gpp_version
+ changed_when: false
+
+ - name: Debug updated G++ version
+ ansible.builtin.debug:
+ msg: "Updated G++ version: {{ updated_gpp_version.stdout }}"
+
+ - name: Verify alternatives are set correctly
+ ansible.builtin.shell: update-alternatives --display gcc
+ register: gcc_alternatives_check
+ changed_when: false
+
+ - name: Debug GCC alternatives status
+ ansible.builtin.debug:
+ msg: "GCC alternatives status: {{ gcc_alternatives_check.stdout }}"
+
+ when: required_gcc_version != current_gcc_major
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/README.md b/community/modules/scripts/vdi-setup/roles/lock_manager/README.md
new file mode 100644
index 0000000000..3111b2572c
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/README.md
@@ -0,0 +1,150 @@
+# Lock Manager Role
+
+The `lock_manager` role is the central coordination mechanism for the VDI setup module. It manages deployment state, change detection, and ensures idempotent role execution.
+
+## Overview
+
+The lock manager uses a hash-based change detection system to determine when roles should run, preventing unnecessary re-execution while ensuring changes are properly applied.
+
+## Key Features
+
+### 1. **Hash-Based Change Detection**
+
+- **Deployment Hash**: Tracks changes to deployment configuration (ports, tools, etc.)
+- **User Hash**: Tracks changes to user configurations (additions, removals, password resets)
+- **Smart Detection**: Only re-runs roles when relevant changes are detected
+
+### 2. **Centralized Variable Management**
+
+- **User Management Variables**: Calculates and propagates user change information to all roles
+- **Port Change Detection**: Detects and tracks VNC port changes for existing users
+- **Password Reset Tracking**: Identifies users requiring password resets
+
+### 3. **Targeted Updates**
+
+- **Database-Only Updates**: Performs user additions/deletions/password resets without container recreation
+- **Port Change Handling**: Updates VNC ports and Guacamole connections without full re-initialization
+- **Webapp Admin Password Reset**: Updates admin password without container recreation
+
+### 4. **Deployment Status Management**
+
+- **Lock File**: `.vdi-lock.yaml` tracks deployment state and completed roles
+- **Status Tracking**: `available`, `configuring`, `reconfiguring`, `failed` states
+- **Role Completion**: Tracks which roles have completed successfully
+
+## Variable Propagation
+
+### **Variables Calculated by Lock Manager:**
+
+| Variable | Description | Usage |
+|----------|-------------|-------|
+| `new_users` | Users added since last deployment | `vdi_tool`, `user_provision`, `vnc` |
+| `removed_users` | Users removed since last deployment | `vdi_tool`, `user_provision`, `vnc` |
+| `users_needing_reset` | Users with `reset_password: true` | `vdi_tool`, `secret_manager` |
+| `users_with_port_changes` | Users whose VNC ports changed | `vdi_tool`, `vnc` |
+| `webapp_port_changed` | Whether webapp port changed | `vdi_tool` |
+| `users_changed` | Any user-related changes detected | `vdi_tool` |
+| `db_needs_init` | Whether database needs re-initialization | `vdi_tool` |
+| `vdi_user_group` | Current VDI user group for group management | `user_provision` |
+
+### **Variable Propagation Flow:**
+
+1. **Lock Manager Calculation**: Variables calculated once during initial `lock_manager` run
+2. **Role Import**: Other roles import `lock_manager` and receive these variables
+3. **Defensive Programming**: Variables preserved with `default()` filters, not overwritten
+4. **Role Usage**: Each role uses only the variables it needs
+
+## Data Flows
+
+### **Initial Deployment:**
+
+```mermaid
+1. lock_manager → Calculates all variables (empty for fresh deployment)
+2. base_os → OS setup
+3. secret_manager → Password generation and Secret Manager setup
+4. user_provision → Local user creation
+5. vnc → VNC service setup
+6. vdi_tool → Guacamole deployment
+7. vdi_monitor → Monitoring service setup
+```
+
+### **Reconfiguration (User Changes):**
+
+```mermaid
+1. lock_manager → Detects changes, calculates user management variables
+2. user_provision → Handles user additions/removals
+3. vnc → Handles VNC port changes and user removal
+4. vdi_tool → Handles database updates (targeted, no container recreation)
+5. vdi_monitor → No changes needed
+```
+
+### **Reconfiguration (Port Changes):**
+
+```mermaid
+1. lock_manager → Detects port changes
+2. vnc → Stops VNC services for users with port changes
+3. vdi_tool → Updates Guacamole connection ports in database
+4. user_provision → Updates local user configurations
+```
+
+## Lock File Structure
+
+```yaml
+vdi_setup_status:
+ completed_roles:
+ base_os: true
+ secret_manager: true
+ user_provision: true
+ vdi_monitor: true
+ vdi_tool: true
+ vnc: true
+ created_at: '2025-08-07T16:20:26Z'
+ current_users: ['alice', 'bob', 'charlie', 'david']
+ deployment_hash: 'abc123...'
+ deployment_name: 'vdi-test-scott'
+ force_rerun: false
+ last_updated: '2025-08-07T16:28:42Z'
+ lock_version: '1.0'
+ setup_status: 'available'
+ user_hash: 'def456...'
+ user_ports:
+ alice: 5901
+ bob: 5902
+ charlie: 5903
+ david: 5904
+ webapp_port: 8081
+ vdi_user_group: vdiusers
+```
+
+## Role Execution Logic
+
+### **When Roles Run:**
+- **Fresh Deployment**: All roles run regardless of hash
+- **Hash Mismatch**: Only roles with changed hashes run
+- **Force Rerun**: All roles run when `force_rerun: true`
+
+### **Role Completion:**
+- Roles mark themselves as completed in lock file
+- Lock file tracks completion status per role
+- Deployment status updated based on all role completions
+
+## Error Handling
+
+### **Failed Deployments:**
+- VDI monitor sets status to `"failed"` when Ansible exits with non-zero code
+- Lock file preserves failed state for debugging
+- Valid blueprint can be re-applied to fix `failed` deployments
+
+### **Variable Safety:**
+- Defensive programming ensures all variables have safe defaults
+- `default()` filters prevent undefined variable errors
+- Variables preserved across role imports
+
+## Debug Output
+
+When `debug: true` is set, the lock manager provides detailed information about:
+- Variable calculations and propagation
+- Change detection results
+- Hash comparisons
+- Role execution decisions
+- Port change detection details
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/check_lock.yaml b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/check_lock.yaml
new file mode 100644
index 0000000000..35578df559
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/check_lock.yaml
@@ -0,0 +1,311 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Check if lock file exists
+ ansible.builtin.stat:
+ path: /opt/vdi-setup/.vdi-lock.yaml
+ register: lock_file_stat
+
+- name: Debug lock file existence
+ ansible.builtin.debug:
+ msg: "Lock file exists: {{ lock_file_stat.stat.exists }}"
+ when: debug
+
+- name: Load existing lock file
+ ansible.builtin.include_vars:
+ file: /opt/vdi-setup/.vdi-lock.yaml
+ when: lock_file_stat.stat.exists
+ register: lock_file_load
+
+- name: Set vdi_setup_status from loaded lock file
+ ansible.builtin.set_fact:
+ vdi_setup_status: "{{ vdi_setup_status | default({}) }}"
+ when: lock_file_stat.stat.exists
+
+- name: Set vdi_setup_status to empty dict for fresh deployments
+ ansible.builtin.set_fact:
+ vdi_setup_status: {}
+ when: not lock_file_stat.stat.exists
+
+- name: Debug vdi_setup_status after loading
+ ansible.builtin.debug:
+ msg: |
+ vdi_setup_status after loading:
+ - vdi_setup_status is defined: {{ vdi_setup_status is defined }}
+ - vdi_setup_status type: {{ vdi_setup_status | type_debug }}
+ - deployment_hash: {{ vdi_setup_status.deployment_hash | default('not_found') }}
+ - keys: {{ vdi_setup_status.keys() | default([]) }}
+ when: debug and lock_file_stat.stat.exists
+
+- name: Debug lock file load result
+ ansible.builtin.debug:
+ msg: "Lock file loaded successfully: {{ lock_file_load is defined }}"
+ when: debug and lock_file_stat.stat.exists
+
+# =============================================================================
+# DEFENSIVE PROGRAMMING: SET DEFAULTS FOR ALL CRITICAL VARIABLES
+# =============================================================================
+# This ensures that debug tasks and other logic never fail due to undefined variables
+# All variables that might be referenced in debug output or conditional logic are set here
+# Set early so we can remove default() filters throughout the rest of the file
+
+- name: Set default values for critical variables (defensive programming)
+ ansible.builtin.set_fact:
+ is_fresh_deployment: "{{ is_fresh_deployment | default(not lock_file_stat.stat.exists) }}"
+ db_needs_init: "{{ db_needs_init | default(false) }}"
+ users_needing_reset: "{{ users_needing_reset | default([]) }}"
+ force_rerun: "{{ force_rerun | default(false) }}"
+ reset_webapp_admin_password: "{{ reset_webapp_admin_password | default(false) }}"
+ debug: "{{ debug | default(false) }}"
+ # Initialize deployment variables with safe defaults
+ deployment_name: "{{ deployment_name | default('vdi-deployment') }}"
+ project_id: "{{ project_id | default('unknown-project') }}"
+ vdi_tool: "{{ vdi_tool | default('guacamole') }}"
+ vnc_flavor: "{{ vnc_flavor | default('tigervnc') }}"
+ user_provision: "{{ user_provision | default('local_users') }}"
+ vdi_webapp_port: "{{ vdi_webapp_port | default(8080) }}"
+ vdi_user_group: "{{ vdi_user_group | default('vdiusers') }}"
+ vdi_resolution: "{{ vdi_resolution | default('1920x1080') }}"
+ vdi_resolution_locked: "{{ vdi_resolution_locked | default(true) }}"
+ vdi_users: "{{ vdi_users | default([]) }}"
+ # Initialize user management variables with safe defaults (only if not already set)
+ current_users: "{{ current_users | default([]) }}"
+ previous_users: "{{ previous_users | default([]) }}"
+ new_user_names: "{{ new_user_names | default([]) }}"
+ removed_users: "{{ removed_users | default([]) }}"
+ users_changed: "{{ users_changed | default(false) }}"
+ new_users: "{{ new_users | default([]) }}"
+ removed_user_objects: "{{ removed_user_objects | default([]) }}"
+ # Initialize port change detection variables with safe defaults (only if not already set)
+ users_with_port_changes: "{{ users_with_port_changes | default([]) }}"
+ webapp_port_changed: "{{ webapp_port_changed | default(false) }}"
+ vdi_resolution_changed: "{{ vdi_resolution_changed | default(false) }}"
+ vdi_resolution_locked_changed: "{{ vdi_resolution_locked_changed | default(false) }}"
+ infrastructure_changed: "{{ infrastructure_changed | default(false) }}"
+ # Initialize secret manager variables with safe defaults
+ webapp_admin_hash: "{{ webapp_admin_hash | default('') }}"
+
+- name: Calculate user management variables (when vdi_users is available)
+ ansible.builtin.set_fact:
+ current_users: "{{ vdi_users | map(attribute='username') | list }}"
+ previous_users: "{{ vdi_setup_status.current_users | default([]) }}"
+ new_user_names: "{{ current_users | difference(previous_users) }}"
+ removed_users: "{{ previous_users | difference(current_users) }}"
+ users_changed: "{{ (new_user_names | length > 0) or (removed_users | length > 0) }}"
+ new_users: "{{ vdi_users | selectattr('username', 'in', new_user_names) | list if new_user_names | length > 0 else [] }}"
+ removed_user_objects: "{{ vdi_users | selectattr('username', 'in', removed_users) | list if removed_users | length > 0 else [] }}"
+ users_needing_reset: "{{ vdi_users | selectattr('reset_password', 'defined') | selectattr('reset_password', 'equalto', true) | list }}"
+ when: vdi_users is defined
+
+- name: Detect port changes
+ ansible.builtin.set_fact:
+ users_with_port_changes: "{{ users_with_port_changes | default([]) }}"
+ webapp_port_changed: "{{ vdi_webapp_port != vdi_setup_status.webapp_port | default(false) }}"
+ vdi_resolution_changed: "{{ vdi_resolution != vdi_setup_status.vdi_resolution | default(false) }}"
+ vdi_resolution_locked_changed: "{{ vdi_resolution_locked != vdi_setup_status.vdi_resolution_locked | default(false) }}"
+ infrastructure_changed: "{{ webapp_port_changed or vdi_resolution_changed or vdi_resolution_locked_changed or reset_webapp_admin_password }}"
+ when: vdi_users is defined and not is_fresh_deployment
+
+- name: Calculate user port changes (only if not already calculated)
+ ansible.builtin.set_fact:
+ users_with_port_changes: "{{ users_with_port_changes_temp | default([]) }}"
+ when: vdi_users is defined and not is_fresh_deployment and (users_with_port_changes | default([]) | length == 0)
+
+- name: Calculate port changes for each user
+ ansible.builtin.set_fact:
+ users_with_port_changes_temp: "{{ users_with_port_changes_temp | default([]) + [item] }}"
+ loop: "{{ vdi_users | selectattr('username', 'in', vdi_setup_status.current_users | default([])) | selectattr('port', 'defined') | list }}"
+ when: >
+ vdi_users is defined and
+ not is_fresh_deployment and
+ (users_with_port_changes | default([]) | length == 0) and
+ vdi_setup_status.user_ports[item.username] is defined and
+ item.port != vdi_setup_status.user_ports[item.username]
+
+- name: Debug port change detection
+ ansible.builtin.debug:
+ msg: |
+ Port change detection results:
+ - users_with_port_changes: {{ users_with_port_changes | default([]) }}
+ - vdi_setup_status.user_ports: {{ vdi_setup_status.user_ports | default({}) }}
+ - webapp_port_changed: {{ webapp_port_changed }}
+ - current_webapp_port: {{ vdi_webapp_port }}
+ - previous_webapp_port: {{ vdi_setup_status.webapp_port | default('none') }}
+ - vdi_resolution_changed: {{ vdi_resolution_changed }}
+ - current_vdi_resolution: {{ vdi_resolution }}
+ - previous_vdi_resolution: {{ vdi_setup_status.vdi_resolution | default('none') }}
+ - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }}
+ - current_vdi_resolution_locked: {{ vdi_resolution_locked }}
+ - previous_vdi_resolution_locked: {{ vdi_setup_status.vdi_resolution_locked | default('none') }}
+ - infrastructure_changed: {{ infrastructure_changed }}
+ when: vdi_users is defined and not is_fresh_deployment and debug
+
+- name: Calculate current deployment hash
+ ansible.builtin.set_fact:
+ current_deployment_hash: >-
+ {{
+ (deployment_name | string +
+ project_id | string +
+ vdi_tool | string +
+ vnc_flavor | string +
+ user_provision | string +
+ vdi_webapp_port | string +
+ vdi_resolution | string +
+ vdi_resolution_locked | string +
+ vdi_user_group | string +
+ (reset_webapp_admin_password | string) +
+ (force_rerun | string) +
+ (vdi_users | to_json)) | hash('sha256')
+ }}
+
+- name: Debug deployment hash calculation
+ ansible.builtin.debug:
+ msg: |
+ Current deployment hash: {{ current_deployment_hash }}
+ Deployment components:
+ - deployment_name: {{ deployment_name }}
+ - project_id: {{ project_id }}
+ - vdi_tool: {{ vdi_tool }}
+ - vnc_flavor: {{ vnc_flavor }}
+ - user_provision: {{ user_provision }}
+ - vdi_webapp_port: {{ vdi_webapp_port }}
+ - vdi_resolution: {{ vdi_resolution }}
+ - vdi_resolution_locked: {{ vdi_resolution_locked }}
+ - vdi_user_group: {{ vdi_user_group }}
+ - reset_webapp_admin_password: {{ reset_webapp_admin_password }}
+ - force_rerun: {{ force_rerun }}
+ - vdi_users count: {{ vdi_users | length }}
+ when: debug
+
+- name: Calculate current user secrets hash
+ ansible.builtin.set_fact:
+ current_user_secrets_hash: "{{ (vdi_users | to_json | sort) | hash('sha256') }}"
+
+- name: Debug user secrets hash calculation
+ ansible.builtin.debug:
+ msg: |
+ Current user secrets hash: {{ current_user_secrets_hash }}
+ User configuration components:
+ - vdi_users (blueprint): {{ vdi_users | to_json }}
+ when: debug
+
+- name: Store original lock file state for this deployment run
+ ansible.builtin.set_fact:
+ original_lock_file_exists: "{{ lock_file_stat.stat.exists }}"
+ when: original_lock_file_exists is not defined
+
+- name: Debug lock file status
+ ansible.builtin.debug:
+ msg: |
+ Lock file status:
+ - current_exists: {{ lock_file_stat.stat.exists }}
+ - original_exists: {{ original_lock_file_exists | default('not_set') }}
+ - deployment_hash: {{ vdi_setup_status.deployment_hash | default('none') }}
+ - user_secrets_hash: {{ vdi_setup_status.user_secrets_status.user_secrets_hash | default('none') }}
+ - force_rerun: {{ vdi_setup_status.force_rerun | default(false) }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ when: debug
+
+- name: Check if lock file exists
+ ansible.builtin.set_fact:
+ lock_file_exists: "{{ lock_file_stat.stat.exists }}"
+
+- name: Check if current role is completed
+ ansible.builtin.set_fact:
+ current_role_completed: "{{ vdi_setup_status.completed_roles[current_role | default(ansible_role_name | basename)] | default(false) if lock_file_stat.stat.exists else false }}"
+
+- name: Check if deployment hash matches
+ ansible.builtin.set_fact:
+ deployment_hash_matches: "{{ (vdi_setup_status.deployment_hash | default('none')) == current_deployment_hash if lock_file_stat.stat.exists and vdi_setup_status is defined else false }}"
+
+- name: Check if user hash matches
+ ansible.builtin.set_fact:
+ user_hash_matches: "{{ (vdi_setup_status.user_hash | default('none')) == current_user_secrets_hash if lock_file_stat.stat.exists and vdi_setup_status is defined else false }}"
+
+- name: Check if force rerun is enabled
+ ansible.builtin.set_fact:
+ force_rerun_enabled: "{{ force_rerun | default(false) }}"
+
+- name: Determine if role should run (hash-based)
+ ansible.builtin.set_fact:
+ role_should_run: "{{ is_fresh_deployment or not current_role_completed or not deployment_hash_matches or not user_hash_matches or force_rerun_enabled }}"
+
+- name: Debug role should run calculation
+ ansible.builtin.debug:
+ msg: |
+ Role should run calculation for {{ current_role | default(ansible_role_name | basename) }}:
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - not current_role_completed: {{ not current_role_completed }}
+ - not deployment_hash_matches: {{ not deployment_hash_matches }}
+ - not user_hash_matches: {{ not user_hash_matches }}
+ - force_rerun_enabled: {{ force_rerun_enabled }}
+ - role_should_run: {{ role_should_run }}
+ when: debug
+
+- name: Debug role execution decision
+ ansible.builtin.debug:
+ msg: |
+ Role execution decision for {{ current_role | default(ansible_role_name | basename) }}:
+ - current_role: {{ current_role | default('not set') }}
+ - ansible_role_name: {{ ansible_role_name }}
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - current_role_completed: {{ current_role_completed }}
+ - deployment_hash_matches: {{ deployment_hash_matches }}
+ - user_hash_matches: {{ user_hash_matches }}
+ - force_rerun_enabled: {{ force_rerun_enabled }}
+ when: debug
+
+- name: Debug lock manager completion
+ ansible.builtin.debug:
+ msg: "Lock manager role completed successfully"
+
+- name: Summary of changes and lock file contents
+ ansible.builtin.debug:
+ msg: |
+ ================================================================================
+ VDI DEPLOYMENT SUMMARY
+ ================================================================================
+
+ DEPLOYMENT STATUS:
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - deployment_hash: {{ current_deployment_hash }}
+ - user_hash: {{ current_user_secrets_hash }}
+ - reset_webapp_admin_password: {{ reset_webapp_admin_password }}
+
+ USER CHANGES DETECTED:
+ - users_changed: {{ users_changed }}
+ - new_users: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }}
+ - removed_users: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }}
+ - users_needing_reset: {{ users_needing_reset | map(attribute='username') | list if users_needing_reset is defined and users_needing_reset | length > 0 else [] }}
+ - users_with_port_changes: {{ users_with_port_changes | default([]) }}
+ - webapp_port_changed: {{ webapp_port_changed }}
+ - vdi_resolution_changed: {{ vdi_resolution_changed }}
+ - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }}
+
+ CURRENT LOCK FILE CONTENTS:
+ - completed_roles: {{ vdi_setup_status.completed_roles | default({}) }}
+ - current_users: {{ vdi_setup_status.current_users | default([]) }}
+ - user_ports: {{ vdi_setup_status.user_ports | default({}) }}
+ - webapp_port: {{ vdi_setup_status.webapp_port | default('none') }}
+ - vdi_resolution: {{ vdi_setup_status.vdi_resolution | default('none') }}
+ - vdi_resolution_locked: {{ vdi_setup_status.vdi_resolution_locked | default('none') }}
+ - deployment_hash: {{ vdi_setup_status.deployment_hash | default('none') }}
+ - user_hash: {{ vdi_setup_status.user_hash | default('none') }}
+ - setup_status: {{ vdi_setup_status.setup_status | default('unknown') }}
+ - last_updated: {{ vdi_setup_status.last_updated | default('unknown') }}
+
+ ================================================================================
+ when: debug and current_role == 'lock_manager'
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/cleanup_lock.yaml b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/cleanup_lock.yaml
new file mode 100644
index 0000000000..e90f2bdef6
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/cleanup_lock.yaml
@@ -0,0 +1,29 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Debug cleanup lock operation
+ ansible.builtin.debug:
+ msg: "Cleaning up lock file"
+
+- name: Remove lock file
+ ansible.builtin.file:
+ path: /opt/vdi-setup/.vdi-lock.yaml
+ state: absent
+ register: lock_file_removal
+
+- name: Debug lock file removal
+ ansible.builtin.debug:
+ msg: "Lock file removed: {{ lock_file_removal.changed }}"
+ when: debug
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/create_lock.yaml b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/create_lock.yaml
new file mode 100644
index 0000000000..411cd37f5e
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/create_lock.yaml
@@ -0,0 +1,188 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Backup existing lock file
+ ansible.builtin.copy:
+ src: /opt/vdi-setup/.vdi-lock.yaml
+ dest: /opt/vdi-setup/.vdi-lock.yaml.backup
+ remote_src: true
+ when: lock_file_stat.stat.exists
+ ignore_errors: true
+
+- name: Debug create lock operation
+ ansible.builtin.debug:
+ msg: "Creating/updating lock file for role: {{ current_role }}"
+
+- name: Check if lock file exists
+ ansible.builtin.stat:
+ path: /opt/vdi-setup/.vdi-lock.yaml
+ register: lock_file_stat
+
+- name: Debug lock file existence
+ ansible.builtin.debug:
+ msg: "Lock file exists: {{ lock_file_stat.stat.exists }}"
+ when: debug
+
+- name: Create initial lock file structure
+ ansible.builtin.template:
+ src: lock_file.yaml.j2
+ dest: /opt/vdi-setup/.vdi-lock.yaml
+ mode: '0644'
+ when: not lock_file_stat.stat.exists
+ register: lock_file_creation
+
+- name: Debug lock file creation
+ ansible.builtin.debug:
+ msg: "Lock file created: {{ lock_file_creation.changed }}"
+ when: debug and not lock_file_stat.stat.exists
+
+- name: Set fresh deployment flag (only when lock file is created)
+ ansible.builtin.set_fact:
+ is_fresh_deployment: true
+ when: lock_file_creation.changed
+
+- name: Load existing lock file data
+ ansible.builtin.include_vars:
+ file: /opt/vdi-setup/.vdi-lock.yaml
+ when: lock_file_stat.stat.exists or lock_file_creation.changed
+ register: existing_lock_data
+
+- name: Get current lock data
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ existing_lock_data.ansible_facts.vdi_setup_status | default({}) }}"
+ when: lock_file_stat.stat.exists or lock_file_creation.changed
+
+# Update hashes and configuration when lock manager runs independently OR when secret_manager provides admin hash
+- name: Update deployment hash (independent run only)
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ current_lock_data | combine({'deployment_hash': current_deployment_hash}) }}"
+ when:
+ - lock_file_stat.stat.exists or lock_file_creation.changed
+ - current_role == 'lock_manager'
+
+- name: Update user hash (independent run only)
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ current_lock_data | combine({'user_hash': current_user_secrets_hash}) }}"
+ when:
+ - lock_file_stat.stat.exists or lock_file_creation.changed
+ - current_role == 'lock_manager'
+
+- name: Prepare configuration updates
+ ansible.builtin.set_fact:
+ config_updates:
+ current_users: "{{ vdi_users | map(attribute='username') | list }}"
+ user_ports: "{{ vdi_users | items2dict(key_name='username', value_name='port') }}"
+ webapp_port: "{{ vdi_webapp_port }}"
+ vdi_resolution: "{{ vdi_resolution }}"
+ vdi_resolution_locked: "{{ vdi_resolution_locked }}"
+ vdi_user_group: "{{ vdi_user_group }}"
+ webapp_admin_hash: "{{ webapp_admin_hash }}"
+ last_updated: "{{ ansible_date_time.iso8601 }}"
+ when:
+ - lock_file_stat.stat.exists or lock_file_creation.changed
+ - current_role == 'lock_manager'
+
+- name: Update configuration data (independent run only)
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ current_lock_data | combine(config_updates) }}"
+ when:
+ - lock_file_stat.stat.exists or lock_file_creation.changed
+ - current_role == 'lock_manager'
+
+# Update admin hash when secret_manager role provides it
+- name: Update admin hash from secret_manager
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ current_lock_data | combine({'webapp_admin_hash': webapp_admin_hash}) }}"
+ when:
+ - lock_file_stat.stat.exists or lock_file_creation.changed
+ - current_role == 'secret_manager'
+ - webapp_admin_hash is defined
+
+- name: Update role completion status
+ ansible.builtin.set_fact:
+ current_completed_roles: "{{ current_lock_data.completed_roles | default({}) }}"
+ updated_completed_roles: "{{ (current_lock_data.completed_roles | default({})) | combine({current_role: role_completed | default(false)}) }}"
+ when: lock_file_stat.stat.exists or lock_file_creation.changed
+
+- name: Update lock data with completed roles
+ ansible.builtin.set_fact:
+ current_lock_data: "{{ current_lock_data | combine({'completed_roles': updated_completed_roles}) }}"
+ when: lock_file_stat.stat.exists or lock_file_creation.changed
+
+- name: Write updated lock file
+ ansible.builtin.copy:
+ content: |
+ vdi_setup_status:
+ {{ current_lock_data | to_nice_yaml(indent=2) | indent(2, first=true) }}
+ dest: /opt/vdi-setup/.vdi-lock.yaml
+ mode: '0644'
+ when: lock_file_stat.stat.exists or lock_file_creation.changed
+ register: lock_file_write
+
+- name: Debug VM metadata update
+ ansible.builtin.debug:
+ msg: |
+ VM metadata update info:
+ - hostname: {{ ansible_hostname }}
+ - zone: {{ zone }}
+ - lock_file_changed: {{ lock_file_write.changed }}
+ when: debug
+
+- name: Get lock file content as base64
+ ansible.builtin.shell: cat /opt/vdi-setup/.vdi-lock.yaml | base64 -w 0
+ register: lock_file_base64
+ when: lock_file_write.changed
+
+- name: Add VDI lock file content to VM metadata (with retry)
+ ansible.builtin.command: >
+ gcloud compute instances add-metadata {{ ansible_hostname }}
+ --metadata vdi-lock-content="{{ lock_file_base64.stdout }}"
+ --zone={{ zone }}
+ when:
+ - lock_file_write.changed
+ - lock_file_base64.rc == 0
+ register: metadata_update
+ retries: 3
+ delay: 2
+ until: metadata_update.rc == 0
+ changed_when: metadata_update.rc == 0
+ failed_when:
+ - metadata_update.rc != 0
+ - "'already exists' not in metadata_update.stderr | default('')"
+
+- name: Debug lock file write
+ ansible.builtin.debug:
+ msg: "Lock file written: {{ lock_file_write.changed }}"
+ when: debug
+
+- name: Verify lock file integrity
+ ansible.builtin.command: python3 -c "import yaml; yaml.safe_load(open('/opt/vdi-setup/.vdi-lock.yaml'))"
+ register: lock_file_validation
+ changed_when: false
+
+- name: Debug lock file validation
+ ansible.builtin.debug:
+ msg: "Lock file validation: {{ lock_file_validation.rc == 0 }}"
+ when: debug
+
+- name: Debug lock manager completion
+ ansible.builtin.debug:
+ msg: "Lock manager role completed successfully"
+ when: debug
+
+- name: Fail if lock file is invalid
+ ansible.builtin.fail:
+ msg: "Lock file validation failed: {{ lock_file_validation.stderr }}"
+ when: lock_file_validation.rc != 0
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/main.yaml
new file mode 100644
index 0000000000..7802068833
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/tasks/main.yaml
@@ -0,0 +1,50 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: Set current role (only if not already set)
+ ansible.builtin.set_fact:
+ current_role: "{{ current_role | default('lock_manager') }}"
+
+- name: Debug lock manager role execution
+ ansible.builtin.debug:
+ msg: "Lock manager role started - operation: {{ lock_operation | default('check') }} for role: {{ current_role }}"
+
+- name: Ensure lock directory exists
+ ansible.builtin.file:
+ path: /opt/vdi-setup
+ state: directory
+ mode: '0755'
+ register: lock_dir_creation
+
+- name: Debug lock directory status
+ ansible.builtin.debug:
+ msg: "Lock directory created: {{ lock_dir_creation.changed }}"
+ when: debug
+
+- name: Include check lock tasks
+ ansible.builtin.include_tasks: check_lock.yaml
+ when: lock_operation | default('check') == 'check'
+
+- name: Include create lock tasks
+ ansible.builtin.include_tasks: create_lock.yaml
+ when: lock_operation | default('check') == 'create'
+
+- name: Include cleanup lock tasks
+ ansible.builtin.include_tasks: cleanup_lock.yaml
+ when: lock_operation | default('check') == 'cleanup'
+
+- name: Debug lock manager role completion
+ ansible.builtin.debug:
+ msg: "Lock manager role completed - operation: {{ lock_operation | default('check') }}"
diff --git a/community/modules/scripts/vdi-setup/roles/lock_manager/templates/lock_file.yaml.j2 b/community/modules/scripts/vdi-setup/roles/lock_manager/templates/lock_file.yaml.j2
new file mode 100644
index 0000000000..a87c5e9e30
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/lock_manager/templates/lock_file.yaml.j2
@@ -0,0 +1,43 @@
+{#
+Copyright 2025 "Google LLC"
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+#}
+
+# VDI Setup Lock File
+# Generated on {{ ansible_date_time.iso8601 }}
+
+vdi_setup_status:
+ deployment_name: "{{ deployment_name }}"
+ deployment_hash: "{{ current_deployment_hash }}"
+ user_hash: "{{ current_user_secrets_hash }}"
+ current_users: {{ vdi_users | map(attribute='username') | list | to_json }}
+ user_ports: {{ vdi_users | items2dict(key_name='username', value_name='port') | to_json }}
+ webapp_port: {{ vdi_webapp_port }}
+ vdi_resolution: {{ vdi_resolution | default('1920x1080') }}
+ vdi_resolution_locked: {{ vdi_resolution_locked | default(true) }}
+ webapp_admin_hash: "{{ webapp_admin_hash | default('') }}"
+ vdi_user_group: "{{ vdi_user_group | default('vdiusers') }}"
+ lock_version: "1.0"
+ created_at: "{{ ansible_date_time.iso8601 }}"
+ last_updated: "{{ ansible_date_time.iso8601 }}"
+ force_rerun: {{ force_rerun | default(false) }}
+ setup_status: "configuring" # configuring, available, error
+
+ completed_roles:
+ base_os: false
+ secret_manager: false
+ user_provision: false
+ vnc: false
+ vdi_tool: false
+ vdi_monitor: false
diff --git a/community/modules/scripts/vdi-setup/roles/secret_manager/defaults/main.yaml b/community/modules/scripts/vdi-setup/roles/secret_manager/defaults/main.yaml
new file mode 100644
index 0000000000..15a621b3ad
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/secret_manager/defaults/main.yaml
@@ -0,0 +1,16 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+---
+
+secret_project: "{{ project_id }}"
diff --git a/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/main.yaml
new file mode 100644
index 0000000000..c013d042c4
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/main.yaml
@@ -0,0 +1,194 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+---
+
+# Check if this role should run
+- name: Set current role for lock manager check
+ ansible.builtin.set_fact:
+ current_role: "secret_manager"
+
+- name: Check if secret_manager role should run
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: check_lock
+
+- name: Set role variables from lock manager facts
+ ansible.builtin.set_fact:
+ role_should_run: "{{ role_should_run | default(false) }}"
+ is_fresh_deployment: "{{ is_fresh_deployment | default(false) }}"
+ deployment_hash: "{{ current_deployment_hash | default('none') }}"
+ user_hash: "{{ current_user_secrets_hash | default('none') }}"
+ lock_file_stat: "{{ lock_file_stat | default({}) }}"
+ vdi_setup_status: "{{ vdi_setup_status | default({}) }}"
+
+- name: Debug role variables
+ ansible.builtin.debug:
+ msg: |
+ Secret Manager role variables:
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - deployment_hash: {{ deployment_hash }}
+ - user_hash: {{ user_hash }}
+ - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }}
+ when: debug
+
+# Skip all tasks if role should not run
+- name: Skip secret_manager tasks if role should not run
+ ansible.builtin.debug:
+ msg: "Skipping secret_manager role - already completed or not needed"
+ when: not role_should_run
+
+# Run secret manager tasks only if needed
+- name: Run secret manager tasks
+ block:
+ # General secret handling
+ - name: Generate random database password
+ ansible.builtin.set_fact:
+ database_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
+
+ - name: Get access token for Secret Manager API
+ ansible.builtin.uri:
+ url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"
+ method: GET
+ headers:
+ Metadata-Flavor: Google
+ return_content: true
+ register: access_token_result
+
+ # Webapp admin password handling
+ - name: Fetch existing webapp admin password from Secret Manager
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets/webapp-server-password-{{ deployment_name }}/versions/latest:access"
+ method: GET
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ return_content: true
+ register: webapp_admin_sm_result
+ failed_when: false
+
+ - name: Set webapp admin password from Secret Manager (if available and not resetting)
+ ansible.builtin.set_fact:
+ webapp_admin_password: "{{ webapp_admin_sm_result.json.payload.data | b64decode }}"
+ webapp_password_was_fetched: true
+ when:
+ - webapp_admin_sm_result.status == 200
+ - not (reset_webapp_admin_password | default(false))
+
+ - name: Generate new webapp admin password (if not available from Secret Manager or reset requested)
+ ansible.builtin.set_fact:
+ webapp_admin_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
+ webapp_password_was_fetched: false
+ when:
+ - webapp_admin_sm_result.status != 200 or (reset_webapp_admin_password | default(false))
+
+ - name: Compute Webapp admin password hash to save to Webapp server
+ ansible.builtin.set_fact:
+ webapp_admin_hash: "{{ webapp_admin_password | hash('sha256') | upper }}"
+
+ - name: Create a GCP secret for webapp server login
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets?secretId=webapp-server-password-{{ deployment_name }}"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ replication:
+ automatic: {}
+ status_code: [200, 409] # 409 = already exists
+ register: webapp_secret_create
+
+ - name: Add webapp password version to secret (only if new password was generated)
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets/webapp-server-password-{{ deployment_name }}:addVersion"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ payload:
+ data: "{{ webapp_admin_password | b64encode }}"
+ register: webapp_secret
+ when: not (webapp_password_was_fetched | default(false))
+
+ - name: Debug webapp admin password handling
+ ansible.builtin.debug:
+ msg: |
+ Webapp admin password handling (secret_manager):
+ - reset_requested: {{ reset_webapp_admin_password | default(false) }}
+ - is_fresh_deployment: {{ is_fresh_deployment | default(false) }}
+ - reset_applied: {{ reset_webapp_admin_password | default(false) }}
+ - secret_manager_status: {{ webapp_admin_sm_result.status }}
+ - password_source: {{ 'Secret Manager' if webapp_admin_sm_result.status == 200 and not (reset_webapp_admin_password | default(false)) else 'Generated' }}
+ - password_was_fetched: {{ webapp_password_was_fetched | default(false) }}
+ - password_length: {{ webapp_admin_password | length if webapp_admin_password is defined else 'N/A' }}
+ - stored_in_secret_manager: {{ not (webapp_password_was_fetched | default(false)) }}
+ when: debug
+
+ - name: Create a GCP secret for database password
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets?secretId=db-password-{{ deployment_name }}"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ replication:
+ automatic: {}
+ status_code: [200, 409] # 409 = already exists
+ register: database_secret_create
+
+ - name: Add database password version to secret
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets/db-password-{{ deployment_name }}:addVersion"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ payload:
+ data: "{{ database_password | b64encode }}"
+ register: database_secret
+
+ # Per-user secret handling
+ - name: Generate per-user secrets and enrich list
+ include_tasks: user_secret_tasks.yaml
+ loop: "{{ vdi_users }}"
+ loop_control:
+ loop_var: item
+
+ - name: Set enriched vdi_users_updated variable
+ ansible.builtin.set_fact:
+ vdi_users_updated: "{{ vdi_users_updated }}"
+ when: vdi_users_updated is defined
+
+ when: role_should_run
+
+# Mark role as completed
+- name: Set current role for lock manager completion
+ ansible.builtin.set_fact:
+ current_role: "secret_manager"
+ role_completed: true
+ when: role_should_run
+
+- name: Mark secret_manager role as completed
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: create_lock
+ when: role_should_run
diff --git a/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/user_secret_tasks.yaml b/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/user_secret_tasks.yaml
new file mode 100644
index 0000000000..499dbc7b84
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/secret_manager/tasks/user_secret_tasks.yaml
@@ -0,0 +1,274 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# =============================================================================
+# USER SECRET MANAGEMENT TASKS
+# =============================================================================
+#
+# This file handles user password management with Secret Manager integration.
+#
+# Password Sources:
+# - "secret": Use existing Secret Manager secret (auto-creates if missing)
+# - "password": Use provided password value (not currently used)
+# - "generate": Generate random password
+#
+# Auto-Creation Behavior:
+# - If a secret is referenced but doesn't exist, it will be automatically created
+# - A secure random password will be generated and stored in the new secret
+# - This ensures deployments never fail due to missing secrets
+# - Auto-created secrets are treated as password changes for user provisioning
+#
+# =============================================================================
+---
+
+- name: Compute default secret_name if none provided
+ set_fact:
+ secret_name: "vdi-user-password-{{ item.username }}-{{ deployment_name }}"
+ when:
+ - item.password is not defined
+ - item.secret_name is not defined
+
+- name: Determine password source for user {{ item.username }}
+ ansible.builtin.set_fact:
+ password_source: >-
+ {%- if item.secret_name is defined -%}secret
+ {%- elif item.password is defined -%}password
+ {%- else -%}generate{%- endif -%}
+
+- name: Clean password source value
+ ansible.builtin.set_fact:
+ password_source: "{{ password_source | trim }}"
+
+- name: Debug password source for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Password source for {{ item.username }}:
+ - password_source: "{{ password_source }}"
+ - secret_name: {{ item.secret_name | default('not set') }}
+ - password: {{ item.password | default('not set') }}
+ when: debug
+
+- name: Fetch existing Secret Manager password for user {{ item.username }}
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets/{{ item.secret_name }}/versions/latest:access"
+ method: GET
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ return_content: true
+ register: sm_result
+ failed_when: false
+ when: password_source == "secret"
+
+- name: Debug Secret Manager fetch result for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Secret Manager fetch for {{ item.username }}:
+ - status: {{ sm_result.status }}
+ - success: {{ sm_result.status == 200 }}
+ - user_hash_matches: {{ user_hash_matches | default(false) }}
+ - password_source: {{ password_source }}
+ when: debug and password_source == "secret"
+
+- name: Set VDI user password from Secret Manager
+ ansible.builtin.set_fact:
+ vdiuser_password: "{{ sm_result.json.payload.data | b64decode }}"
+ when: password_source == "secret" and sm_result.status == 200
+
+- name: Handle missing secret - auto-create with generated password
+ ansible.builtin.debug:
+ msg: |
+ INFO: Secret Manager secret not found for {{ item.username }}.
+ Auto-creating secret '{{ item.secret_name }}' with generated password.
+ This ensures the deployment continues without errors.
+ when: password_source == "secret" and sm_result.status != 200
+
+- name: Generate password for auto-created secret
+ ansible.builtin.set_fact:
+ vdiuser_password: "{{ lookup('password', '/dev/null length=16 chars=ascii_letters,digits') }}"
+ when: password_source == "secret" and sm_result.status != 200
+
+- name: Create missing secret for user {{ item.username }}
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets?secretId={{ item.secret_name }}"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ replication:
+ automatic: {}
+ status_code: [200, 409] # 409 = already exists
+ when: password_source == "secret" and sm_result.status != 200
+ register: secret_creation
+
+- name: Store generated password in auto-created secret
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets/{{ item.secret_name }}:addVersion"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ payload:
+ data: "{{ vdiuser_password | b64encode }}"
+ when: password_source == "secret" and sm_result.status != 200
+
+- name: Debug auto-created secret for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Auto-created secret for {{ item.username }}:
+ - secret_name: {{ item.secret_name }}
+ - secret_created: {{ secret_creation.status == 200 if secret_creation is defined else 'N/A' }}
+ - password_generated: {{ vdiuser_password is defined }}
+ - password_length: {{ vdiuser_password | length if vdiuser_password is defined else 'N/A' }}
+ when: debug and password_source == "secret" and sm_result.status != 200
+
+- name: Check if password should be forced to update (only on existing deployments)
+ ansible.builtin.set_fact:
+ password_force_update: "{{ item.reset_password | default(false) and not (is_fresh_deployment | default(false)) }}"
+
+- name: Check if password has actually changed for {{ item.username }}
+ ansible.builtin.set_fact:
+ password_actually_changed: "{{ user_hash_matches | default(true) or (item.reset_password | default(false) and not (is_fresh_deployment | default(false))) }}"
+ when: password_source == "secret" and sm_result.status == 200
+
+- name: Set password_actually_changed for auto-created secrets
+ ansible.builtin.set_fact:
+ password_actually_changed: true
+ when: password_source == "secret" and sm_result.status != 200
+
+- name: Debug password change detection for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Password change detection for {{ item.username }}:
+ - user_hash_matches: {{ user_hash_matches | default(false) }}
+ - password_source: {{ password_source }}
+ - sm_result_status: {{ sm_result.status }}
+ - reset_password: {{ item.reset_password | default(false) }}
+ - is_fresh_deployment: {{ is_fresh_deployment | default(false) }}
+ - password_force_update: {{ password_force_update }}
+ - password_actually_changed: {{ password_actually_changed | default(false) }}
+ - vdiuser_password_set: {{ vdiuser_password is defined }}
+ when: debug and password_source == "secret"
+
+- name: Generate random password for user {{ item.username }}
+ ansible.builtin.set_fact:
+ vdiuser_password: "{{ lookup('password', '/dev/null length=16 chars=ascii_letters,digits') }}"
+ when: password_source == "generate"
+
+- name: Set user password from provided value
+ ansible.builtin.set_fact:
+ vdiuser_password: "{{ item.password }}"
+ when: password_source == "password"
+
+- name: Set password_actually_changed for generate and password sources
+ ansible.builtin.set_fact:
+ password_actually_changed: "{{ not user_hash_matches | default(true) or (item.reset_password | default(false) and not (is_fresh_deployment | default(false))) }}"
+ when: password_source in ["generate", "password"]
+
+- name: Debug final password decision for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Final password decision for {{ item.username }}:
+ - password_source: {{ password_source }}
+ - user_hash_matches: {{ user_hash_matches | default(false) }}
+ - reset_password: {{ item.reset_password | default(false) }}
+ - is_fresh_deployment: {{ is_fresh_deployment | default(false) }}
+ - password_actually_changed: {{ password_actually_changed | default(false) }}
+ - vdiuser_password_set: {{ vdiuser_password is defined }}
+ - password_length: {{ vdiuser_password | length if vdiuser_password is defined else 'N/A' }}
+ when: debug
+
+- name: Create secret for user {{ item.username }} (provided password)
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets?secretId=vdi-user-password-{{ item.username }}-{{ deployment_name }}"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ replication:
+ automatic: {}
+ status_code: [200, 409] # 409 = already exists
+ when: password_source == "password"
+
+- name: Store provided password in Secret Manager for user {{ item.username }}
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets/vdi-user-password-{{ item.username }}-{{ deployment_name }}:addVersion"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ payload:
+ data: "{{ item.password | b64encode }}"
+ when: password_source == "password"
+
+- name: Create secret for user {{ item.username }}
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets?secretId={{ item.secret_name | default(secret_name) }}"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ replication:
+ automatic: {}
+ status_code: [200, 409] # 409 = already exists
+ when: password_source == "generate"
+
+- name: Store generated password in Secret Manager for user {{ item.username }}
+ ansible.builtin.uri:
+ url: "https://secretmanager.googleapis.com/v1/projects/{{ item.secret_project | default(project_id) }}/secrets/{{ item.secret_name | default(secret_name) }}:addVersion"
+ method: POST
+ headers:
+ Authorization: "Bearer {{ access_token_result.json.access_token }}"
+ Content-Type: "application/json"
+ body_format: json
+ body:
+ payload:
+ data: "{{ vdiuser_password | b64encode }}"
+ when: password_source == "generate"
+
+# Note: Auto-creation logic above handles all missing secret scenarios
+# This includes both fresh deployments and reset_password scenarios
+
+- name: Set VNC configuration for user
+ ansible.builtin.set_fact:
+ vnc_config: >-
+ {{
+ (vnc_flavor | lower in ['tightvnc', 'tigervnc'])
+ | ternary({
+ 'vncserver_password': lookup('community.general.random_string', length=8, special=false),
+ 'display_number': item.port | int - 5900
+ }, {})
+ }}
+
+- name: Build enriched user list
+ ansible.builtin.set_fact:
+ vdi_users_updated: >-
+ {{
+ (vdi_users_updated | default([]))
+ + [
+ item
+ | combine({ 'password': vdiuser_password })
+ | combine(vnc_config)
+ ]
+ }}
diff --git a/community/modules/scripts/vdi-setup/roles/user_provision/tasks/local_users.yaml b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/local_users.yaml
new file mode 100644
index 0000000000..e28436386d
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/local_users.yaml
@@ -0,0 +1,103 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+---
+
+- name: Check if user exists
+ ansible.builtin.getent:
+ database: passwd
+ key: "{{ item.username }}"
+ register: user_exists_check
+ ignore_errors: true
+ when: debug
+
+- name: Set user exists status for debug
+ ansible.builtin.set_fact:
+ user_exists_status: false
+ when: debug and user_exists_check is failed
+
+- name: Set user exists status for debug (success case)
+ ansible.builtin.set_fact:
+ user_exists_status: "{{ user_exists_check.ansible_facts.getent_passwd is defined }}"
+ when: debug and user_exists_check is not failed
+
+- name: Debug user provision for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Provisioning user {{ item.username }}:
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - user_hash: {{ user_hash }}
+ - password_update_mode: {{ 'always' if is_fresh_deployment else 'on_create' }}
+ - user_exists: {{ user_exists_status | default(false) }}
+ when: debug
+
+- name: Provision VDI users (custom or generated passwords)
+ block:
+
+ # Handle group changes for existing users
+ - name: Debug group change detection for {{ item.username }}
+ ansible.builtin.debug:
+ msg: |
+ Group change detection for {{ item.username }}:
+ - old_group: {{ vdi_setup_status.vdi_user_group | default('not_set') }}
+ - new_group: {{ vdi_user_group }}
+ - group_changed: {{ vdi_setup_status.vdi_user_group is defined and vdi_setup_status.vdi_user_group != vdi_user_group }}
+ when:
+ - debug
+ - not is_fresh_deployment
+ - vdi_setup_status.vdi_user_group is defined
+
+ - name: Remove users from old VDI group (if group changed)
+ ansible.builtin.user:
+ name: "{{ item.username }}"
+ groups: "{{ vdi_setup_status.vdi_user_group }}"
+ remove: yes
+ when:
+ - not is_fresh_deployment
+ - vdi_setup_status.vdi_user_group is defined
+ - vdi_setup_status.vdi_user_group != vdi_user_group
+ ignore_errors: true
+
+ # Create the Linux user with a hashed password
+ - name: Create VDI user {{ item.username }}
+ ansible.builtin.user:
+ name: "{{ item.username }}"
+ groups: "{{ vdi_user_group }},wheel"
+ shell: /bin/bash
+ create_home: yes
+ append: true
+ password: "{{ item.password | password_hash('sha512') }}"
+ update_password: "{{ 'always' if is_fresh_deployment else 'on_create' }}"
+
+ # SSH key setup
+ - name: Ensure .ssh directory for {{ item.username }}
+ ansible.builtin.file:
+ path: "/home/{{ item.username }}/.ssh"
+ state: directory
+ owner: "{{ item.username }}"
+ group: "{{ vdi_user_group }}"
+ mode: '0700'
+
+ - name: Generate SSH key pair for {{ item.username }}
+ community.crypto.openssh_keypair:
+ path: "/home/{{ item.username }}/.ssh/id_rsa"
+ owner: "{{ item.username }}"
+ group: "{{ vdi_user_group }}"
+ mode: '0600'
+ register: ssh_key
+
+ - name: Add public key to authorized_keys for {{ item.username }}
+ ansible.builtin.authorized_key:
+ user: "{{ item.username }}"
+ key: "{{ ssh_key.public_key }}"
+ state: present
diff --git a/community/modules/scripts/vdi-setup/roles/user_provision/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/main.yaml
new file mode 100644
index 0000000000..33c72751ed
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/main.yaml
@@ -0,0 +1,132 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+---
+
+# Check if this role should run
+- name: Set current role for lock manager check
+ ansible.builtin.set_fact:
+ current_role: "user_provision"
+
+- name: Check if user_provision role should run
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: check_lock
+
+- name: Set role variables from lock manager facts
+ ansible.builtin.set_fact:
+ role_should_run: "{{ role_should_run | default(false) }}"
+ is_fresh_deployment: "{{ is_fresh_deployment | default(false) }}"
+ deployment_hash: "{{ current_deployment_hash | default('none') }}"
+ user_hash: "{{ current_user_secrets_hash | default('none') }}"
+ lock_file_stat: "{{ lock_file_stat | default({}) }}"
+ vdi_setup_status: "{{ vdi_setup_status | default({}) }}"
+ removed_users: "{{ removed_users | default([]) }}"
+ vdi_user_group: "{{ vdi_user_group | default('vdiusers') }}"
+
+- name: Debug role variables
+ ansible.builtin.debug:
+ msg: |
+ User Provision role variables:
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - deployment_hash: {{ deployment_hash }}
+ - user_hash: {{ user_hash }}
+ - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }}
+ when: debug
+
+# Skip all tasks if role should not run
+- name: Skip user_provision tasks if role should not run
+ ansible.builtin.debug:
+ msg: "Skipping user_provision role - already completed or not needed"
+ when: not role_should_run
+
+# Run user provision tasks only if needed
+- name: Run user provision tasks
+ block:
+ - name: Ensure 'wheel' group is present
+ group:
+ name: wheel
+ state: present
+
+ - name: Ensure VDI user group exists
+ ansible.builtin.group:
+ name: "{{ vdi_user_group }}"
+ state: present
+
+ # Run local‐user provisioning if selected
+ - name: Provision local users
+ ansible.builtin.include_tasks: local_users.yaml
+ loop: "{{ vdi_users_updated }}"
+ loop_control:
+ loop_var: item
+ when: user_provision | lower == 'local_users'
+
+ # Run OS Login tasks if selected
+ - name: Provision OS Login users
+ ansible.builtin.include_tasks: os_login.yaml
+ loop: "{{ vdi_users_updated }}"
+ loop_control:
+ loop_var: item
+ when: user_provision | lower == 'os_login'
+
+ # =============================================================================
+ # USER REMOVAL HANDLING
+ # =============================================================================
+
+ - name: Handle removed users (lock local accounts)
+ block:
+ - name: Lock local user accounts for removed users
+ ansible.builtin.user:
+ name: "{{ item }}"
+ password: "!"
+ loop: "{{ removed_users }}"
+ when: removed_users is defined and removed_users | length > 0
+
+ - name: Remove users from VDI group
+ ansible.builtin.user:
+ name: "{{ item }}"
+ groups: "{{ vdi_user_group }}"
+ remove: yes
+ loop: "{{ removed_users }}"
+ when: removed_users is defined and removed_users | length > 0
+
+ - name: Debug user removal actions
+ ansible.builtin.debug:
+ msg: |
+ User removal actions completed:
+ {% if removed_users is defined and removed_users | length > 0 %}
+ - Users locked: {{ removed_users }}
+ - Local accounts locked with '!' password
+ - Users removed from {{ vdi_user_group }} group
+ {% else %}
+ - No users to remove
+ {% endif %}
+ when: debug
+
+ when: removed_users is defined and removed_users | length > 0
+
+ when: role_should_run
+
+# Mark role as completed
+- name: Set current role for lock manager completion
+ ansible.builtin.set_fact:
+ current_role: "user_provision"
+ role_completed: true
+ when: role_should_run
+
+- name: Mark user_provision role as completed
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: create_lock
+ when: role_should_run
diff --git a/community/modules/scripts/vdi-setup/roles/user_provision/tasks/os_login.yaml b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/os_login.yaml
new file mode 100644
index 0000000000..3039c31d62
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/user_provision/tasks/os_login.yaml
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+---
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/README.md b/community/modules/scripts/vdi-setup/roles/vdi_monitor/README.md
new file mode 100644
index 0000000000..54971254dc
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/README.md
@@ -0,0 +1,146 @@
+# VDI Monitor Role
+
+## Overview
+
+The `vdi_monitor` role sets up and configures the VDI Configuration Monitor system. This role is responsible for:
+
+- Installing the modular VDI monitoring scripts
+- Configuring the systemd service for automatic monitoring
+- Setting up file change detection
+- Enabling automatic VDI reconfiguration
+
+## Features
+
+### Change Detection
+- **File Monitoring**: Detects changes to `vars.yaml`, `install.yaml`, and `roles.tar.gz` in GCS bucket
+- **Password Reset Detection**: Detects `reset_password: true` flags in configuration
+- **Immediate Initialization**: Refreshes file tracking on startup to capture current bucket state
+
+### Automatic Reconfiguration
+- **Immediate Action**: Triggers reconfiguration as soon as changes are detected
+- **Cooldown**: Prevents excessive reconfigurations with a 1-minute cooldown period
+- **Idempotent**: Safe to run multiple times without side effects
+- **Smart Container Management**: User changes handled via SQL updates, full container recreation only when necessary
+- **Status Tracking**: Updates deployment status to `failed` when Ansible exits with non-zero code
+
+### Modular Architecture
+- **Config Module**: Constants, logging, and initialization
+- **File Monitor**: GCS file detection and synchronization
+- **Change Detector**: Change detection logic and reconfiguration
+- **Test Utils**: Comprehensive testing and diagnostics
+
+## Role Structure
+
+```text
+vdi_monitor/
+├── defaults/
+│ └── main.yaml # Default variables
+├── tasks/
+│ └── main.yaml # Main role tasks
+├── templates/
+│ ├── vdi-monitor.sh.j2 # Main orchestrator script
+│ ├── vdi-monitor-config.sh.j2 # Configuration module
+│ ├── vdi-monitor-file.sh.j2 # File monitoring module
+│ ├── vdi-monitor-detector.sh.j2 # Change detection module
+│ ├── vdi-monitor-test.sh.j2 # Testing module
+│ └── vdi-monitor.service.j2 # Systemd service
+└── README.md # This file
+```
+
+## Variables
+
+### Required Variables
+None - all variables have sensible defaults.
+
+### Optional Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `vdi_monitor_check_interval` | `60` | Monitoring check interval in seconds |
+| `vdi_monitor_reconfig_cooldown` | `60` | Reconfiguration cooldown in seconds (1 minute) |
+| `vdi_monitor_log_file` | `"/var/log/vdi-monitor.log"` | Monitor log file |
+| `vdi_monitor_ansible_log_file` | `"/var/log/ansible-vdi-reconfig.log"` | Ansible reconfig log |
+
+## Usage
+
+### Basic Usage
+The role is automatically included in the VDI setup playbook:
+
+```yaml
+# In your VDI blueprint
+- id: vdi_setup
+ source: modules/scripts/vdi-setup
+ # ... other configuration
+```
+
+## Monitoring Commands
+
+### Test Mode
+
+```bash
+# Run comprehensive tests
+sudo /usr/local/bin/vdi-monitor.sh --test
+```
+
+### Diagnostics
+
+```bash
+# Run system diagnostics
+sudo /usr/local/bin/vdi-monitor.sh --diagnostics
+```
+
+### Help
+
+```bash
+# Show help and usage
+sudo /usr/local/bin/vdi-monitor.sh --help
+```
+
+## Files Created
+
+### Scripts
+- `/usr/local/bin/vdi-monitor.sh` - Main monitoring script
+- `/opt/vdi-setup/vdi-monitor-lib/` - Module directory
+ - `config.sh` - Configuration module
+ - `file-monitor.sh` - File monitoring module
+ - `change-detector.sh` - Change detection module
+ - `test-utils.sh` - Testing module
+
+### Services
+- `/etc/systemd/system/vdi-monitor.service` - Systemd service
+
+### Logs
+- `/var/log/vdi-monitor.log` - Monitor activity log
+- `/var/log/ansible-vdi-reconfig.log` - Reconfiguration log
+
+### State Files
+- `/opt/vdi-setup/.current_*_file` - File tracking state
+- `/opt/vdi-setup/.pending_changes` - Pending change details
+
+## How It Works
+
+### Change Detection Process
+1. **File Monitoring**: Every 60 seconds, checks for changes in GCS bucket files
+2. **Immediate Response**: When changes are detected, triggers reconfiguration immediately
+3. **File Sync**: Downloads updated files from GCS bucket
+4. **Ansible Execution**: Runs the VDI setup playbook with updated configuration
+5. **Status Update**: Sets deployment status to `available` on success or `failed` on error
+6. **Cooldown**: Prevents reconfigurations for 1 minute after execution
+
+## Troubleshooting
+
+### Service won't start
+- Check logs: `journalctl -u vdi-monitor`
+- Verify permissions: `ls -la /usr/local/bin/vdi-monitor.sh`
+- Check dependencies: `which gcloud gsutil ansible-playbook`
+
+### No changes detected
+- Run test mode: `sudo /usr/local/bin/vdi-monitor.sh --test`
+- Check bucket access: `gsutil ls gs://your-bucket-name/`
+- Verify file tracking: Check `.current_*_file` files in `/opt/vdi-setup/`
+- Check bucket name retrieval: Look for "DEBUG: Bucket name" in logs
+
+### Ansible not running after change detection
+- Check Ansible logs: `tail -f /var/log/ansible-vdi-reconfig.log`
+- Verify files exist: `ls -la /opt/vdi-setup/install.yaml /opt/vdi-setup/vars.yaml`
+- Run diagnostics: `sudo /usr/local/bin/vdi-monitor.sh --diagnostics`
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/defaults/main.yaml b/community/modules/scripts/vdi-setup/roles/vdi_monitor/defaults/main.yaml
new file mode 100644
index 0000000000..7bd4745f09
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/defaults/main.yaml
@@ -0,0 +1,24 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+# VDI Monitor Role Defaults
+
+# Monitoring configuration
+vdi_monitor_check_interval: 60 # seconds
+vdi_monitor_reconfig_cooldown: 180 # seconds (3 minutes)
+
+# Logging
+vdi_monitor_log_file: "/var/log/vdi-monitor.log"
+vdi_monitor_ansible_log_file: "/var/log/ansible-vdi-reconfig.log"
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/vdi_monitor/tasks/main.yaml
new file mode 100644
index 0000000000..708c1f365a
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/tasks/main.yaml
@@ -0,0 +1,214 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+---
+- name: DEBUG About to import lock_manager for vdi_monitor
+ debug:
+ msg: "About to import lock_manager for vdi_monitor"
+
+# Check if this role should run
+- name: Set current role for lock manager check
+ ansible.builtin.set_fact:
+ current_role: "vdi_monitor"
+
+- name: Check if vdi_monitor role should run
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: check_lock
+
+- name: DEBUG Finished import_role for vdi_monitor
+ debug:
+ msg: "Finished import_role for vdi_monitor"
+
+- name: Set role variables from lock manager facts
+ ansible.builtin.set_fact:
+ role_should_run: "{{ role_should_run | default(false) }}"
+ is_fresh_deployment: "{{ is_fresh_deployment | default(false) }}"
+ deployment_hash: "{{ current_deployment_hash | default('none') }}"
+ user_hash: "{{ current_user_secrets_hash | default('none') }}"
+ lock_file_stat: "{{ lock_file_stat | default({}) }}"
+ vdi_setup_status: "{{ vdi_setup_status | default({}) }}"
+
+- name: Debug role variables
+ ansible.builtin.debug:
+ msg: |
+ VDI Monitor role variables:
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment }}
+ - deployment_hash: {{ deployment_hash }}
+ - user_hash: {{ user_hash }}
+ - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }}
+ when: debug
+
+- name: Set VDI monitor variables
+ ansible.builtin.set_fact:
+ vdi_monitor_reconfig_cooldown: "{{ vdi_monitor_reconfig_cooldown | default(300) }}"
+ vdi_monitor_check_interval: "{{ vdi_monitor_check_interval | default(60) }}"
+
+# Skip all tasks if role should not run
+- name: Skip vdi_monitor tasks if role should not run
+ ansible.builtin.debug:
+ msg: "Skipping vdi_monitor role - already completed or not needed"
+ when: not role_should_run
+
+- name: Debug VDI monitor role execution
+ ansible.builtin.debug:
+ msg: |
+ VDI monitor role started:
+ - role_should_run: {{ role_should_run }}
+ - is_fresh_deployment: {{ is_fresh_deployment | default(false) }}
+ - lock_file_exists: {{ lock_file_stat.stat.exists | default(false) }}
+ when: role_should_run
+
+- name: Ensure VDI setup directory exists
+ ansible.builtin.file:
+ path: /opt/vdi-setup
+ state: directory
+ mode: '0755'
+ register: vdi_dir_creation
+ when: role_should_run
+
+- name: Create VDI monitor module directory
+ ansible.builtin.file:
+ path: /opt/vdi-setup/vdi-monitor-lib
+ state: directory
+ mode: '0755'
+ register: monitor_lib_creation
+ when: role_should_run
+
+- name: Debug directory creation status
+ ansible.builtin.debug:
+ msg: "VDI dir created: {{ vdi_dir_creation.changed }}, Monitor lib created: {{ monitor_lib_creation.changed }}"
+ when: debug and role_should_run
+
+# Copy VDI monitor modules
+- name: Copy VDI monitor configuration module
+ ansible.builtin.template:
+ src: vdi-monitor-config.sh.j2
+ dest: /opt/vdi-setup/vdi-monitor-lib/config.sh
+ mode: '0755'
+ owner: root
+ group: root
+ register: config_module_copy
+ when: role_should_run
+
+- name: Copy VDI monitor file module
+ ansible.builtin.template:
+ src: vdi-monitor-file.sh.j2
+ dest: /opt/vdi-setup/vdi-monitor-lib/file-monitor.sh
+ mode: '0755'
+ owner: root
+ group: root
+ register: file_module_copy
+ when: role_should_run
+
+- name: Copy VDI monitor change detector module
+ ansible.builtin.template:
+ src: vdi-monitor-detector.sh.j2
+ dest: /opt/vdi-setup/vdi-monitor-lib/change-detector.sh
+ mode: '0755'
+ owner: root
+ group: root
+ register: detector_module_copy
+ when: role_should_run
+
+- name: Copy VDI monitor test module
+ ansible.builtin.template:
+ src: vdi-monitor-test.sh.j2
+ dest: /opt/vdi-setup/vdi-monitor-lib/test-utils.sh
+ mode: '0755'
+ owner: root
+ group: root
+ register: test_module_copy
+ when: role_should_run
+
+- name: Copy VDI monitor main script
+ ansible.builtin.template:
+ src: vdi-monitor.sh.j2
+ dest: /usr/local/bin/vdi-monitor.sh
+ mode: '0755'
+ owner: root
+ group: root
+ register: main_script_copy
+ when: role_should_run
+
+- name: Copy VDI monitor systemd service
+ ansible.builtin.template:
+ src: vdi-monitor.service.j2
+ dest: /etc/systemd/system/vdi-monitor.service
+ mode: '0644'
+ owner: root
+ register: service_copy
+ when: role_should_run
+
+- name: Debug module copy status
+ ansible.builtin.debug:
+ msg: "VDI monitor modules copied - config: {{ config_module_copy.changed }}, file: {{ file_module_copy.changed }}, detector: {{ detector_module_copy.changed }}, test: {{ test_module_copy.changed }}, main: {{ main_script_copy.changed }}, service: {{ service_copy.changed }}"
+ when: debug and role_should_run
+
+# Enable and start VDI monitor service
+- name: Reload systemd daemon
+ systemd:
+ daemon_reload: yes
+ when: role_should_run and service_copy.changed
+ register: daemon_reload_result
+
+- name: Check if VDI monitor service is already running
+ systemd:
+ name: vdi-monitor
+ register: monitor_service_status
+ when: role_should_run
+
+- name: Debug VDI monitor service status check
+ ansible.builtin.debug:
+ msg: "VDI monitor service status - Active: {{ monitor_service_status.status.ActiveState }}, Enabled: {{ monitor_service_status.status.LoadState }}"
+ when: debug and role_should_run
+
+- name: Enable VDI monitor service
+ systemd:
+ name: vdi-monitor
+ enabled: yes
+ state: started
+ register: monitor_service_start
+ when: role_should_run and not monitor_service_status.status.ActiveState == "active"
+
+- name: Set service start result for already running service
+ ansible.builtin.set_fact:
+ monitor_service_start:
+ changed: false
+ status: "{{ monitor_service_status.status }}"
+ when: role_should_run and monitor_service_status.status.ActiveState == "active"
+
+- name: Debug VDI monitor service status
+ ansible.builtin.debug:
+ msg: "VDI monitor service started: {{ monitor_service_start.changed }}"
+ when: debug and role_should_run
+
+- name: Debug VDI monitor role completion
+ ansible.builtin.debug:
+ msg: "VDI monitor role completed successfully"
+ when: role_should_run
+
+# Mark role as completed
+- name: Set current role for lock manager completion
+ ansible.builtin.set_fact:
+ current_role: "vdi_monitor"
+ role_completed: true
+ when: role_should_run
+
+- name: Mark vdi_monitor role as completed
+ ansible.builtin.import_role:
+ name: lock_manager
+ tasks_from: create_lock
+ when: role_should_run
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-config.sh.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-config.sh.j2
new file mode 100644
index 0000000000..912eb6d468
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-config.sh.j2
@@ -0,0 +1,138 @@
+#!/bin/bash
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# VDI Monitor Configuration Module
+# Contains constants, logging functions, and initialization logic
+
+# Constants
+LOCK_FILE="/opt/vdi-setup/.vdi-lock.yaml"
+CONFIG_HASH_FILE="/opt/vdi-setup/.config-hash"
+MONITOR_LOG="/var/log/vdi-monitor.log"
+ANSIBLE_LOG="/var/log/ansible-vdi-reconfig.log"
+VDI_SETUP_DIR="/opt/vdi-setup"
+
+# Reconfiguration cooldown (prevents rapid reconfigurations)
+RECONFIG_COOLDOWN={{ vdi_monitor_reconfig_cooldown | default(300) }} # seconds between reconfigurations
+
+# Function to log messages
+log_message() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$MONITOR_LOG"
+}
+
+# Function to log messages without stdout output (for functions that return values)
+log_message_quiet() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$MONITOR_LOG"
+}
+
+# Function to get bucket name from vars file
+get_bucket_name() {
+ local vars_file="$VDI_SETUP_DIR/vars.yaml"
+
+ log_message_quiet "DEBUG: Getting bucket name from: $vars_file"
+
+ if [[ -f "$vars_file" ]]; then
+ log_message_quiet "DEBUG: vars.yaml file exists"
+ local bucket_name=$(grep "^vdi_bucket_name:" "$vars_file" | awk '{print $2}' | tr -d ' ')
+ log_message_quiet "DEBUG: Extracted bucket name: '$bucket_name'"
+ if [[ -n "$bucket_name" ]]; then
+ echo "$bucket_name"
+ return 0
+ else
+ log_message_quiet "WARNING: Bucket name is empty after extraction"
+ fi
+ else
+ log_message_quiet "WARNING: vars.yaml file not found: $vars_file"
+ fi
+
+ echo ""
+ return 1
+}
+
+# Function to validate environment
+validate_environment() {
+ # Check if VDI setup directory exists
+ if [[ ! -d "$VDI_SETUP_DIR" ]]; then
+ log_message "ERROR: VDI setup directory not found: $VDI_SETUP_DIR"
+ return 1
+ fi
+
+ # Check if vars.yaml exists
+ if [[ ! -f "$VDI_SETUP_DIR/vars.yaml" ]]; then
+ log_message "ERROR: vars.yaml not found: $VDI_SETUP_DIR/vars.yaml"
+ return 1
+ fi
+
+ # Get and validate bucket name
+ local bucket_name=$(get_bucket_name)
+ if [[ -z "$bucket_name" ]]; then
+ log_message "ERROR: Could not extract bucket name from vars.yaml"
+ return 1
+ fi
+
+ # Check if gcloud is available
+ if ! command -v gcloud >/dev/null 2>&1; then
+ log_message "ERROR: gcloud command not found"
+ return 1
+ fi
+
+ # Check if gsutil is available
+ if ! command -v gsutil >/dev/null 2>&1; then
+ log_message "ERROR: gsutil command not found"
+ return 1
+ fi
+
+ log_message "Environment validation passed"
+ return 0
+}
+
+# Function to create necessary directories and files
+setup_directories() {
+ # Ensure log directory exists
+ mkdir -p "$(dirname "$MONITOR_LOG")"
+
+ # Ensure VDI setup directory exists
+ mkdir -p "$VDI_SETUP_DIR"
+
+ # Create log files if they don't exist
+ touch "$MONITOR_LOG"
+ touch "$ANSIBLE_LOG"
+
+ log_message "Directories and files setup complete"
+}
+
+# Function to initialize monitoring system
+initialize_monitoring() {
+ log_message "Initializing VDI monitoring system"
+
+ # Setup directories
+ setup_directories
+
+ # Validate environment
+ if ! validate_environment; then
+ log_message "ERROR: Environment validation failed"
+ exit 1
+ fi
+
+ # Get bucket name
+ BUCKET_NAME=$(get_bucket_name)
+ if [[ -z "$BUCKET_NAME" ]]; then
+ log_message "ERROR: Bucket name not available. Cannot proceed with monitoring."
+ exit 1
+ fi
+
+ log_message "Using bucket: gs://$BUCKET_NAME"
+ log_message "VDI monitoring system initialized successfully"
+}
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-detector.sh.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-detector.sh.j2
new file mode 100644
index 0000000000..7e6515a4fd
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-detector.sh.j2
@@ -0,0 +1,341 @@
+#!/bin/bash
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# VDI Monitor Change Detection Module
+# Contains change detection logic and reconfiguration functions
+
+# Function to check if monitoring system is initialized
+is_system_initialized() {
+ local vars_file="$VDI_SETUP_DIR/vars.yaml"
+ if [[ ! -f "$vars_file" ]]; then
+ return 1
+ fi
+
+ # Check if file tracking is initialized for all required files
+ if [[ ! -f "/opt/vdi-setup/.current_vars_file" ]] || \
+ [[ ! -f "/opt/vdi-setup/.current_install_file" ]] || \
+ [[ ! -f "/opt/vdi-setup/.current_roles_file" ]]; then
+ return 1
+ fi
+
+ return 0 # Fully initialized
+}
+
+# Function to check if configuration files have changed
+check_config_changed() {
+ local changed=false
+ local change_details=""
+
+ # Check vars.yaml
+ local current_vars_file=$(get_current_file_name "vars.yaml")
+ local stored_vars_file=$(get_stored_file_name "vars")
+
+ if [[ "$current_vars_file" != "$stored_vars_file" ]]; then
+ change_details+="vars.yaml: $stored_vars_file -> $current_vars_file; "
+ changed=true
+ fi
+
+ # Check install.yaml
+ local current_install_file=$(get_current_file_name "install.yaml")
+ local stored_install_file=$(get_stored_file_name "install")
+
+ if [[ "$current_install_file" != "$stored_install_file" ]]; then
+ change_details+="install.yaml: $stored_install_file -> $current_install_file; "
+ changed=true
+ fi
+
+ # Check roles.tar.gz
+ local current_roles_file=$(get_current_file_name "roles.tar.gz")
+ local stored_roles_file=$(get_stored_file_name "roles")
+
+ if [[ "$current_roles_file" != "$stored_roles_file" ]]; then
+ change_details+="roles.tar.gz: $stored_roles_file -> $current_roles_file; "
+ changed=true
+ fi
+
+ # Check for reset_password flags
+ if check_reset_password_flags; then
+ change_details+="reset_password flags detected; "
+ changed=true
+ fi
+
+ # Store change details for logging
+ if [[ "$changed" == "true" ]]; then
+ echo "$change_details" > "/opt/vdi-setup/.pending_changes"
+ log_message "Configuration changes detected: $change_details"
+ return 0
+ else
+ return 1
+ fi
+}
+
+# Function to check if any user has reset_password flags set
+check_reset_password_flags() {
+ local vars_file="$VDI_SETUP_DIR/vars.yaml"
+ if [[ ! -f "$vars_file" ]]; then
+ return 1
+ fi
+
+ local in_users_section=false
+ local has_reset_flag=false
+
+ while IFS= read -r line; do
+ # Check if we're in the vdi_users section
+ if [[ "$line" =~ ^[[:space:]]*vdi_users:[[:space:]]*$ ]]; then
+ in_users_section=true
+ continue
+ fi
+
+ # Check if we've left the vdi_users section
+ if [[ "$in_users_section" == "true" && "$line" =~ ^[[:space:]]*[a-zA-Z] ]]; then
+ in_users_section=false
+ continue
+ fi
+
+ if [[ "$in_users_section" == "true" ]]; then
+ # Check for reset_password flag
+ if [[ "$line" =~ reset_password:[[:space:]]*(true|yes|1) ]]; then
+ has_reset_flag=true
+ log_message "DEBUG: Found reset_password flag set to true"
+ break
+ fi
+ fi
+ done < "$vars_file"
+
+ if [[ "$has_reset_flag" == "true" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+# Function to trigger VDI re-configuration
+trigger_reconfig() {
+ log_message "Triggering VDI re-configuration"
+
+ # Update deployment status to "reconfiguring"
+ update_deployment_status "reconfiguring"
+
+ # Log pending changes if available
+ if [[ -f "/opt/vdi-setup/.pending_changes" ]]; then
+ local pending_changes=$(cat "/opt/vdi-setup/.pending_changes")
+ log_message "Pending changes to apply: $pending_changes"
+ fi
+
+ # First, sync latest files from bucket
+ if ! sync_changed_files; then
+ log_message "WARNING: No files changed or failed to sync, proceeding with local files"
+ fi
+
+ # Change to VDI setup directory
+ cd "$VDI_SETUP_DIR" || {
+ log_message "ERROR: Cannot change to $VDI_SETUP_DIR"
+ update_deployment_status "available"
+ return 1
+ }
+
+ # Verify files exist before running Ansible
+ log_message "DEBUG: Checking required files before Ansible execution"
+ if [[ ! -f "install.yaml" ]]; then
+ log_message "ERROR: install.yaml not found in $VDI_SETUP_DIR"
+ update_deployment_status "available"
+ return 1
+ fi
+ if [[ ! -f "vars.yaml" ]]; then
+ log_message "ERROR: vars.yaml not found in $VDI_SETUP_DIR"
+ update_deployment_status "available"
+ return 1
+ fi
+
+ log_message "DEBUG: Running Ansible playbook from $(pwd)"
+ log_message "DEBUG: Command: ansible-playbook install.yaml --connection=local --inventory=localhost, --limit=localhost --extra-vars=@vars.yaml"
+
+ # Run Ansible playbook with re-configuration
+ ansible-playbook install.yaml \
+ --connection=local \
+ --inventory=localhost, \
+ --limit=localhost \
+ --extra-vars="@vars.yaml" \
+ --extra-vars="debug=true" \
+ >> "$ANSIBLE_LOG" 2>&1
+
+ local exit_code=$?
+ log_message "DEBUG: Ansible exit code: $exit_code"
+
+ if [[ $exit_code -eq 0 ]]; then
+ log_message "VDI re-configuration completed successfully"
+ update_deployment_status "available"
+ else
+ log_message "ERROR: VDI re-configuration failed with exit code $exit_code"
+ log_message "Check $ANSIBLE_LOG for details"
+ # Show last few lines of Ansible log for debugging
+ if [[ -f "$ANSIBLE_LOG" ]]; then
+ log_message "DEBUG: Last 10 lines of Ansible log:"
+ tail -10 "$ANSIBLE_LOG" | while read line; do
+ log_message "DEBUG: $line"
+ done
+ fi
+ update_deployment_status "failed"
+ fi
+
+ return $exit_code
+}
+
+# Function to update deployment status
+update_deployment_status() {
+ local status="$1"
+ local instance_name=$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/name")
+ local zone=$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/zone" | sed 's/.*\/\([^\/]*\)$/\1/')
+ local project_id=$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/project/project-id")
+
+ if [[ -n "$instance_name" && -n "$zone" && -n "$project_id" ]]; then
+ log_message "Updating deployment status to: $status"
+
+ # Retry logic for metadata updates to handle fingerprint conflicts
+ local max_retries=3
+ local retry_count=0
+ local success=false
+
+ while [[ $retry_count -lt $max_retries && "$success" == "false" ]]; do
+ if gcloud compute instances add-metadata "$instance_name" \
+ --zone="$zone" \
+ --project="$project_id" \
+ --metadata="deployment-status=$status" \
+ --quiet 2>/dev/null; then
+ success=true
+ log_message "Successfully updated deployment status to: $status"
+ else
+ retry_count=$((retry_count + 1))
+ if [[ $retry_count -lt $max_retries ]]; then
+ log_message "WARNING: Failed to update deployment status (attempt $retry_count/$max_retries), retrying in 2 seconds..."
+ sleep 2
+ else
+ log_message "WARNING: Failed to update deployment status after $max_retries attempts"
+ fi
+ fi
+ done
+ else
+ log_message "WARNING: Could not determine instance metadata for status update"
+ fi
+}
+
+# Function to run monitoring loop
+run_monitoring_loop() {
+ log_message "Starting VDI monitoring loop"
+
+ # Initialize file tracking
+ initialize_file_tracking
+
+ # Always refresh file tracking on startup to capture current bucket state
+ log_message "Refreshing file tracking to capture current bucket state..."
+
+ # Debug: Check bucket name and tracking files
+ log_message "DEBUG: Bucket name: '$BUCKET_NAME'"
+ log_message "DEBUG: Checking existing tracking files:"
+ log_message "DEBUG: .current_vars_file exists: $([[ -f "/opt/vdi-setup/.current_vars_file" ]] && echo 'yes' || echo 'no')"
+ log_message "DEBUG: .current_install_file exists: $([[ -f "/opt/vdi-setup/.current_install_file" ]] && echo 'yes' || echo 'no')"
+ log_message "DEBUG: .current_roles_file exists: $([[ -f "/opt/vdi-setup/.current_roles_file" ]] && echo 'yes' || echo 'no')"
+
+ # Update file tracking with current bucket state
+ local current_vars_file=$(get_current_file_name "vars.yaml")
+ log_message "DEBUG: Current vars file from bucket: '$current_vars_file'"
+ if [[ -n "$current_vars_file" ]]; then
+ store_file_name "vars" "$current_vars_file"
+ log_message "Updated vars file tracking: $current_vars_file"
+ else
+ log_message "WARNING: No vars.yaml file found in bucket"
+ fi
+
+ local current_install_file=$(get_current_file_name "install.yaml")
+ log_message "DEBUG: Current install file from bucket: '$current_install_file'"
+ if [[ -n "$current_install_file" ]]; then
+ store_file_name "install" "$current_install_file"
+ log_message "Updated install file tracking: $current_install_file"
+ else
+ log_message "WARNING: No install.yaml file found in bucket"
+ fi
+
+ local current_roles_file=$(get_current_file_name "roles.tar.gz")
+ log_message "DEBUG: Current roles file from bucket: '$current_roles_file'"
+ if [[ -n "$current_roles_file" ]]; then
+ store_file_name "roles" "$current_roles_file"
+ log_message "Updated roles file tracking: $current_roles_file"
+ else
+ log_message "WARNING: No roles.tar.gz file found in bucket"
+ fi
+
+ log_message "File tracking refresh complete"
+
+ # Cooldown variable to prevent rapid reconfigurations
+ local last_reconfig_time=0
+
+ # Check for changes immediately after initialization
+ log_message "Performing initial change detection check..."
+
+ # Debug: Show current file tracking state
+ log_message "DEBUG: Current file tracking state:"
+ local stored_vars=$(get_stored_file_name 'vars')
+ local stored_install=$(get_stored_file_name 'install')
+ local stored_roles=$(get_stored_file_name 'roles')
+ log_message "DEBUG: vars.yaml: '$stored_vars'"
+ log_message "DEBUG: install.yaml: '$stored_install'"
+ log_message "DEBUG: roles.tar.gz: '$stored_roles'"
+
+ if check_config_changed; then
+ log_message "Configuration changes detected during initialization - triggering reconfiguration"
+ if trigger_reconfig; then
+ last_reconfig_time=$(date +%s)
+ rm -f "/opt/vdi-setup/.pending_changes"
+ fi
+ else
+ log_message "No configuration changes detected during initialization"
+ fi
+
+ # Main monitoring loop
+ local check_count=0
+ while true; do
+ local current_time=$(date +%s)
+ check_count=$((check_count + 1))
+
+ # Log periodic check (every 5th check to avoid log spam)
+ if [[ $((check_count % 5)) -eq 0 ]]; then
+ log_message "Periodic check #${check_count} - checking for configuration changes..."
+ fi
+
+ if check_config_changed; then
+ # Check if enough time has passed since last reconfiguration
+ local time_since_last_reconfig=$((current_time - last_reconfig_time))
+
+ if [[ $time_since_last_reconfig -ge $RECONFIG_COOLDOWN ]]; then
+ log_message "Configuration changes detected - triggering reconfiguration"
+ if trigger_reconfig; then
+ last_reconfig_time=$current_time
+ rm -f "/opt/vdi-setup/.pending_changes"
+ fi
+ else
+ log_message "Changes detected but cooldown active (${time_since_last_reconfig}s/${RECONFIG_COOLDOWN}s) - waiting for cooldown to expire"
+ fi
+ else
+ # Log when no changes detected (every 5th check)
+ if [[ $((check_count % 5)) -eq 0 ]]; then
+ log_message "No configuration changes detected in check #${check_count}"
+ fi
+ fi
+
+ # Sleep for configured interval before next check
+ sleep {{ vdi_monitor_check_interval | default(60) }}
+ done
+}
diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-file.sh.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-file.sh.j2
new file mode 100644
index 0000000000..e0587d01a7
--- /dev/null
+++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-file.sh.j2
@@ -0,0 +1,183 @@
+#!/bin/bash
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# VDI Monitor File Module
+# Contains file change detection and synchronization functions
+
+# Function to get GCS file hash
+get_gcs_hash() {
+ local gcs_path="$1"
+ log_message_quiet "DEBUG: Getting GCS hash for: $gcs_path"
+ local hash_result=$(gcloud storage hash "$gcs_path" --skip-crc32c 2>&1)
+ local exit_code=$?
+ if [[ $exit_code -eq 0 ]]; then
+ # Extract MD5 hash from output (format: "MD5 hash: