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: ") + local md5_hash=$(echo "$hash_result" | grep "MD5 hash:" | awk '{print $3}') + if [[ -n "$md5_hash" ]]; then + log_message_quiet "DEBUG: GCS hash result: $md5_hash" + echo "$md5_hash" + else + log_message_quiet "ERROR: Could not extract MD5 hash from output: $hash_result" + echo "INVALID-HASH" + fi + else + log_message_quiet "ERROR: Failed to get GCS hash for $gcs_path: $hash_result (exit code: $exit_code)" + echo "INVALID-HASH" + fi +} + +# Function to get local file hash +get_local_hash() { + local local_path="$1" + log_message_quiet "DEBUG: Getting local hash for: $local_path" + if [[ -f "$local_path" ]]; then + local hash_result=$(gcloud storage hash "$local_path" --skip-crc32c 2>&1) + local exit_code=$? + if [[ $exit_code -eq 0 ]]; then + # Extract MD5 hash from output (format: "MD5 hash: ") + local md5_hash=$(echo "$hash_result" | grep "MD5 hash:" | awk '{print $3}') + if [[ -n "$md5_hash" ]]; then + log_message_quiet "DEBUG: Local hash result: $md5_hash" + echo "$md5_hash" + else + log_message_quiet "ERROR: Could not extract MD5 hash from output: $hash_result" + echo "INVALID-HASH" + fi + else + log_message_quiet "ERROR: Failed to get local hash for $local_path: $hash_result (exit code: $exit_code)" + echo "INVALID-HASH" + fi + else + log_message_quiet "DEBUG: Local file does not exist: $local_path" + echo "INVALID-HASH" + fi +} + +# Function to get current file name from bucket (with hash suffix) +get_current_file_name() { + local base_name="$1" + local pattern="gs://$BUCKET_NAME/$base_name-*" + local current_file=$(gsutil ls "$pattern" 2>/dev/null | head -1) + if [[ -n "$current_file" ]]; then + # Extract just the filename from the full path + basename "$current_file" + else + echo "" + fi +} + +# Function to get stored file name +get_stored_file_name() { + local file_type="$1" + local stored_file="/opt/vdi-setup/.current_${file_type}_file" + if [[ -f "$stored_file" ]]; then + cat "$stored_file" + else + echo "" + fi +} + +# Function to store current file name +store_file_name() { + local file_type="$1" + local file_name="$2" + echo "$file_name" > "/opt/vdi-setup/.current_${file_type}_file" +} + +# Function to sync changed files from bucket +sync_changed_files() { + log_message "Syncing changed files from bucket: gs://$BUCKET_NAME" + local sync_needed=false + + # Check and sync 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 + log_message "Syncing vars.yaml: $current_vars_file" + gsutil cp "gs://$BUCKET_NAME/$current_vars_file" "$VDI_SETUP_DIR/vars.yaml" + store_file_name "vars" "$current_vars_file" + sync_needed=true + fi + + # Check and sync 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 + log_message "Syncing install.yaml: $current_install_file" + gsutil cp "gs://$BUCKET_NAME/$current_install_file" "$VDI_SETUP_DIR/install.yaml" + store_file_name "install" "$current_install_file" + sync_needed=true + fi + + # Check and sync roles.tar.gz and unpack if needed + 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 + log_message "Syncing roles.tar.gz: $current_roles_file" + gsutil cp "gs://$BUCKET_NAME/$current_roles_file" "$VDI_SETUP_DIR/roles.tar.gz" + store_file_name "roles" "$current_roles_file" + + # Unpack roles + log_message "Unpacking updated roles" + tar xzf "$VDI_SETUP_DIR/roles.tar.gz" -C "$VDI_SETUP_DIR/" + sync_needed=true + fi + + if [[ "$sync_needed" == "true" ]]; then + log_message "Successfully synced changed files from bucket" + return 0 + else + log_message "No files needed syncing" + return 1 + fi +} + +# Function to initialize file tracking +initialize_file_tracking() { + log_message "Initializing file tracking" + + # Initialize vars.yaml tracking + if [[ ! -f "/opt/vdi-setup/.current_vars_file" ]]; then + local current_vars_file=$(get_current_file_name "vars.yaml") + if [[ -n "$current_vars_file" ]]; then + store_file_name "vars" "$current_vars_file" + log_message "Initialized vars file tracking: $current_vars_file" + fi + fi + + # Initialize install.yaml tracking + if [[ ! -f "/opt/vdi-setup/.current_install_file" ]]; then + local current_install_file=$(get_current_file_name "install.yaml") + if [[ -n "$current_install_file" ]]; then + store_file_name "install" "$current_install_file" + log_message "Initialized install file tracking: $current_install_file" + fi + fi + + # Initialize roles.tar.gz tracking + if [[ ! -f "/opt/vdi-setup/.current_roles_file" ]]; then + local current_roles_file=$(get_current_file_name "roles.tar.gz") + if [[ -n "$current_roles_file" ]]; then + store_file_name "roles" "$current_roles_file" + log_message "Initialized roles file tracking: $current_roles_file" + fi + fi + + log_message "File tracking initialization complete" +} diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-test.sh.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-test.sh.j2 new file mode 100644 index 0000000000..9eb94e5a38 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor-test.sh.j2 @@ -0,0 +1,141 @@ +#!/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 Testing Module +# Contains testing and diagnostic functions + +# Function to run comprehensive tests +run_tests() { + log_message "VDI Configuration Monitor - Test Mode" + log_message "Testing GCS hash detection functionality..." + + # Test 1: Check if gcloud is available + log_message "Test 1: Checking gcloud availability..." + if command -v gcloud >/dev/null 2>&1; then + log_message "SUCCESS: gcloud is available" + gcloud --version | head -1 + else + log_message "ERROR: gcloud is not available" + exit 1 + fi + + # Test 2: Check if we can access the bucket + log_message "Test 2: Checking bucket access..." + if gsutil ls "gs://$BUCKET_NAME/" >/dev/null 2>&1; then + log_message "SUCCESS: Can access bucket gs://$BUCKET_NAME/" + else + log_message "ERROR: Cannot access bucket gs://$BUCKET_NAME/" + exit 1 + fi + + # Test 3: List bucket contents + log_message "Test 3: Listing bucket contents..." + gsutil ls "gs://$BUCKET_NAME/" + + # Test 4: Test file detection + log_message "Test 4: Testing file detection..." + local test_vars_file=$(get_current_file_name "vars.yaml") + local test_install_file=$(get_current_file_name "install.yaml") + local test_roles_file=$(get_current_file_name "roles.tar.gz") + + if [[ -n "$test_vars_file" && -n "$test_install_file" && -n "$test_roles_file" ]]; then + log_message "SUCCESS: File detection works" + log_message "Current files:" + log_message " vars.yaml: $test_vars_file" + log_message " install.yaml: $test_install_file" + log_message " roles.tar.gz: $test_roles_file" + else + log_message "ERROR: File detection failed" + log_message " vars.yaml: $test_vars_file" + log_message " install.yaml: $test_install_file" + log_message " roles.tar.gz: $test_roles_file" + exit 1 + fi + + # Test 5: Test reset password flag detection + log_message "Test 5: Testing reset password flag detection..." + if check_reset_password_flags; then + log_message "SUCCESS: Reset password flag detected" + else + log_message "INFO: No reset password flags found" + fi + + # Test 6: Check if configuration change detection works + log_message "Test 6: Testing configuration change detection..." + + # Check if system is properly initialized + if ! is_system_initialized; then + log_message "WARNING: System not fully initialized - initializing now..." + + # Initialize file tracking + local current_vars_file=$(get_current_file_name "vars.yaml") + if [[ -n "$current_vars_file" ]]; then + store_file_name "vars" "$current_vars_file" + fi + + local current_install_file=$(get_current_file_name "install.yaml") + if [[ -n "$current_install_file" ]]; then + store_file_name "install" "$current_install_file" + fi + + local current_roles_file=$(get_current_file_name "roles.tar.gz") + if [[ -n "$current_roles_file" ]]; then + store_file_name "roles" "$current_roles_file" + fi + + log_message "System initialization complete" + fi + + # Now test change detection + if check_config_changed; then + log_message "SUCCESS: Configuration change detected" + exit 0 + else + log_message "INFO: No configuration changes detected" + exit 0 + fi +} + +# Function to run diagnostics +run_diagnostics() { + log_message "VDI Monitor Diagnostics" + + # Environment information + log_message "Environment:" + log_message " VDI_SETUP_DIR: $VDI_SETUP_DIR" + log_message " BUCKET_NAME: $BUCKET_NAME" + log_message " MONITOR_LOG: $MONITOR_LOG" + log_message " ANSIBLE_LOG: $ANSIBLE_LOG" + + # Check file existence + log_message "File Status:" + log_message " vars.yaml exists: $([[ -f "$VDI_SETUP_DIR/vars.yaml" ]] && echo 'yes' || echo 'no')" + log_message " install.yaml exists: $([[ -f "$VDI_SETUP_DIR/install.yaml" ]] && echo 'yes' || echo 'no')" + log_message " roles directory exists: $([[ -d "$VDI_SETUP_DIR/roles" ]] && echo 'yes' || echo 'no')" + + # Check tracking files + log_message "Tracking Files:" + log_message " vars tracking: $([[ -f "/opt/vdi-setup/.current_vars_file" ]] && echo 'yes' || echo 'no')" + log_message " install tracking: $([[ -f "/opt/vdi-setup/.current_install_file" ]] && echo 'yes' || echo 'no')" + log_message " roles tracking: $([[ -f "/opt/vdi-setup/.current_roles_file" ]] && echo 'yes' || echo 'no')" + + # System status + log_message "System Status:" + log_message " Initialized: $(is_system_initialized && echo 'yes' || echo 'no')" + log_message " gcloud available: $(command -v gcloud >/dev/null 2>&1 && echo 'yes' || echo 'no')" + log_message " gsutil available: $(command -v gsutil >/dev/null 2>&1 && echo 'yes' || echo 'no')" + log_message " ansible-playbook available: $(command -v ansible-playbook >/dev/null 2>&1 && echo 'yes' || echo 'no')" +} diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.service.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.service.j2 new file mode 100644 index 0000000000..6f4016df3b --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.service.j2 @@ -0,0 +1,33 @@ +{# +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. +#} + +[Unit] +Description=VDI Configuration Monitor +After=network.target +Wants=network.target + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/local/bin/vdi-monitor.sh +Restart=always +RestartSec=30 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.sh.j2 b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.sh.j2 new file mode 100644 index 0000000000..23c6b7d81f --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_monitor/templates/vdi-monitor.sh.j2 @@ -0,0 +1,132 @@ +#!/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 Configuration Monitor - Main Orchestrator +# Sources all modules and provides the main entry point + +# Source all modules +source /opt/vdi-setup/vdi-monitor-lib/config.sh +source /opt/vdi-setup/vdi-monitor-lib/file-monitor.sh +source /opt/vdi-setup/vdi-monitor-lib/change-detector.sh +source /opt/vdi-setup/vdi-monitor-lib/test-utils.sh + +# 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 +} + +# Main execution +main() { + # Parse command line arguments + while [[ $# -gt 0 ]]; do + case $1 in + --test) + run_tests + exit 0 + ;; + --diagnostics) + run_diagnostics + exit 0 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac + done + + # Initialize monitoring system + initialize_monitoring + + # Set initial deployment status to available + update_deployment_status "available" + + # Run the monitoring loop + run_monitoring_loop +} + +# Entry point +case "${1:-}" in + --test) + # Initialize for testing + initialize_monitoring + run_tests + ;; + --diagnostics) + # Initialize for diagnostics + initialize_monitoring + run_diagnostics + ;; + --help|-h) + echo "VDI Configuration Monitor" + echo "" + echo "Usage: $0 [OPTION]" + echo "" + echo "Options:" + echo " --test Run comprehensive tests" + echo " --diagnostics Run system diagnostics" + echo " --help, -h Show this help message" + echo " (no args) Start monitoring" + echo "" + echo "The VDI Configuration Monitor automatically detects changes in:" + echo " - Configuration files (vars.yaml, install.yaml, roles.tar.gz)" + echo " - Password reset flags" + echo "" + echo "When changes are detected, it triggers VDI reconfiguration." + ;; + *) + # Default: start monitoring + main + ;; +esac diff --git a/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/guacamole.yaml b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/guacamole.yaml new file mode 100644 index 0000000000..2b07b55747 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/guacamole.yaml @@ -0,0 +1,1003 @@ +# 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. +--- + +# ============================================================================= +# DOCKER SETUP (IDEMPOTENT) +# ============================================================================= + +- name: Check Docker service status + ansible.builtin.service_facts: + +- name: Ensure Docker service is running + ansible.builtin.service: + name: docker + state: started + enabled: yes + register: docker_service_status + +- name: Wait for Docker daemon to be ready + ansible.builtin.wait_for: + path: /var/run/docker.sock + timeout: 30 + +- name: Test Docker connectivity + ansible.builtin.command: docker version + register: docker_test + retries: 5 + delay: 2 + until: docker_test.rc == 0 + +- name: Check if Docker images exist + ansible.builtin.shell: | + docker images | grep -E "postgres[[:space:]]+latest" && echo "postgres:latest" || echo "" + docker images | grep -E "guacamole/guacd[[:space:]]+latest" && echo "guacamole/guacd:latest" || echo "" + docker images | grep -E "guacamole/guacamole[[:space:]]+latest" && echo "guacamole/guacamole:latest" || echo "" + register: existing_images + changed_when: false + +- name: Pull required Docker images (only if missing) + ansible.builtin.command: docker pull {{ item }} + loop: + - postgres:latest + - guacamole/guacd:latest + - guacamole/guacamole:latest + when: item not in existing_images.stdout + +- name: Check if Docker network exists + ansible.builtin.shell: docker network ls | grep -E "guac_net" || echo "" + register: network_exists + changed_when: false + failed_when: false + +- name: Create a dedicated Docker network (only if missing) + ansible.builtin.command: docker network create guac_net + register: network_create + failed_when: + - network_create.rc != 0 + - "'already exists' not in network_create.stderr" + when: "'guac_net' not in network_exists.stdout" + +# ============================================================================= +# CONTAINER STATUS CHECKING +# ============================================================================= + +- name: Check if containers are already running + ansible.builtin.shell: docker ps | grep -E "(guac_db|guacd|guac_app)" || echo "" + register: running_containers + changed_when: false + failed_when: false + +- name: Debug container status + ansible.builtin.debug: + msg: | + Container status check: + - running_containers: {{ running_containers.stdout_lines }} + - guac_db running: {{ 'guac_db' in running_containers.stdout }} + - guacd running: {{ 'guacd' in running_containers.stdout }} + - guac_app running: {{ 'guac_app' in running_containers.stdout }} + when: debug + +# ============================================================================= +# DATABASE INITIALIZATION LOGIC +# ============================================================================= + +- name: Ensure PostgreSQL data & init directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: '0755' + loop: + - /opt/guacamole-db/data + - /opt/guacamole-db/initdb + +- name: Debug user configuration status + ansible.builtin.debug: + msg: | + User configuration status: + - is_fresh_deployment: {{ is_fresh_deployment }} + - deployment_hash: {{ deployment_hash }} + - user_hash: {{ user_hash }} + - User hash changed: {{ not is_fresh_deployment and vdi_setup_status.user_hash is defined and vdi_setup_status.user_hash != user_hash }} + when: debug + +- name: Check if users have changed (hash-based) + ansible.builtin.set_fact: + users_changed: "{{ not is_fresh_deployment and ((new_users is defined and new_users | length > 0) or (removed_users is defined and removed_users | length > 0) or (users_needing_reset is defined and users_needing_reset | length > 0) or reset_webapp_admin_password or (users_with_port_changes is defined and users_with_port_changes | length > 0)) }}" + +- name: Check if database needs initialization (smart detection) + ansible.builtin.set_fact: + # Only reinitialize database for infrastructure changes, not user changes + db_needs_init: "{{ is_fresh_deployment or force_rerun | default(false) or infrastructure_changed }}" + +- name: Debug user change detection (from lock_manager) + ansible.builtin.debug: + msg: | + User change detection (from lock_manager): + - users_changed: {{ users_changed }} + - new_users: {{ new_users | length if new_users is defined else 0 }} + - removed_users: {{ removed_users | length if removed_users is defined else 0 }} + - new_user_names: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }} + - removed_user_names: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }} + when: debug and users_changed + +- name: Debug user change detection + ansible.builtin.debug: + msg: | + User change detection: + - is_fresh_deployment: {{ is_fresh_deployment }} + - users_changed: {{ users_changed }} + - infrastructure_changed: {{ infrastructure_changed }} + - db_needs_init: {{ db_needs_init }} + - will_reinitialize: {{ db_needs_init }} + - will_apply_targeted_updates: {{ users_changed and not db_needs_init }} + - users_needing_reset: {{ users_needing_reset | length }} + - reset_webapp_admin_password: {{ reset_webapp_admin_password }} + - users_with_port_changes: {{ users_with_port_changes | length if users_with_port_changes is defined else 0 }} + - webapp_port_changed: {{ webapp_port_changed }} + - vdi_resolution_changed: {{ vdi_resolution_changed }} + - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }} + - new_users: {{ new_users | length if new_users is defined else 0 }} + - removed_users: {{ removed_users | length if removed_users is defined else 0 }} + - new_user_names: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }} + - removed_user_names: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }} + when: debug + +# ============================================================================= +# TARGETED USER MANAGEMENT (NO CONTAINER RECREATION) +# ============================================================================= + +- name: Handle user changes without container recreation + block: + - name: Get enriched new users from vdi_users_updated + ansible.builtin.set_fact: + new_users_enriched: "{{ vdi_users_updated | selectattr('username', 'in', new_users | map(attribute='username') | list) | list }}" + when: users_changed and new_users is defined and new_users | length > 0 + - name: Debug user update mode + ansible.builtin.debug: + msg: | + User changes detected - applying targeted updates (no container recreation): + - users_changed: {{ users_changed }} + - db_needs_init: {{ db_needs_init }} + - force_rerun: {{ force_rerun }} + - users_needing_reset: {{ users_needing_reset | length }} + - new_users: {{ new_users | length if new_users is defined else 0 }} + - removed_users: {{ removed_users | length if removed_users is defined else 0 }} + - new_user_names: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }} + - removed_user_names: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }} + when: debug + + # ============================================================================= + # ADD NEW USERS + # ============================================================================= + + - name: Add new users to database (without container recreation) + ansible.builtin.template: + src: guacamole/user_bootstrap.sql.j2 + dest: "/opt/guacamole-db/initdb/02-{{ item.username }}_user.sql" + owner: root + group: root + mode: '0644' + loop: "{{ new_users_enriched | default(new_users) }}" + when: new_users is defined and new_users | length > 0 + vars: + username: "{{ item.username }}" + vdi_password: "{{ item.password }}" + + - name: Execute new user SQL files + ansible.builtin.command: docker exec guac_db psql -U guacamole_db -d guacamole_db -f /docker-entrypoint-initdb.d/02-{{ item.username }}_user.sql + loop: "{{ new_users_enriched | default(new_users) }}" + when: new_users is defined and new_users | length > 0 + + - name: Add new user connections to database + ansible.builtin.template: + src: guacamole/connection_bootstrap.sql.j2 + dest: "/opt/guacamole-db/initdb/03-{{ item.username }}_connection.sql" + owner: root + group: root + mode: '0644' + loop: "{{ new_users_enriched | default(new_users) }}" + when: new_users is defined and new_users | length > 0 + vars: + username: "{{ item.username }}" + port: "{{ item.port }}" + vnc_password: "{{ item.vncserver_password }}" + + - name: Execute new connection SQL files + ansible.builtin.command: docker exec guac_db psql -U guacamole_db -d guacamole_db -f /docker-entrypoint-initdb.d/03-{{ item.username }}_connection.sql + loop: "{{ new_users_enriched | default(new_users) }}" + when: new_users is defined and new_users | length > 0 + + # ============================================================================= + # REMOVE/DISABLE USERS + # ============================================================================= + + - name: Disable removed users in Guacamole database + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "UPDATE guacamole_user SET disabled = true WHERE entity_id IN (SELECT entity_id FROM guacamole_entity WHERE name = '{{ item }}' AND type = 'USER');" + loop: "{{ removed_users }}" + when: removed_users is defined and removed_users | length > 0 + + - name: Revoke connection permissions for removed users + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "DELETE FROM guacamole_connection_permission WHERE entity_id IN (SELECT entity_id FROM guacamole_entity WHERE name = '{{ item }}' AND type = 'USER');" + loop: "{{ removed_users }}" + when: removed_users is defined and removed_users | length > 0 + + - name: Delete connections for removed users + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "DELETE FROM guacamole_connection WHERE connection_name IN ('{{ item }}', '{{ item }} SSH');" + loop: "{{ removed_users }}" + when: removed_users is defined and removed_users | length > 0 + + # ============================================================================= + # PASSWORD RESETS + # ============================================================================= + + - name: Update user passwords in database (for reset_password users) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c "UPDATE guacamole_user SET password_salt = NULL, password_hash = digest('{{ item.password }}', 'SHA-256'), password_date = now() FROM guacamole_entity WHERE guacamole_user.entity_id = guacamole_entity.entity_id AND guacamole_entity.name = '{{ item.username }}' AND guacamole_entity.type = 'USER';" + loop: "{{ vdi_users_updated | selectattr('reset_password', 'defined') | selectattr('reset_password', 'equalto', true) | list }}" + when: users_needing_reset | length > 0 + + # ============================================================================= + # VNC CONNECTION PASSWORD UPDATES + # ============================================================================= + + - name: Update VNC connection passwords in database (for all users) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c "UPDATE guacamole_connection_parameter SET parameter_value = '{{ item.vncserver_password }}' FROM guacamole_connection WHERE guacamole_connection_parameter.connection_id = guacamole_connection.connection_id AND guacamole_connection.connection_name = '{{ item.username }}' AND guacamole_connection_parameter.parameter_name = 'password';" + loop: "{{ vdi_users_updated }}" + when: users_changed + + # ============================================================================= + # WEBAPP ADMIN PASSWORD RESET + # ============================================================================= + + - name: Update webapp admin password in database (if reset requested) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "UPDATE guacamole_user SET password_salt = NULL, password_hash = decode('{{ webapp_admin_hash }}', 'hex'), password_date = now() FROM guacamole_entity WHERE guacamole_user.entity_id = guacamole_entity.entity_id AND guacamole_entity.name = 'guacadmin' AND guacamole_entity.type = 'USER';" + when: reset_webapp_admin_password and not is_fresh_deployment + + - name: Restart Guacamole webapp container (if webapp admin password reset) + ansible.builtin.command: docker restart guac_app + when: reset_webapp_admin_password and not is_fresh_deployment + + - name: Wait for Guacamole webapp to be ready after restart + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 5 + timeout: 60 + when: reset_webapp_admin_password and not is_fresh_deployment + + # ============================================================================= + # PORT CHANGES + # ============================================================================= + + - name: Update user connection ports in database (for port changes) + ansible.builtin.template: + src: guacamole/connection_port_update.sql.j2 + dest: "/opt/guacamole-db/initdb/04-{{ item.username }}_port_update.sql" + owner: root + group: root + mode: '0644' + loop: "{{ users_with_port_changes }}" + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + vars: + username: "{{ item.username }}" + port: "{{ item.port }}" + + - name: Execute port update SQL files + ansible.builtin.command: docker exec guac_db psql -U guacamole_db -d guacamole_db -f /docker-entrypoint-initdb.d/04-{{ item.username }}_port_update.sql + loop: "{{ users_with_port_changes }}" + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + + - name: Debug targeted updates completion + ansible.builtin.debug: + msg: | + Targeted user updates completed: + - New users added: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }} + - Users removed: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }} + - Password resets: {{ users_needing_reset | map(attribute='username') | list if users_needing_reset is defined and users_needing_reset | length > 0 else [] }} + - Webapp admin password reset: {{ reset_webapp_admin_password and not is_fresh_deployment }} + - User port changes: {{ users_with_port_changes | map(attribute='username') | list if users_with_port_changes is defined and users_with_port_changes | length > 0 else [] }} + when: debug + + when: users_changed and not db_needs_init + +# ============================================================================= +# RESOLUTION LOCKED CHANGES +# ============================================================================= + +- name: Update connection disable-display-resize parameter in database (for resolution locked changes) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "UPDATE guacamole_connection_parameter SET parameter_value = '{{ 'true' if vdi_resolution_locked else 'false' }}' WHERE connection_id IN (SELECT connection_id FROM guacamole_connection WHERE protocol = 'vnc') AND parameter_name = 'disable-display-resize';" + when: vdi_resolution_locked_changed + +- name: Debug resolution locked change completion + ansible.builtin.debug: + msg: | + Resolution locked change completed: + - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }} + - new_resolution_locked: {{ vdi_resolution_locked }} + - previous_resolution_locked: {{ vdi_setup_status.vdi_resolution_locked | default('unknown') }} + when: debug and vdi_resolution_locked_changed + +# ============================================================================= +# DATABASE PASSWORD RETRIEVAL (FOR CONTAINER RECREATION) +# ============================================================================= + +- name: Get access token for Secret Manager API (for database password) + 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 + when: webapp_port_changed or db_needs_init + +- name: Fetch database password from Secret Manager + ansible.builtin.uri: + url: "https://secretmanager.googleapis.com/v1/projects/{{ secret_project }}/secrets/db-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: database_password_sm_result + when: webapp_port_changed or db_needs_init + +- name: Set database password from Secret Manager + ansible.builtin.set_fact: + database_password: "{{ database_password_sm_result.json.payload.data | b64decode }}" + when: (webapp_port_changed or db_needs_init) and database_password_sm_result.status == 200 + +- name: Fetch 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 + when: is_fresh_deployment or infrastructure_changed or webapp_port_changed + +- name: Set webapp admin password from Secret Manager + ansible.builtin.set_fact: + webapp_admin_password: "{{ webapp_admin_sm_result.json.payload.data | b64decode }}" + when: (is_fresh_deployment or infrastructure_changed or webapp_port_changed) and webapp_admin_sm_result.status == 200 + +- name: Calculate webapp admin hash from password + ansible.builtin.set_fact: + webapp_admin_hash: "{{ webapp_admin_password | hash('sha256') | upper }}" + when: webapp_admin_password is defined + +- name: Debug password retrieval + ansible.builtin.debug: + msg: | + Password retrieval: + - is_fresh_deployment: {{ is_fresh_deployment }} + - infrastructure_changed: {{ infrastructure_changed }} + - webapp_port_changed: {{ webapp_port_changed }} + - database_secret_status: {{ database_password_sm_result.status if database_password_sm_result is defined else 'N/A' }} + - webapp_secret_status: {{ webapp_admin_sm_result.status if webapp_admin_sm_result is defined else 'N/A' }} + - database_password_retrieved: {{ database_password is defined }} + - webapp_admin_password_retrieved: {{ webapp_admin_password is defined }} + - database_password_length: {{ database_password | length if database_password is defined else 'N/A' }} + - webapp_admin_password_length: {{ webapp_admin_password | length if webapp_admin_password is defined else 'N/A' }} + when: debug and (is_fresh_deployment or infrastructure_changed or webapp_port_changed) + +# ============================================================================= +# WEBAPP PORT CHANGE HANDLING +# ============================================================================= + +- name: Handle webapp port changes + block: + - name: Debug webapp port change + ansible.builtin.debug: + msg: | + Webapp port change detected: + - current_port: {{ vdi_webapp_port }} + - previous_port: {{ vdi_setup_status.webapp_port | default('none') }} + - will_recreate_container: True + - database_password_available: {{ database_password is defined }} + when: debug + + - name: Stop Guacamole webapp container (for port change) + ansible.builtin.command: docker stop guac_app + ignore_errors: true + + - name: Remove Guacamole webapp container (for port change) + ansible.builtin.command: docker rm guac_app + ignore_errors: true + + - name: Start Guacamole webapp container with new port + ansible.builtin.command: > + docker run -d --name guac_app --network guac_net + -p {{ vdi_webapp_port }}:8080 + -e POSTGRES_HOSTNAME=guac_db + -e POSTGRES_PORT=5432 + -e POSTGRES_DATABASE=guacamole_db + -e POSTGRES_USER=guacamole_db + -e POSTGRES_PASSWORD={{ database_password }} + -e POSTGRES_AUTO_CREATE_ACCOUNTS=true + -e GUACD_HOSTNAME=guacd + -e GUACD_PORT=4822 + --restart always + guacamole/guacamole:latest + + - name: Wait for Guacamole webapp to be ready after port change + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 5 + timeout: 60 + + - name: Update database user password (for port change) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "ALTER USER guacamole_db WITH PASSWORD '{{ database_password }}';" + + - name: Update webapp admin password in database (for port change) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "UPDATE guacamole_user SET password_salt = NULL, password_hash = decode('{{ webapp_admin_password | hash('sha256') | upper }}', 'hex'), password_date = now() FROM guacamole_entity WHERE guacamole_user.entity_id = guacamole_entity.entity_id AND guacamole_entity.name = 'guacadmin' AND guacamole_entity.type = 'USER';" + + - name: Restart Guacamole webapp container (after password update) + ansible.builtin.command: docker restart guac_app + + - name: Wait for Guacamole webapp to be ready after restart + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 5 + timeout: 60 + + - name: Debug webapp port change completion + ansible.builtin.debug: + msg: | + Webapp port change completed: + - new_port: {{ vdi_webapp_port }} + - container_recreated: True + - webapp_admin_password_updated: True + when: debug + + when: webapp_port_changed and not is_fresh_deployment + +# ============================================================================= +# DATABASE RE-INITIALIZATION (ONLY WHEN NECESSARY) +# ============================================================================= + +- name: Re-initialize database if database needs initialization + block: + - name: Enable pgcrypto extension for initdb + ansible.builtin.copy: + dest: /opt/guacamole-db/initdb/00-enable-pgcrypto.sql + owner: root + group: root + mode: '0644' + content: | + -- Make digest(), hmac(), etc. available + CREATE EXTENSION IF NOT EXISTS pgcrypto; + + - name: Template per-user Guacamole user SQL + ansible.builtin.template: + src: guacamole/user_bootstrap.sql.j2 + dest: "/opt/guacamole-db/initdb/02-{{ item.username }}_user.sql" + owner: root + group: root + mode: '0644' + loop: "{{ vdi_users_updated }}" + loop_control: + loop_var: item + vars: + username: "{{ item.username }}" + vdi_password: "{{ item.password }}" + + - name: Template per-user SQL for Guacamole connections + ansible.builtin.template: + src: guacamole/connection_bootstrap.sql.j2 + dest: "/opt/guacamole-db/initdb/03-{{ item.username }}_connection.sql" + owner: root + group: root + mode: '0644' + loop: "{{ vdi_users_updated }}" + loop_control: + loop_var: item + vars: + port: "{{ item.port }}" + username: "{{ item.username }}" + vnc_password: "{{ item.vncserver_password }}" + + - name: Generate combined initdb.sql + ansible.builtin.shell: | + docker run --rm \ + -v /opt/guacamole-db/initdb:/opt/guacamole-db/initdb \ + guacamole/guacamole:latest \ + /opt/guacamole/bin/initdb.sh --postgresql \ + > /opt/guacamole-db/initdb/01-initdb.sql + + - name: Debug hash replacement conditions + ansible.builtin.debug: + msg: | + Hash replacement conditions: + - is_fresh_deployment: {{ is_fresh_deployment }} + - vdi_setup_status.webapp_admin_hash is defined: {{ vdi_setup_status.webapp_admin_hash is defined }} + - vdi_setup_status.webapp_admin_hash: '{{ vdi_setup_status.webapp_admin_hash | default("NOT_DEFINED") }}' + - vdi_setup_status.webapp_admin_hash != '': {{ (vdi_setup_status.webapp_admin_hash | default('')) != '' }} + - vdi_setup_status.webapp_admin_hash != webapp_admin_hash: {{ (vdi_setup_status.webapp_admin_hash | default('')) != webapp_admin_hash }} + - webapp_admin_hash: '{{ webapp_admin_hash }}' + - (vdi_setup_status.webapp_admin_hash is not defined) or (vdi_setup_status.webapp_admin_hash == ''): {{ (vdi_setup_status.webapp_admin_hash is not defined) or ((vdi_setup_status.webapp_admin_hash | default('')) == '') }} + when: debug + + - name: Update guacadmin password hash in 01-initdb.sql (initial deployment) + ansible.builtin.replace: + path: /opt/guacamole-db/initdb/01-initdb.sql + regexp: "decode\\('CA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960', 'hex'\\)" + replace: "decode('{{ webapp_admin_hash }}', 'hex')" + when: is_fresh_deployment + + - name: Update guacadmin password hash in 01-initdb.sql (force rerun) + ansible.builtin.replace: + path: /opt/guacamole-db/initdb/01-initdb.sql + regexp: "decode\\('{{ vdi_setup_status.webapp_admin_hash | default('CA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960') }}', 'hex'\\)" + replace: "decode('{{ webapp_admin_hash }}', 'hex')" + when: + - not is_fresh_deployment + - vdi_setup_status.webapp_admin_hash is defined + - vdi_setup_status.webapp_admin_hash != '' + + - name: Update guacadmin password hash in 01-initdb.sql (force rerun fallback) + ansible.builtin.replace: + path: /opt/guacamole-db/initdb/01-initdb.sql + regexp: "decode\\('CA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960', 'hex'\\)" + replace: "decode('{{ webapp_admin_hash }}', 'hex')" + when: + - not is_fresh_deployment + - (vdi_setup_status.webapp_admin_hash is not defined) or (vdi_setup_status.webapp_admin_hash == '') + + - name: Remove salt from guacadmin in 01-initdb.sql + ansible.builtin.replace: + path: /opt/guacamole-db/initdb/01-initdb.sql + regexp: "decode\\('FE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264', 'hex'\\)" + replace: "NULL" + + - name: Debug webapp admin password usage + ansible.builtin.debug: + msg: | + Webapp admin password usage (vdi_tool): + - password_provided_by_secret_manager: {{ webapp_admin_password is defined }} + - password_length: {{ webapp_admin_password | length if webapp_admin_password is defined else 'N/A' }} + - hash_computed: {{ webapp_admin_hash is defined }} + when: debug + + - name: Stop and remove existing containers + ansible.builtin.command: docker stop {{ item }} + loop: + - guac_app + - guacd + - guac_db + ignore_errors: true + + - name: Remove existing containers + ansible.builtin.command: docker rm {{ item }} + loop: + - guac_app + - guacd + - guac_db + ignore_errors: true + + - name: Backup existing database data + ansible.builtin.shell: > + tar -czf /opt/guacamole-db/backup-$(date +%Y%m%d-%H%M%S).tar.gz + -C /opt/guacamole-db data/ + ignore_errors: true + + - name: Remove existing database data + ansible.builtin.file: + path: /opt/guacamole-db/data + state: absent + ignore_errors: true + + - name: Recreate database data directory + ansible.builtin.file: + path: /opt/guacamole-db/data + state: directory + owner: root + group: root + mode: '0700' + + - name: Start Guacamole PostgreSQL container (re-initialized) + ansible.builtin.command: > + docker run -d --name guac_db --network guac_net + -e POSTGRES_USER=guacamole_db + -e POSTGRES_PASSWORD={{ database_password }} + -e POSTGRES_DB=guacamole_db + -v /opt/guacamole-db/data:/var/lib/postgresql/data + -v /opt/guacamole-db/initdb:/docker-entrypoint-initdb.d + --restart always + postgres:latest + register: postgres_restart + failed_when: + - postgres_restart.rc != 0 + - "'already in use' not in postgres_restart.stderr" + + - name: Wait for PostgreSQL to be ready after re-initialization + ansible.builtin.command: docker exec guac_db pg_isready -U guacamole_db + register: postgres_ready_after_restart + until: postgres_ready_after_restart.rc == 0 + retries: 30 + delay: 2 + + - name: Check if Guacamole tables exist + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'guacamole_%';" + register: table_check + changed_when: false + + - name: Debug table check + ansible.builtin.debug: + msg: | + Table check results: + - tables_found: {{ table_check.stdout_lines | length }} + - table_names: {{ table_check.stdout_lines }} + when: debug + + - name: Check if connections were created + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT connection_name FROM guacamole_connection;" + register: connection_check + changed_when: false + + - name: Debug connection check + ansible.builtin.debug: + msg: | + Connection check results: + - connections_found: {{ connection_check.stdout_lines | length }} + - connection_names: {{ connection_check.stdout_lines }} + when: debug + + - name: Wait for PostgreSQL via Docker exec + ansible.builtin.command: docker exec guac_db pg_isready -U guacamole_db + register: postgres_ready + until: postgres_ready.rc == 0 + retries: 30 + delay: 2 + + - name: Check if connections exist (non-reinitialisation case) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT connection_name FROM guacamole_connection;" + register: existing_connection_check + changed_when: false + + - name: Debug existing connection check + ansible.builtin.debug: + msg: | + Existing VNC Connections: + {{ existing_connection_check.stdout }} + when: debug + + - name: Start guacd container (force rerun or if not running) + ansible.builtin.command: > + docker run -d --name guacd --network guac_net + -p 4822:4822 + --restart always + guacamole/guacd:latest + register: guacd_start + failed_when: + - guacd_start.rc != 0 + - "'already in use' not in guacd_start.stderr" + + - name: Start Guacamole webapp container (force rerun or if not running) + ansible.builtin.command: > + docker run -d --name guac_app --network guac_net + -p {{ vdi_webapp_port }}:8080 + -e POSTGRES_HOSTNAME=guac_db + -e POSTGRES_PORT=5432 + -e POSTGRES_DATABASE=guacamole_db + -e POSTGRES_USER=guacamole_db + -e POSTGRES_PASSWORD={{ database_password }} + -e POSTGRES_AUTO_CREATE_ACCOUNTS=true + -e GUACD_HOSTNAME=guacd + -e GUACD_PORT=4822 + --restart always + guacamole/guacamole:latest + register: guac_app_start + failed_when: + - guac_app_start.rc != 0 + - "'already in use' not in guac_app_start.stderr" + + - name: Debug container status before waiting for Guacamole + ansible.builtin.command: docker ps + register: container_status_before_wait + changed_when: false + + - name: Show container status + ansible.builtin.debug: + msg: | + {{ container_status_before_wait.stdout }} + when: debug + + - name: Wait for Guacamole HTTP endpoint + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 5 + timeout: 60 + + # ============================================================================= + # UPDATE PASSWORDS AFTER DATABASE REINITIALIZATION + # ============================================================================= + + - name: Update database user password (after reinitialization) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "ALTER USER guacamole_db WITH PASSWORD '{{ database_password }}';" + + - name: Update webapp admin password in database (after reinitialization) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "UPDATE guacamole_user SET password_salt = NULL, password_hash = decode('{{ webapp_admin_password | hash('sha256') | upper }}', 'hex'), password_date = now() FROM guacamole_entity WHERE guacamole_user.entity_id = guacamole_entity.entity_id AND guacamole_entity.name = 'guacadmin' AND guacamole_entity.type = 'USER';" + + - name: Restart Guacamole webapp container (after password update) + ansible.builtin.command: docker restart guac_app + + - name: Wait for Guacamole webapp to be ready after password update restart + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 5 + timeout: 60 + + - name: Debug password update completion after reinitialization + ansible.builtin.debug: + msg: | + Password update completed after database reinitialization: + - database_password_updated: True + - webapp_admin_password_updated: True + - container_restarted: True + when: debug + + - name: Obtain Guacamole API token to verify login + ansible.builtin.uri: + url: "http://localhost:{{ vdi_webapp_port }}/guacamole/api/tokens" + method: POST + body_format: form-urlencoded + body: + username: "guacadmin" + password: "{{ webapp_admin_password }}" + return_content: yes + register: guac_api_token + until: guac_api_token.status == 200 and guac_api_token.json.authToken is defined + retries: 10 + delay: 3 + + - name: Debug Guacamole API token verification + ansible.builtin.debug: + msg: | + Guacamole API token verification: + - status: {{ guac_api_token.status }} + - success: {{ guac_api_token.status == 200 }} + - token_obtained: {{ guac_api_token.json.authToken is defined }} + when: debug + + when: db_needs_init + +# ============================================================================= +# CONTAINER MANAGEMENT (IDEMPOTENT) +# ============================================================================= + +- name: Wait for PostgreSQL via Docker exec + ansible.builtin.command: docker exec guac_db pg_isready -U guacamole_db + register: postgres_ready + until: postgres_ready.rc == 0 + retries: 30 + delay: 2 + when: "'guac_db' in running_containers.stdout" + +- name: Check if connections exist (non-reinitialisation case) + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT connection_name, protocol FROM guacamole_connection WHERE connection_name NOT LIKE '%SSH';" + register: existing_connection_check + changed_when: false + when: not db_needs_init + +- name: Debug existing connection check + ansible.builtin.debug: + msg: | + Existing VNC Connections: + {{ existing_connection_check.stdout }} + when: debug and not db_needs_init + +- name: Start guacd container (only if not running) + ansible.builtin.command: > + docker run -d --name guacd --network guac_net + -p 4822:4822 + --restart always + guacamole/guacd:latest + register: guacd_start + failed_when: + - guacd_start.rc != 0 + - "'already in use' not in guacd_start.stderr" + when: db_needs_init or (not db_needs_init and "'guacd' not in running_containers.stdout") + +- name: Start Guacamole webapp container (only if not running) + ansible.builtin.command: > + docker run -d --name guac_app --network guac_net + -p {{ vdi_webapp_port }}:8080 + -e POSTGRES_HOSTNAME=guac_db + -e POSTGRES_PORT=5432 + -e POSTGRES_DATABASE=guacamole_db + -e POSTGRES_USER=guacamole_db + -e POSTGRES_PASSWORD={{ database_password }} + -e POSTGRES_AUTO_CREATE_ACCOUNTS=true + -e GUACD_HOSTNAME=guacd + -e GUACD_PORT=4822 + --restart always + guacamole/guacamole:latest + register: guac_app_start + failed_when: + - guac_app_start.rc != 0 + - "'already in use' not in guac_app_start.stderr" + when: db_needs_init or (not db_needs_init and "'guac_app' not in running_containers.stdout") + +# ============================================================================= +# VERIFICATION AND CLEANUP +# ============================================================================= + +- name: Debug container status before waiting for Guacamole + ansible.builtin.command: docker ps + register: container_status + changed_when: false + +- name: Show container status + ansible.builtin.debug: + msg: "{{ container_status.stdout }}" + when: debug + +- name: Wait for Guacamole HTTP endpoint + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ vdi_webapp_port }}" + delay: 2 + timeout: 60 + +- name: Check Guacamole UI is up + ansible.builtin.uri: + url: "http://localhost:{{ vdi_webapp_port }}/guacamole/" + status_code: 200 + return_content: no + register: guac_health + until: guac_health.status == 200 + retries: 10 + delay: 3 + +- name: Verify connections were created in database + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT connection_name, protocol FROM guacamole_connection WHERE connection_name IN ({% for user in vdi_users_updated %}'{{ user.username }}'{% if not loop.last %},{% endif %}{% endfor %},{% for user in vdi_users_updated %}'{{ user.username }} SSH'{% if not loop.last %},{% endif %}{% endfor %});" + register: connection_verification + changed_when: false + +- name: Debug connection verification + ansible.builtin.debug: + msg: | + Connection verification results: + {{ connection_verification.stdout }} + when: debug + +- name: Verify connection permissions were created + ansible.builtin.command: > + docker exec guac_db psql -U guacamole_db -d guacamole_db -c + "SELECT e.name as username, c.connection_name, cp.permission + FROM guacamole_entity e + JOIN guacamole_connection_permission cp ON e.entity_id = cp.entity_id + JOIN guacamole_connection c ON cp.connection_id = c.connection_id + WHERE e.type = 'USER' AND e.name != 'guacadmin' + ORDER BY e.name, c.connection_name, cp.permission;" + register: permission_verification + changed_when: false + +- name: Debug permission verification + ansible.builtin.debug: + msg: | + Permission verification results: + {{ permission_verification.stdout }} + when: debug + +- name: Debug fresh deployment reset logic + ansible.builtin.debug: + msg: | + Fresh deployment detected - password reset logic skipped: + - is_fresh_deployment: {{ is_fresh_deployment }} + - users_needing_reset: [] (empty list) + - reset_password flags will be ignored + - reset_webapp_admin_password: {{ reset_webapp_admin_password }} (will be ignored on fresh deployment) + when: debug and is_fresh_deployment + +- name: Debug API verification logic + ansible.builtin.debug: + msg: | + API verification logic: + - is_fresh_deployment: {{ is_fresh_deployment }} + - infrastructure_changed: {{ infrastructure_changed }} + - webapp_port_changed: {{ webapp_port_changed }} + - will_verify_api: {{ is_fresh_deployment or infrastructure_changed or webapp_port_changed }} + - reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Infrastructure change' if infrastructure_changed else ('Webapp port change' if webapp_port_changed else 'User-only changes - API verification skipped')) }} + when: debug + +- name: Obtain Guacamole API token to verify login + ansible.builtin.uri: + url: "http://localhost:{{ vdi_webapp_port }}/guacamole/api/tokens" + method: POST + body_format: form-urlencoded + body: + username: "guacadmin" + password: "{{ webapp_admin_password }}" + return_content: yes + register: guac_api + until: guac_api.status == 200 and guac_api.json.authToken is defined + retries: 10 + delay: 3 + when: + - webapp_admin_password is defined + - (is_fresh_deployment or infrastructure_changed or webapp_port_changed) + +- name: Remove bootstrap SQL files unless debug is enabled + ansible.builtin.file: + path: /opt/guacamole-db/initdb + state: absent + when: not debug + +- name: VDI Tool deployment summary + ansible.builtin.debug: + msg: | + ================================================================================ + VDI TOOL DEPLOYMENT SUMMARY + ================================================================================ + + DEPLOYMENT MODE: + - is_fresh_deployment: {{ is_fresh_deployment }} + - db_needs_init: {{ db_needs_init }} + - users_changed: {{ users_changed }} + - force_rerun: {{ force_rerun }} + - reset_webapp_admin_password: {{ reset_webapp_admin_password }} + + TARGETED UPDATES APPLIED: + - New users added: {{ new_users | map(attribute='username') | list if new_users is defined and new_users | length > 0 else [] }} + - Users removed: {{ removed_users if removed_users is defined and removed_users | length > 0 else [] }} + - Password resets: {{ users_needing_reset | map(attribute='username') | list if users_needing_reset is defined and users_needing_reset | length > 0 else [] }} + - Webapp admin password reset: {{ reset_webapp_admin_password and not is_fresh_deployment }} + + CONTAINER STATUS: + - Database container: {{ 'Running' if 'guac_db' in running_containers.stdout else 'Not running' }} + - Guacd container: {{ 'Running' if 'guacd' in running_containers.stdout else 'Not running' }} + - Webapp container: {{ 'Running' if 'guac_app' in running_containers.stdout else 'Not running' }} + + ================================================================================ + when: debug diff --git a/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/main.yaml new file mode 100644 index 0000000000..a1b9349c6e --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/main.yaml @@ -0,0 +1,101 @@ +# 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: "vdi_tool" + +- name: Check if vdi_tool 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({}) }}" + 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) }}" + new_users: "{{ new_users | default([]) }}" + removed_users: "{{ removed_users | default([]) }}" + users_needing_reset: "{{ users_needing_reset | default([]) }}" + secret_project: "{{ project_id }}" + reset_webapp_admin_password: "{{ reset_webapp_admin_password | default(false) }}" + force_rerun: "{{ force_rerun | default(false) }}" + webapp_admin_hash: "{{ webapp_admin_hash | default('') }}" + +- name: Debug role variables + ansible.builtin.debug: + msg: | + VDI Tool role variables: + - role_should_run: {{ role_should_run }} + - is_fresh_deployment: {{ is_fresh_deployment }} + - deployment_hash: {{ deployment_hash }} + - user_hash: {{ user_hash }} + - vdi_resolution_changed: {{ vdi_resolution_changed }} + - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }} + - infrastructure_changed: {{ infrastructure_changed }} + - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }} + when: debug + +- name: Skip VDI Tool role if not needed + ansible.builtin.debug: + msg: "Skipping VDI Tool role - already completed or not needed" + when: not role_should_run + +# Skip all tasks if role should not run +- name: Skip vdi_tool tasks if role should not run + ansible.builtin.debug: + msg: "Skipping vdi_tool role - already completed or not needed" + when: not role_should_run + +- name: "Install Guacamole" + include_tasks: guacamole.yaml + when: + - vdi_tool | lower == 'guacamole' + - role_should_run + +- name: "Install NoMachine" + include_tasks: nomachine.yaml + when: + - vdi_tool | lower == 'nomachine' + - role_should_run + +- name: "Install Workspot" + include_tasks: workspot.yaml + when: + - vdi_tool | lower == 'workspot' + - role_should_run + +# Mark role as completed +- name: Set current role for lock manager completion + ansible.builtin.set_fact: + current_role: "vdi_tool" + role_completed: true + when: role_should_run + +- name: Mark vdi_tool 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_tool/tasks/nomachine.yaml b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/nomachine.yaml new file mode 100644 index 0000000000..3039c31d62 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/nomachine.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_tool/tasks/workspot.yaml b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/workspot.yaml new file mode 100644 index 0000000000..3039c31d62 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/tasks/workspot.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_tool/templates/guacamole/connection_bootstrap.sql.j2 b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/connection_bootstrap.sql.j2 new file mode 100644 index 0000000000..0835ce73f6 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/connection_bootstrap.sql.j2 @@ -0,0 +1,71 @@ +{# +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. +#} + +-- VNC Connection for {{ username }} +INSERT INTO guacamole_connection (connection_name, protocol) +VALUES ('{{ username }}', 'vnc'); + +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT + connection_id, + param_name, + param_value +FROM guacamole_connection, (VALUES + ('hostname', '{{ ansible_default_ipv4.address }}'), + ('port', '{{ port }}'), + ('username', '{{ username }}'), + ('password', '{{ vnc_password }}'), + ('disable-display-resize', '{{ "true" if vdi_resolution_locked else "false" }}') +) AS params(param_name, param_value) +WHERE connection_name = '{{ username }}'; + +INSERT INTO guacamole_connection_permission (entity_id, connection_id, permission) +SELECT + (SELECT entity_id FROM guacamole_entity WHERE name = '{{ username }}' AND type = 'USER'), + (SELECT connection_id FROM guacamole_connection WHERE connection_name = '{{ username }}'), + perms.permission::guacamole_object_permission_type +FROM (VALUES ('READ'),('UPDATE'),('DELETE'),('ADMINISTER')) AS perms(permission); + +-- SSH Connection for {{ username }} +INSERT INTO guacamole_connection (connection_name, protocol) +VALUES ('{{ username }} SSH', 'ssh'); + +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT + connection_id, + param_name, + param_value +FROM guacamole_connection, (VALUES + ('hostname', '{{ ansible_default_ipv4.address }}'), + ('port', '22'), + ('username', '{{ username }}'), + ( + 'private-key', + E'{{ lookup("file", "/home/" ~ username ~ "/.ssh/id_rsa") + | replace("\\", "\\\\") + | replace("'", "''") + | replace("\r", "") + | replace("\n", "\\n") }}' + ) +) AS params(param_name, param_value) +WHERE connection_name = '{{ username }} SSH'; + +INSERT INTO guacamole_connection_permission (entity_id, connection_id, permission) +SELECT + (SELECT entity_id FROM guacamole_entity WHERE name = '{{ username }}' AND type = 'USER'), + (SELECT connection_id FROM guacamole_connection WHERE connection_name = '{{ username }} SSH'), + perms.permission::guacamole_object_permission_type +FROM (VALUES ('READ'),('UPDATE'),('DELETE'),('ADMINISTER')) AS perms(permission); diff --git a/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/connection_port_update.sql.j2 b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/connection_port_update.sql.j2 new file mode 100644 index 0000000000..82dc83d7e8 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/connection_port_update.sql.j2 @@ -0,0 +1,21 @@ +{# +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. +#} + +-- Update VNC Connection Port for {{ username }} +UPDATE guacamole_connection_parameter +SET parameter_value = '{{ port }}' +WHERE connection_id = (SELECT connection_id FROM guacamole_connection WHERE connection_name = '{{ username }}') +AND parameter_name = 'port'; diff --git a/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/user_bootstrap.sql.j2 b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/user_bootstrap.sql.j2 new file mode 100644 index 0000000000..aeda82561f --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vdi_tool/templates/guacamole/user_bootstrap.sql.j2 @@ -0,0 +1,37 @@ +{# +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. +#} + +-- Create Guacamole user for {{ username }} +INSERT INTO guacamole_entity (name, type) +VALUES ('{{ username }}', 'USER'); + +INSERT INTO guacamole_user ( + entity_id, + password_salt, + password_hash, + password_date, + disabled, + expired +) +SELECT + entity_id, + NULL, + digest('{{ vdi_password }}', 'SHA-256'), + now(), + false, + false +FROM guacamole_entity +WHERE name = '{{ username }}' AND type = 'USER'; diff --git a/community/modules/scripts/vdi-setup/roles/vnc/tasks/main.yaml b/community/modules/scripts/vdi-setup/roles/vnc/tasks/main.yaml new file mode 100644 index 0000000000..3cea28cd3d --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/tasks/main.yaml @@ -0,0 +1,256 @@ +# 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: "vnc" + +- name: Check if vnc 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({}) }}" + users_with_port_changes: "{{ users_with_port_changes | default([]) }}" + removed_users: "{{ removed_users | default([]) }}" + vdi_resolution_changed: "{{ vdi_resolution_changed | default(false) }}" + vdi_resolution_locked_changed: "{{ vdi_resolution_locked_changed | default(false) }}" + +- name: Debug role variables + ansible.builtin.debug: + msg: | + VNC role variables: + - role_should_run: {{ role_should_run }} + - is_fresh_deployment: {{ is_fresh_deployment }} + - deployment_hash: {{ deployment_hash }} + - user_hash: {{ user_hash }} + - vdi_resolution_changed: {{ vdi_resolution_changed }} + - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }} + - execution_reason: {{ 'Fresh deployment' if is_fresh_deployment else ('Hash mismatch' if not role_should_run else 'Normal role execution') }} + when: debug + +- name: Debug resolution change detection + ansible.builtin.debug: + msg: | + Resolution change detection: + - vdi_resolution_changed: {{ vdi_resolution_changed }} + - current_resolution: {{ vdi_resolution }} + - previous_resolution: {{ vdi_setup_status.vdi_resolution | default('unknown') }} + - will_restart_services: {{ vdi_resolution_changed }} + when: debug and vdi_resolution_changed + +- name: Debug resolution locked change detection + ansible.builtin.debug: + msg: | + Resolution locked change detection: + - vdi_resolution_locked_changed: {{ vdi_resolution_locked_changed }} + - current_resolution_locked: {{ vdi_resolution_locked }} + - previous_resolution_locked: {{ vdi_setup_status.vdi_resolution_locked | default('unknown') }} + - will_update_connections: {{ vdi_resolution_locked_changed }} + when: debug and vdi_resolution_locked_changed + +- name: Skip VNC role if not needed + ansible.builtin.debug: + msg: "Skipping VNC role - already completed or not needed" + when: not role_should_run + +# Run VNC tasks only if needed +- name: Run VNC tasks + block: + + - name: "Install TigerVNC" + include_tasks: tigervnc.yaml + when: vnc_flavor | lower == 'tigervnc' + + - name: "Install TightVNC" + include_tasks: tightvnc.yaml + when: vnc_flavor | lower == 'tightvnc' + + # ============================================================================= + # USER REMOVAL HANDLING + # ============================================================================= + + - name: Handle removed users (stop VNC services and lock accounts) + block: + + - name: Stop VNC services for removed users + ansible.builtin.systemd: + name: "vncserver@{{ item }}" + state: stopped + enabled: no + loop: "{{ removed_users }}" + when: removed_users is defined and removed_users | length > 0 + ignore_errors: true + + - name: Kill X server processes for removed users + ansible.builtin.shell: | + pkill -f "Xvnc.*:{{ item }}" || true + sleep 2 + loop: "{{ removed_users }}" + when: removed_users is defined and removed_users | length > 0 + ignore_errors: true + + - 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: 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 }} + - VNC services stopped and disabled + - Local accounts locked with '!' password + {% else %} + - No users to remove + {% endif %} + when: debug + + when: removed_users is defined and removed_users | length > 0 + + # ============================================================================= + # PORT CHANGE HANDLING + # ============================================================================= + + - name: Handle port changes for existing users + block: + - name: Stop VNC services for users with port changes + ansible.builtin.systemd: + name: "vncserver@:{{ item.port - 5900 }}" + state: stopped + loop: "{{ users_with_port_changes }}" + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + ignore_errors: true + + - name: Kill X server processes for users with port changes (old display) + ansible.builtin.shell: | + pkill -f "Xvnc.*:{{ (vdi_setup_status.user_ports[item.username] - 5900) }}" || true + sleep 2 + loop: "{{ users_with_port_changes }}" + when: + - users_with_port_changes is defined and users_with_port_changes | length > 0 + - vdi_setup_status.user_ports[item.username] is defined + ignore_errors: true + + - name: Update VNC users mapping for port changes + ansible.builtin.template: + src: vncserver.users.j2 + dest: /etc/tigervnc/vncserver.users + mode: '0644' + vars: + guac_map: "{{ vdi_users }}" + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + + - name: Start VNC services for users with port changes + ansible.builtin.systemd: + name: "vncserver@:{{ item.port - 5900 }}" + state: started + enabled: yes + loop: "{{ users_with_port_changes }}" + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + + - name: Debug port change actions + ansible.builtin.debug: + msg: | + Port change actions completed: + {% if users_with_port_changes is defined and users_with_port_changes | length > 0 %} + - Users with port changes: {{ users_with_port_changes | map(attribute='username') | list }} + - VNC services stopped, reconfigured, and restarted + {% else %} + - No port changes detected + {% endif %} + when: debug + + when: users_with_port_changes is defined and users_with_port_changes | length > 0 + + # ============================================================================= + # RESOLUTION CHANGE HANDLING + # ============================================================================= + + - name: Handle resolution changes for all users + block: + - name: Stop all VNC services for resolution change + ansible.builtin.systemd: + name: "vncserver@:{{ item.port - 5900 }}" + state: stopped + loop: "{{ vdi_users }}" + when: vdi_resolution_changed + ignore_errors: true + + - name: Kill all X server processes for resolution change + ansible.builtin.shell: | + pkill -f "Xvnc.*:{{ item.port - 5900 }}" || true + sleep 2 + loop: "{{ vdi_users }}" + when: vdi_resolution_changed + ignore_errors: true + + - name: Update VNC users mapping for resolution change + ansible.builtin.template: + src: vncserver.users.j2 + dest: /etc/tigervnc/vncserver.users + mode: '0644' + vars: + guac_map: "{{ vdi_users }}" + when: vdi_resolution_changed + + - name: Start all VNC services for resolution change + ansible.builtin.systemd: + name: "vncserver@:{{ item.port - 5900 }}" + state: started + enabled: yes + loop: "{{ vdi_users }}" + when: vdi_resolution_changed + + - name: Debug resolution change actions + ansible.builtin.debug: + msg: | + Resolution change actions completed: + {% if vdi_resolution_changed %} + - Resolution changed from {{ vdi_setup_status.vdi_resolution | default('unknown') }} to {{ vdi_resolution }} + - All VNC services stopped, reconfigured, and restarted + {% else %} + - No resolution changes detected + {% endif %} + when: debug + + when: vdi_resolution_changed + + when: role_should_run + +# Mark role as completed +- name: Set current role for lock manager completion + ansible.builtin.set_fact: + current_role: "vnc" + role_completed: true + when: role_should_run + +- name: Mark vnc 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/vnc/tasks/tigervnc.yaml b/community/modules/scripts/vdi-setup/roles/vnc/tasks/tigervnc.yaml new file mode 100644 index 0000000000..2db0fe4f0a --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/tasks/tigervnc.yaml @@ -0,0 +1,84 @@ +# 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. +--- + +# Per-user configuration +- name: Configure per-user VNC settings + include_tasks: vnc_user_config.yaml + loop: "{{ vdi_users_updated }}" + loop_control: + loop_var: vdi_user + +# Global VNC users mapping (for systemd service) +- name: Template TigerVNC users file + ansible.builtin.template: + src: vncserver.users.j2 + dest: /etc/tigervnc/vncserver.users + mode: '0644' + vars: + guac_map: "{{ vdi_users_updated }}" + +# Systemd units +- name: Deploy per-user TigerVNC systemd unit (Ubuntu/Debian) + ansible.builtin.template: + src: vncserver@.service.ubuntu.j2 + dest: "/etc/systemd/system/vncserver@{{ item.username }}.service" + mode: '0644' + vars: + display_number: "{{ item.display_number }}" + when: ansible_distribution in ["Ubuntu", "Debian"] + loop: "{{ vdi_users_updated }}" + +- name: Deploy TigerVNC systemd unit (Rocky) + ansible.builtin.template: + src: vncserver@.service.rocky.j2 + dest: /etc/systemd/system/vncserver@.service + mode: '0644' + when: ansible_os_family == "RedHat" + +- name: Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: yes + +# Enable & start each user's VNC server service +- name: Enable & start TigerVNC (Ubuntu/Debian) + systemd: + name: "vncserver@{{ item.username }}" + enabled: yes + state: started + when: ansible_distribution in ["Ubuntu", "Debian"] + loop: "{{ vdi_users_updated }}" + +- name: Enable & start TigerVNC (Rocky) + systemd: + name: "vncserver@:{{ item.display_number }}" + enabled: yes + state: started + when: ansible_os_family == "RedHat" + loop: "{{ vdi_users_updated }}" + +# Restart all VNC services to ensure they pick up any password changes +- name: Restart all VNC services (Rocky) + systemd: + name: "vncserver@:{{ item.display_number }}" + state: restarted + when: ansible_os_family == "RedHat" + loop: "{{ vdi_users_updated }}" + +- name: Restart all VNC services (Ubuntu/Debian) + systemd: + name: "vncserver@{{ item.username }}" + state: restarted + when: ansible_distribution in ["Ubuntu", "Debian"] + loop: "{{ vdi_users_updated }}" diff --git a/community/modules/scripts/vdi-setup/roles/vnc/tasks/tightvnc.yaml b/community/modules/scripts/vdi-setup/roles/vnc/tasks/tightvnc.yaml new file mode 100644 index 0000000000..3039c31d62 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/tasks/tightvnc.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/vnc/tasks/vnc_user_config.yaml b/community/modules/scripts/vdi-setup/roles/vnc/tasks/vnc_user_config.yaml new file mode 100644 index 0000000000..85107079ab --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/tasks/vnc_user_config.yaml @@ -0,0 +1,58 @@ +# 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: Ensure VNC configuration directory exists + ansible.builtin.file: + path: "/home/{{ vdi_user.username }}/.vnc" + state: directory + owner: "{{ vdi_user.username }}" + group: "{{ vdi_user_group }}" + mode: '0700' + +- name: Write VNC xstartup for {{ vdi_user.username }} (Ubuntu/Debian) + ansible.builtin.copy: + dest: "/home/{{ vdi_user.username }}/.vnc/xstartup" + content: | + #!/bin/sh + unset SESSION_MANAGER + unset DBUS_SESSION_BUS_ADDRESS + exec startxfce4 + owner: "{{ vdi_user.username }}" + group: "{{ vdi_user_group }}" + mode: '0755' + when: ansible_distribution in ["Ubuntu", "Debian"] + +- name: Write VNC password file for {{ vdi_user.username }} + ansible.builtin.shell: | + echo "{{ vdi_user.vncserver_password }}" | vncpasswd -f > "/home/{{ vdi_user.username }}/.vnc/passwd" + become_user: "{{ vdi_user.username }}" + register: vnc_password_result + +- name: Fix passwd file permissions + ansible.builtin.file: + path: "/home/{{ vdi_user.username }}/.vnc/passwd" + owner: "{{ vdi_user.username }}" + group: "{{ vdi_user_group }}" + mode: '0600' + +- name: Copy per-user VNC config + ansible.builtin.template: + src: config.j2 + dest: "/home/{{ vdi_user.username }}/.vnc/config" + owner: "{{ vdi_user.username }}" + group: "{{ vdi_user_group }}" + mode: '0644' + vars: + vdi_resolution: "{{ vdi_resolution }}" diff --git a/community/modules/scripts/vdi-setup/roles/vnc/templates/config.j2 b/community/modules/scripts/vdi-setup/roles/vnc/templates/config.j2 new file mode 100644 index 0000000000..9c379307fb --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/templates/config.j2 @@ -0,0 +1,18 @@ +{# +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. +#} + +session=xfce +geometry={{ vdi_resolution }} diff --git a/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver.users.j2 b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver.users.j2 new file mode 100644 index 0000000000..fa42c3d857 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver.users.j2 @@ -0,0 +1,19 @@ +{# +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. +#} + +{% for user in guac_map %} +:{{ user.port - 5900 }}={{ user.username }} +{% endfor %} diff --git a/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.rocky.j2 b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.rocky.j2 new file mode 100644 index 0000000000..be88533884 --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.rocky.j2 @@ -0,0 +1,31 @@ +{# +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. +#} + +[Unit] +Description=TigerVNC server for user %i +After=syslog.target network.target + +[Service] +Type=forking +ExecStart=/usr/libexec/vncsession-start %i +ExecStop=-/bin/true +PIDFile=/run/vncsession-%i.pid + +Restart=always +RestartSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.ubuntu.j2 b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.ubuntu.j2 new file mode 100644 index 0000000000..6db778debd --- /dev/null +++ b/community/modules/scripts/vdi-setup/roles/vnc/templates/vncserver@.service.ubuntu.j2 @@ -0,0 +1,32 @@ +{# +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. +#} + +[Unit] +Description=TigerVNC server for user %i +After=network.target + +[Service] +Type=forking +User=%i +Environment=HOME=/home/%i +ExecStart=/usr/bin/vncserver -localhost no :{{ display_number }} +ExecStop=/usr/bin/vncserver -kill :{{ display_number }} + +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/community/modules/scripts/vdi-setup/templates/install.yaml.tftpl b/community/modules/scripts/vdi-setup/templates/install.yaml.tftpl new file mode 100644 index 0000000000..91ad46ddf6 --- /dev/null +++ b/community/modules/scripts/vdi-setup/templates/install.yaml.tftpl @@ -0,0 +1,13 @@ +# Generated by Cluster Toolkit + +- hosts: localhost + become: true + vars_files: + - "/opt/vdi-setup/vars.yaml" + tasks: + +%{ for r in roles } + - name: Execute ${r} role + ansible.builtin.import_role: + name: "${r}" +%{ endfor } diff --git a/community/modules/scripts/vdi-setup/templates/vars.yaml.tftpl b/community/modules/scripts/vdi-setup/templates/vars.yaml.tftpl new file mode 100644 index 0000000000..b123719c3f --- /dev/null +++ b/community/modules/scripts/vdi-setup/templates/vars.yaml.tftpl @@ -0,0 +1,31 @@ +# Generated by Cluster Toolkit + +deployment_name: ${deployment_name} +project_id: ${project_id} +user_provision: ${user_provision} +vnc_flavor: ${vnc_flavor} +vdi_tool: ${vdi_tool} +vdi_user_group: ${vdi_user_group} +vdi_webapp_port: ${vdi_webapp_port} +vdi_resolution: ${vdi_resolution} +vdi_resolution_locked: ${vdi_resolution_locked} +vdi_bucket_name: ${vdi_bucket_name} +zone: ${zone} +debug: ${debug} +reset_webapp_admin_password: ${reset_webapp_admin_password} +force_rerun: ${force_rerun} + +vdi_users: +%{ for user in vdi_users } + - username: ${user.username} + port: ${user.port} +%{ if user.secret_name != null } + secret_name: "${user.secret_name}" +%{ endif } +%{ if user.secret_project != null } + secret_project: "${user.secret_project}" +%{ endif } +%{ if user.reset_password != null } + reset_password: ${user.reset_password} +%{ endif } +%{ endfor } diff --git a/community/modules/scripts/vdi-setup/validation.tf b/community/modules/scripts/vdi-setup/validation.tf new file mode 100644 index 0000000000..c5d2b26221 --- /dev/null +++ b/community/modules/scripts/vdi-setup/validation.tf @@ -0,0 +1,72 @@ +# 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. + +resource "terraform_data" "input_validation" { + lifecycle { + precondition { + condition = contains(["tigervnc"], var.vnc_flavor) + error_message = "vnc_flavor must be tigervnc." + } + + precondition { + condition = contains(["guacamole"], var.vdi_tool) + error_message = "vdi_tool must be one of: guacamole." + } + + precondition { + condition = contains(["local_users"], var.user_provision) + error_message = "user_provision must be local_users." + } + + precondition { + condition = can(regex("^[1-9][0-9]*x[1-9][0-9]*$", var.vdi_resolution)) + error_message = "vdi_resolution must be in the form WIDTHxHEIGHT (e.g. 1920x1080)." + } + + precondition { + condition = var.vdi_resolution_locked == true || var.vdi_resolution_locked == false + error_message = "vdi_resolution_locked must be a boolean value (true/false)." + } + + precondition { + condition = var.user_provision == "local_users" || length(var.vdi_users) == 0 + error_message = "vdi_users may only be set when user_provision = local_users." + } + + precondition { + condition = ( + var.vnc_flavor != "tigervnc" && var.vnc_flavor != "tightvnc" + ) || alltrue([ + for user in var.vdi_users : ( + user.port >= var.vnc_port_min && user.port <= var.vnc_port_max + ) + ]) + error_message = "Each VDI user must have a port between 5901 and 5999 when VNC is used." + } + + precondition { + condition = alltrue([ + for user in var.vdi_users : ( + user.reset_password == null || user.reset_password == true || user.reset_password == false + ) + ]) + error_message = "reset_password must be a boolean value (true/false) or null." + } + + precondition { + condition = length(distinct([for user in var.vdi_users : user.port])) == length(var.vdi_users) + error_message = "All VDI users must have unique ports to prevent conflicts." + } + } +} diff --git a/community/modules/scripts/vdi-setup/variables.tf b/community/modules/scripts/vdi-setup/variables.tf new file mode 100644 index 0000000000..96549d3159 --- /dev/null +++ b/community/modules/scripts/vdi-setup/variables.tf @@ -0,0 +1,121 @@ +# 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. + +variable "project_id" { + description = "Project in which the HPC deployment will be created." + type = string +} + +variable "deployment_name" { + description = "The name of the deployment." + type = string +} + +variable "region" { + description = "Region to place bucket containing startup script." + type = string +} + +variable "zone" { + description = "Zone in which the VDI instances are created." + type = string +} + +variable "labels" { + description = "Key-value pairs of labels to be added to created resources." + type = map(string) +} + +variable "vnc_flavor" { + description = "The VNC server flavor to use (tigervnc currently supported)" + type = string + default = "tigervnc" +} + +variable "vdi_tool" { + type = string + description = "VDI tool to deploy (guacamole currently supported)." + default = "guacamole" +} + +variable "user_provision" { + type = string + description = "User type to create (local_users supported. os-login to do." + default = "local_users" +} + +variable "vdi_user_group" { + type = string + description = "Unix group to create/use for VDI users." + default = "vdiusers" +} + +variable "vdi_resolution" { + type = string + description = "Desktop resolution for VNC sessions (e.g. 1920x1080)." + default = "1920x1080" +} + +variable "vdi_resolution_locked" { + type = bool + description = "Disable resize of remote display in Guacamole connections. When true, VDI displays at native resolution without browser scaling." + default = true +} + +variable "vdi_webapp_port" { + type = string + description = "Port to serve the Webapp interface from if applicable (note: containers will be recreated if changed)" + default = "8080" +} + +variable "vdi_users" { + description = "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." + type = list(object({ + username = string + port = number + secret_name = optional(string) + secret_project = optional(string) + reset_password = optional(bool) + })) + default = [] +} + +variable "vnc_port_min" { + type = number + default = 5901 + description = "Minimum valid VNC port." +} +variable "vnc_port_max" { + type = number + default = 5999 + description = "Maximum valid VNC port." +} + +variable "debug" { + type = bool + default = false + description = "Enable debug mode for verbose logging during VDI setup." +} + +variable "reset_webapp_admin_password" { + type = bool + default = false + description = "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." +} + +variable "force_rerun" { + type = bool + default = false + description = "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." +} diff --git a/community/modules/scripts/vdi-setup/versions.tf b/community/modules/scripts/vdi-setup/versions.tf new file mode 100644 index 0000000000..64da35c590 --- /dev/null +++ b/community/modules/scripts/vdi-setup/versions.tf @@ -0,0 +1,33 @@ +/** + * 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. + */ + +terraform { + required_version = ">= 1.0" + required_providers { + archive = { + source = "hashicorp/archive" + version = "~> 2.0" + } + google = { + source = "hashicorp/google" + version = ">= 3.83" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} diff --git a/modules/scripts/startup-script/files/install_docker.yml b/modules/scripts/startup-script/files/install_docker.yml index f9b0abeb14..da096caf7d 100644 --- a/modules/scripts/startup-script/files/install_docker.yml +++ b/modules/scripts/startup-script/files/install_docker.yml @@ -20,24 +20,75 @@ docker_data_root: '' docker_daemon_config: '' enable_docker_world_writable: false + is_rocky_linux: "{{ ansible_distribution in ['Rocky', 'Rocky Linux'] }}" tasks: + - name: Gather distribution facts + ansible.builtin.setup: + gather_subset: platform - name: Check if docker is installed ansible.builtin.stat: path: /usr/bin/docker register: docker_binary - - name: Download Docker Installer + - name: Add Docker GPG key (Rocky) + ansible.builtin.rpm_key: + state: present + key: https://download.docker.com/linux/rhel/gpg + when: + - is_rocky_linux + - not docker_binary.stat.exists + register: add_docker_key + retries: 30 + delay: 10 + until: add_docker_key is succeeded + - name: Add Docker repository for Rocky Linux + ansible.builtin.command: > + dnf config-manager --add-repo + https://download.docker.com/linux/rhel/docker-ce.repo + when: + - is_rocky_linux + - not docker_binary.stat.exists + notify: Refresh dnf cache + - name: Install Docker Engine and related packages on Rocky + ansible.builtin.package: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: yes + when: + - is_rocky_linux + - not docker_binary.stat.exists + - name: Ensure Docker SDK for Python is installed + block: + - name: Install python3-pip + ansible.builtin.package: + name: python3-pip + state: present + - name: Install Docker SDK for Python + ansible.builtin.pip: + name: docker + executable: /usr/local/ghpc-venv/bin/pip3 + when: is_rocky_linux + - name: Download Docker Installer (get.docker.com) ansible.builtin.get_url: url: https://get.docker.com dest: /tmp/get-docker.sh owner: root group: root mode: '0644' - when: not docker_binary.stat.exists + when: + - not is_rocky_linux + - not docker_binary.stat.exists - name: Install Docker ansible.builtin.command: sh /tmp/get-docker.sh register: docker_installed changed_when: docker_installed.rc != 0 - when: not docker_binary.stat.exists + when: + - not is_rocky_linux + - not docker_binary.stat.exists - name: Create Docker daemon configuration ansible.builtin.copy: dest: /etc/docker/daemon.json @@ -104,6 +155,9 @@ ansible.builtin.service: name: docker.service state: restarted + - name: Refresh dnf cache + ansible.builtin.dnf: + update_cache: yes post_tasks: - name: Start Docker