diff --git a/README.md b/README.md index 0b2f937..538861e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # playbooks +## Cloud Dev Desktop + +The cloud dev desktop flow lives here as two playbooks: + +1. `bootstrap_cloud_dev_desktop.yml` +2. `destroy_cloud_dev_desktop.yml` + +`bootstrap_cloud_dev_desktop.yml` now includes the create/bootstrap/verify sequence in one entry point. The control-plane repo calls these playbooks from `../playbooks`. + ## Traffic Billing Stack The traffic billing stack now has a single aggregate playbook: @@ -8,12 +17,14 @@ The traffic billing stack now has a single aggregate playbook: It orchestrates these existing playbooks in dependency order: -1. `deploy_xray_exporter.yml` -2. `deploy_billing_service.yml` -3. `deploy_accounts_svc_plus.yml` -4. `deploy_console_svc_plus.yml` -5. `deploy_agent_svc_plus.yml` -6. `deploy_xworkmate_bridge_vhosts.yml` +1. `deploy_billing_service.yml` +2. `deploy_xworkmate_bridge_vhosts.yml` +3. `deploy_xray_exporter.yml` +4. `deploy_agent_svc_plus.yml` +5. `deploy_accounts_svc_plus.yml` +6. `deploy_stunnel-client.yml` +7. `deploy_apisix.yml` +8. `deploy_console_svc_plus.yml` ### Full stack deploy @@ -47,12 +58,14 @@ ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_svc_plus_core_ Use `STACK_SERVICES` with a comma-separated list: -- `xray-exporter` - `billing-service` -- `accounts` -- `console` -- `agent` - `xworkmate-bridge` +- `xray-exporter` +- `agent` +- `accounts` +- `stunnel-client` +- `apisix` +- `console` ```bash cd /Users/shenlan/workspaces/cloud-neutral-toolkit/playbooks diff --git a/bootstrap_cloud_dev_desktop.yml b/bootstrap_cloud_dev_desktop.yml new file mode 100644 index 0000000..574cfe5 --- /dev/null +++ b/bootstrap_cloud_dev_desktop.yml @@ -0,0 +1,289 @@ +- name: Normalize cloud dev desktop request + hosts: localhost + connection: local + gather_facts: true + roles: + - role: cloud_vm_request_validate + +- name: Create cloud dev desktop infrastructure + hosts: localhost + connection: local + gather_facts: true + roles: + - role: "{{ (provider == 'azure') | ternary('azure_dev_desktop_lifecycle', 'gcp_dev_desktop_lifecycle') }}" + vars: + cloud_lifecycle_action: create + - role: cloud_vm_inventory_emit + +- name: Bootstrap remote cloud dev desktop + hosts: cloud_desktop + gather_facts: true + become: "{{ os_family != 'windows' }}" + roles: + - role: dev_desktop_common + when: os_family != "windows" + - role: dev_desktop_windows + when: os_family == "windows" + - role: dev_desktop_fedora_gnome + when: os_family == "fedora-gnome" + - role: dev_desktop_debian_kde + when: os_family == "debian-kde" + +- name: Verify remote cloud dev desktop + hosts: cloud_desktop + gather_facts: true + become: "{{ os_family != 'windows' }}" + tasks: + - name: Verify common Linux workspace marker + ansible.builtin.stat: + path: /opt/cloud-dev-desktop/profile.env + register: common_profile_marker + when: os_family != "windows" + + - name: Assert Linux profile marker exists + ansible.builtin.assert: + that: + - common_profile_marker.stat.exists + fail_msg: "Missing /opt/cloud-dev-desktop/profile.env marker on Linux host." + when: os_family != "windows" + + - name: Verify Fedora GNOME desktop packages + ansible.builtin.command: rpm -q gnome-shell gtk3-devel gtk4-devel glib2-devel clang cmake ninja-build + changed_when: false + when: os_family == "fedora-gnome" + + - name: Verify Debian KDE desktop packages + ansible.builtin.shell: | + set -euo pipefail + dpkg-query -W plasma-desktop clang cmake ninja-build >/dev/null + if dpkg-query -W qt6-base-dev >/dev/null 2>&1; then + exit 0 + fi + dpkg-query -W qtbase5-dev >/dev/null + args: + executable: /bin/bash + changed_when: false + when: os_family == "debian-kde" + + - name: Verify Node.js 22+ on Linux + ansible.builtin.shell: | + set -euo pipefail + test "$(node --version | sed 's/^v//' | cut -d. -f1)" -ge 22 + args: + executable: /bin/bash + become_user: "{{ admin_username }}" + changed_when: false + when: os_family != "windows" + + - name: Verify Go toolchain on Linux + ansible.builtin.command: /usr/local/go/bin/go version + become_user: "{{ admin_username }}" + changed_when: false + when: os_family != "windows" + + - name: Verify Codex CLI on Linux + ansible.builtin.command: codex --version + become_user: "{{ admin_username }}" + changed_when: false + when: + - os_family != "windows" + - toolchains.codex | bool + + - name: Verify Flutter is installed on Linux + ansible.builtin.shell: | + set -euo pipefail + test -x {{ cloud_dev_desktop_flutter_install_root | default('/opt/flutter') }}/bin/flutter + {{ cloud_dev_desktop_flutter_install_root | default('/opt/flutter') }}/bin/flutter --version + args: + executable: /bin/bash + become_user: "{{ admin_username }}" + environment: + HOME: "/home/{{ admin_username }}" + PUB_CACHE: "/home/{{ admin_username }}/.pub-cache" + changed_when: false + when: + - os_family != "windows" + - toolchains.flutter | bool + + - name: Verify Codex CLI on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + 'C:\tools\flutter\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + $codexCmd = Join-Path $env:APPDATA 'npm\codex.cmd' + if (-not (Test-Path $codexCmd)) { + throw "Missing Codex CLI launcher at $codexCmd" + } + $codexCmdLine = '"' + $codexCmd + '" --version' + cmd.exe /d /c $codexCmdLine | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Codex CLI version probe failed with exit code $LASTEXITCODE" + } + changed_when: false + when: os_family == "windows" + + - name: Verify Node.js 22+ on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + 'C:\tools\flutter\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + $nodeMajor = [int]((node --version).Trim().TrimStart('v').Split('.')[0]) + if ($nodeMajor -lt 22) { + throw "Node.js 22+ is required, found $(node --version)" + } + changed_when: false + when: os_family == "windows" + + - name: Verify Flutter on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + 'C:\tools\flutter\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + flutter --version | Out-Null + changed_when: false + when: + - os_family == "windows" + - toolchains.flutter | bool + + - name: Verify VS Code on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + 'C:\tools\flutter\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + Get-Command code | Out-Null + changed_when: false + when: + - os_family == "windows" + - toolchains.vscode | bool + + - name: Verify Git on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\Git\cmd', + 'C:\Program Files\nodejs', + 'C:\tools\flutter\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + git --version | Out-Null + changed_when: false + when: os_family == "windows" + + - name: Verify Android Studio on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + if (-not (Test-Path 'C:\Program Files\Android\Android Studio\bin\studio64.exe')) { + throw 'Missing Android Studio executable at C:\Program Files\Android\Android Studio\bin\studio64.exe' + } + changed_when: false + when: + - os_family == "windows" + - toolchains.android_studio | bool + + - name: Verify Visual Studio desktop C++ toolchain on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { + throw "Missing vswhere at $vswhere" + } + $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $installPath) { + throw 'Visual Studio Build Tools with Desktop C++ workload is not installed' + } + changed_when: false + when: os_family == "windows" + + - name: Verify Android SDK and emulator on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $androidSdkRoot = Join-Path $env:LOCALAPPDATA 'Android\Sdk' + $requiredPaths = @( + (Join-Path $androidSdkRoot 'platform-tools\adb.exe'), + (Join-Path $androidSdkRoot 'emulator\emulator.exe'), + (Join-Path $androidSdkRoot 'cmdline-tools\latest\bin\sdkmanager.bat') + ) + foreach ($required in $requiredPaths) { + if (-not (Test-Path $required)) { + throw "Missing Android SDK component: $required" + } + } + changed_when: false + when: + - os_family == "windows" + - toolchains.android_studio | bool + + - name: Verify Windows Android virtualization features + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $featureNames = @( + 'Microsoft-Hyper-V-All', + 'HypervisorPlatform', + 'VirtualMachinePlatform' + ) + foreach ($featureName in $featureNames) { + $feature = Get-WindowsOptionalFeature -Online -FeatureName $featureName + if ($feature.State -ne 'Enabled') { + throw "Windows optional feature $featureName is not enabled" + } + } + & (Join-Path $env:LOCALAPPDATA 'Android\Sdk\emulator\emulator-check.exe') accel | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Android emulator acceleration probe failed with exit code $LASTEXITCODE" + } + changed_when: false + when: + - os_family == "windows" + - toolchains.android_studio | bool + + - name: Verify Windows SSHD service + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $sshd = Get-Service sshd + if ($sshd.Status -ne 'Running') { + throw "SSHD service is not running" + } + changed_when: false + when: os_family == "windows" diff --git a/deploy_agent_svc_plus.yml b/deploy_agent_svc_plus.yml index f6834e9..0fb84fd 100644 --- a/deploy_agent_svc_plus.yml +++ b/deploy_agent_svc_plus.yml @@ -9,6 +9,19 @@ agent_svc_plus_repo_version: >- {{ lookup('ansible.builtin.env', 'AGENT_REPO_VERSION') | default('main', true) }} + agent_svc_plus_release_tag: >- + {{ lookup('ansible.builtin.env', 'AGENT_RELEASE_TAG') + | default( + (lookup('ansible.builtin.env', 'AGENT_REPO_VERSION') + | default('main', true)) + if ((lookup('ansible.builtin.env', 'AGENT_REPO_VERSION') + | default('main', true)) is match('^v.+')) + else '', + true + ) }} + agent_svc_plus_binary_src: >- + {{ lookup('ansible.builtin.env', 'AGENT_BINARY_SRC') + | default('', true) }} agent_svc_plus_app_dir: >- {{ lookup('ansible.builtin.env', 'AGENT_APP_DIR') | default('/opt/agent.svc.plus', true) }} diff --git a/deploy_apisix.yml b/deploy_apisix.yml new file mode 100644 index 0000000..42d0eaa --- /dev/null +++ b/deploy_apisix.yml @@ -0,0 +1,2 @@ +--- +- import_playbook: deploy_apisix_svc.plus.yaml diff --git a/deploy_stunnel-client.yml b/deploy_stunnel-client.yml new file mode 100644 index 0000000..97ed74e --- /dev/null +++ b/deploy_stunnel-client.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy stunnel-client release + hosts: server + become: true + gather_facts: true + roles: + - role: ../github-org-cloud-neutral-toolkit/ansible/roles/stunnel_client_deploy diff --git a/deploy_svc_plus_core_services_stack.yml b/deploy_svc_plus_core_services_stack.yml index dd93b4c..39ffd17 100644 --- a/deploy_svc_plus_core_services_stack.yml +++ b/deploy_svc_plus_core_services_stack.yml @@ -1,20 +1,60 @@ -- import_playbook: deploy_xray_exporter.yml +- name: Load stack environment values from local .env file + hosts: all + gather_facts: false vars: stack_env_file: >- {{ lookup('ansible.builtin.env', 'STACK_ENV_FILE') | default(playbook_dir ~ '/.env', true) }} - xray_exporter_hosts: >- - {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') - | default(lookup('ansible.builtin.env', 'XRAY_EXPORTER_HOSTS') - | default('xray_exporter', true), true) }} - xray_exporter_internal_service_token: >- - {{ lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') - | default(lookup('ansible.builtin.ini', 'INTERNAL_SERVICE_TOKEN type=properties file=' ~ stack_env_file, errors='ignore') - | default('', true), true) }} - stack_services: >- - {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} - when: "'xray-exporter' in (stack_services.split(',') | map('trim') | list)" + tasks: + - name: Parse stack .env file into a dictionary + run_once: true + delegate_to: localhost + ansible.builtin.command: + argv: + - python3 + - -c + - | + import json + import os + import re + import shlex + import sys + + path = sys.argv[1] + data = {} + + if os.path.exists(path): + with open(path, encoding="utf-8", errors="ignore") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line) + if not match: + continue + key, value = match.groups() + value = value.strip() + if value: + try: + parts = shlex.split(value, comments=False, posix=True) + value = parts[0] if parts else "" + except ValueError: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + data[key] = value + + print(json.dumps(data)) + - "{{ stack_env_file }}" + register: stack_env_parse_result + changed_when: false + check_mode: false + + - name: Store parsed stack environment values + run_once: true + delegate_to: localhost + delegate_facts: true + ansible.builtin.set_fact: + stack_env_map: "{{ stack_env_parse_result.stdout | default('{}', true) | from_json }}" - import_playbook: deploy_billing_service.yml vars: @@ -26,18 +66,91 @@ | default(lookup('ansible.builtin.env', 'BILLING_SERVICE_HOSTS') | default('billing_service', true), true) }} billing_service_database_url: >- - {{ lookup('ansible.builtin.env', 'DATABASE_URL') - | default(lookup('ansible.builtin.ini', 'DATABASE_URL type=properties file=' ~ stack_env_file, errors='ignore') - | default('', true), true) }} + {{ + lookup('ansible.builtin.env', 'DATABASE_URL') + | default(hostvars['localhost'].stack_env_map.DATABASE_URL + | default('', true), true) + or ( + 'postgres://%s:%s@%s:%s/%s?sslmode=disable' + | format( + lookup('ansible.builtin.env', 'POSTGRES_USER') + | default(hostvars['localhost'].stack_env_map.POSTGRES_USER + | default('svcplus_vps', true), true), + lookup('ansible.builtin.env', 'POSTGRES_PASSWORD') + | default(hostvars['localhost'].stack_env_map.POSTGRES_PASSWORD + | default('', true), true), + lookup('ansible.builtin.env', 'BILLING_DB_HOST') + | default(hostvars['localhost'].stack_env_map.BILLING_DB_HOST + | default('stunnel-client', true), true), + lookup('ansible.builtin.env', 'BILLING_DB_PORT') + | default(hostvars['localhost'].stack_env_map.BILLING_DB_PORT + | default('15432', true), true), + lookup('ansible.builtin.env', 'BILLING_DB_NAME') + | default(hostvars['localhost'].stack_env_map.BILLING_DB_NAME + | default('account', true), true) + ) + ) + }} billing_service_exporter_base_url: >- {{ lookup('ansible.builtin.env', 'EXPORTER_BASE_URL') - | default(lookup('ansible.builtin.ini', 'EXPORTER_BASE_URL type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.EXPORTER_BASE_URL | default('http://127.0.0.1:8080', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} when: "'billing-service' in (stack_services.split(',') | map('trim') | list)" +- import_playbook: deploy_xworkmate_bridge_vhosts.yml + vars: + xworkmate_bridge_hosts: >- + {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') + | default(lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_HOSTS') + | default('all', true), true) }} + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} + when: "'xworkmate-bridge' in (stack_services.split(',') | map('trim') | list)" + +- import_playbook: deploy_xray_exporter.yml + vars: + stack_env_file: >- + {{ lookup('ansible.builtin.env', 'STACK_ENV_FILE') + | default(playbook_dir ~ '/.env', true) }} + xray_exporter_hosts: >- + {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') + | default(lookup('ansible.builtin.env', 'XRAY_EXPORTER_HOSTS') + | default('xray_exporter', true), true) }} + xray_exporter_internal_service_token: >- + {{ lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') + | default(hostvars['localhost'].stack_env_map.INTERNAL_SERVICE_TOKEN + | default('', true), true) }} + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} + when: "'xray-exporter' in (stack_services.split(',') | map('trim') | list)" + +- import_playbook: deploy_agent_svc_plus.yml + vars: + stack_env_file: >- + {{ lookup('ansible.builtin.env', 'STACK_ENV_FILE') + | default(playbook_dir ~ '/.env', true) }} + agent_service_hosts: >- + {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') + | default(lookup('ansible.builtin.env', 'AGENT_SERVICE_HOSTS') + | default('agent_svc_plus', true), true) }} + agent_api_token: >- + {{ lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') + | default(hostvars['localhost'].stack_env_map.INTERNAL_SERVICE_TOKEN + | default('', true), true) }} + agent_billing_base_url: >- + {{ lookup('ansible.builtin.env', 'BILLING_SERVICE_BASE_URL') + | default(hostvars['localhost'].stack_env_map.BILLING_SERVICE_BASE_URL + | default('http://127.0.0.1:8081', true), true) }} + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} + when: "'agent' in (stack_services.split(',') | map('trim') | list)" + - import_playbook: deploy_accounts_svc_plus.yml vars: stack_env_file: >- @@ -49,17 +162,31 @@ | default('accounts', true), true) }} accounts_service_image_repo: >- {{ lookup('ansible.builtin.env', 'ACCOUNTS_IMAGE_REPO') - | default(lookup('ansible.builtin.ini', 'ACCOUNTS_IMAGE_REPO type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.ACCOUNTS_IMAGE_REPO | default('ghcr.io/x-evor/accounts', true), true) }} accounts_service_image_tag: >- {{ lookup('ansible.builtin.env', 'ACCOUNTS_IMAGE_TAG') - | default(lookup('ansible.builtin.ini', 'ACCOUNTS_IMAGE_TAG type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.ACCOUNTS_IMAGE_TAG | default('latest', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} when: "'accounts' in (stack_services.split(',') | map('trim') | list)" +- import_playbook: deploy_stunnel-client.yml + vars: + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} + when: "'stunnel-client' in (stack_services.split(',') | map('trim') | list)" + +- import_playbook: deploy_apisix.yml + vars: + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} + when: "'apisix' in (stack_services.split(',') | map('trim') | list)" + - import_playbook: deploy_console_svc_plus.yml vars: stack_env_file: >- @@ -75,50 +202,17 @@ | default(false, true), true) | bool }} console_service_frontend_image: >- {{ lookup('ansible.builtin.env', 'FRONTEND_IMAGE') - | default(lookup('ansible.builtin.ini', 'FRONTEND_IMAGE type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.FRONTEND_IMAGE | default('', true), true) }} console_service_registry_username: >- {{ lookup('ansible.builtin.env', 'GHCR_USERNAME') - | default(lookup('ansible.builtin.ini', 'GHCR_USERNAME type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.GHCR_USERNAME | default('', true), true) }} console_service_registry_password: >- {{ lookup('ansible.builtin.env', 'GHCR_PASSWORD') - | default(lookup('ansible.builtin.ini', 'GHCR_PASSWORD type=properties file=' ~ stack_env_file, errors='ignore') + | default(hostvars['localhost'].stack_env_map.GHCR_PASSWORD | default('', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} + | default('billing-service,xworkmate-bridge,xray-exporter,agent,accounts,stunnel-client,apisix,console', true) }} when: "'console' in (stack_services.split(',') | map('trim') | list)" - -- import_playbook: deploy_agent_svc_plus.yml - vars: - stack_env_file: >- - {{ lookup('ansible.builtin.env', 'STACK_ENV_FILE') - | default(playbook_dir ~ '/.env', true) }} - agent_service_hosts: >- - {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') - | default(lookup('ansible.builtin.env', 'AGENT_SERVICE_HOSTS') - | default('agent_svc_plus', true), true) }} - agent_api_token: >- - {{ lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') - | default(lookup('ansible.builtin.ini', 'INTERNAL_SERVICE_TOKEN type=properties file=' ~ stack_env_file, errors='ignore') - | default('', true), true) }} - agent_billing_base_url: >- - {{ lookup('ansible.builtin.env', 'BILLING_SERVICE_BASE_URL') - | default(lookup('ansible.builtin.ini', 'BILLING_SERVICE_BASE_URL type=properties file=' ~ stack_env_file, errors='ignore') - | default('http://127.0.0.1:8081', true), true) }} - stack_services: >- - {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} - when: "'agent' in (stack_services.split(',') | map('trim') | list)" - -- import_playbook: deploy_xworkmate_bridge_vhosts.yml - vars: - xworkmate_bridge_hosts: >- - {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') - | default(lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_HOSTS') - | default('all', true), true) }} - stack_services: >- - {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} - when: "'xworkmate-bridge' in (stack_services.split(',') | map('trim') | list)" diff --git a/deploy_xray_exporter.yml b/deploy_xray_exporter.yml index dacecbf..daffcfc 100644 --- a/deploy_xray_exporter.yml +++ b/deploy_xray_exporter.yml @@ -8,7 +8,7 @@ | default(playbook_dir ~ '/../xray-exporter', true) }} xray_exporter_node_id: >- {{ lookup('ansible.builtin.env', 'EXPORTER_NODE_ID') - | default('node-xhttp.svc.plus', true) }} + | default(xray_exporter_node_id_custom | default(inventory_hostname, true), true) }} xray_exporter_env_name: >- {{ lookup('ansible.builtin.env', 'EXPORTER_ENV') | default('prod', true) }} diff --git a/destroy_cloud_dev_desktop.yml b/destroy_cloud_dev_desktop.yml new file mode 100644 index 0000000..9aeb57c --- /dev/null +++ b/destroy_cloud_dev_desktop.yml @@ -0,0 +1,9 @@ +- name: Destroy cloud dev desktop infrastructure + hosts: localhost + connection: local + gather_facts: true + roles: + - role: cloud_vm_request_validate + - role: "{{ (provider == 'azure') | ternary('azure_dev_desktop_lifecycle', 'gcp_dev_desktop_lifecycle') }}" + vars: + cloud_lifecycle_action: destroy diff --git a/inventory.ini b/inventory.ini index 56b7efd..327d086 100644 --- a/inventory.ini +++ b/inventory.ini @@ -5,7 +5,7 @@ cn-front.svc.plus ansible_host=47.120.61.35 ansible_user=root ansible_ssh_user=r [jp_xhttp_contabo_host] # services: api.svc.plus, console.svc.plus, accounts.svc.plus, acp-server.svc.plus, xworkmate-bridge.svc.plus, vault.svc.plus, openclaw.svc.plus, postgresql.svc.plus -jp-xhttp-contabo.svc.plus ansible_host=46.250.251.132 ansible_user=root ansible_ssh_user=root service_domains=api.svc.plus,console.svc.plus,accounts.svc.plus,acp-server.svc.plus,xworkmate-bridge.svc.plus,vault.svc.plus,openclaw.svc.plus,postgresql.svc.plus +jp-xhttp-contabo.svc.plus ansible_host=46.250.251.132 ansible_user=root ansible_ssh_user=root service_domains=api.svc.plus,console.svc.plus,accounts.svc.plus,acp-server.svc.plus,xworkmate-bridge.svc.plus,vault.svc.plus,openclaw.svc.plus,postgresql.svc.plus xray_exporter_node_id_custom=jp-xhttp-contabo.svc.plus [tky_proxy_host] # services: tky-proxy.svc.plus diff --git a/roles/azure_dev_desktop_lifecycle/tasks/create.yml b/roles/azure_dev_desktop_lifecycle/tasks/create.yml new file mode 100644 index 0000000..d182e45 --- /dev/null +++ b/roles/azure_dev_desktop_lifecycle/tasks/create.yml @@ -0,0 +1,232 @@ +- name: Preview Azure create request + ansible.builtin.debug: + msg: + - "azure_resource_group={{ azure_resource_group }}" + - "azure_vm_name={{ azure_vm_name }}" + - "azure_computer_name={{ azure_computer_name }}" + - "azure_location={{ azure_location }}" + - "allowed_cidrs={{ allowed_cidrs | join(',') }}" + - "allowed_tcp_ports={{ allowed_tcp_ports | join(',') }}" + - "state_file={{ cloud_vm_state_file }}" + +- name: Ensure Azure resource group exists + ansible.builtin.command: + argv: + - az + - group + - create + - --subscription + - "{{ azure_subscription_id }}" + - --name + - "{{ azure_resource_group }}" + - --location + - "{{ azure_location }}" + - --tags + - "{{ tags | dict2items | map('join', '=') | join(' ') }}" + changed_when: true + when: not ansible_check_mode + +- name: Ensure Azure virtual network exists + ansible.builtin.command: + argv: + - az + - network + - vnet + - create + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_virtual_network }}" + - --address-prefixes + - "{{ azure_vnet_cidr | default('10.42.0.0/16') }}" + - --subnet-name + - "{{ azure_subnet }}" + - --subnet-prefixes + - "{{ azure_subnet_cidr | default('10.42.1.0/24') }}" + changed_when: true + when: not ansible_check_mode + +- name: Ensure Azure network security group exists + ansible.builtin.command: + argv: + - az + - network + - nsg + - create + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_network_security_group }}" + - --location + - "{{ azure_location }}" + changed_when: true + when: not ansible_check_mode + +- name: Create Azure allowlist rules + ansible.builtin.command: + argv: + - az + - network + - nsg + - rule + - create + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --nsg-name + - "{{ azure_network_security_group }}" + - --name + - "allow-tcp-{{ port }}-{{ '%03d' | format((index | int) + ((port_index | int) * 100) + 100) }}" + - --priority + - "{{ '%d' | format((index | int) + ((port_index | int) * 100) + 100) }}" + - --access + - Allow + - --protocol + - Tcp + - --direction + - Inbound + - --source-address-prefixes + - "{{ cidr }}" + - --source-port-ranges + - "*" + - --destination-port-ranges + - "{{ port | string }}" + loop: "{{ allowed_cidrs | product(allowed_tcp_ports) | list }}" + loop_control: + label: "{{ item.0 }} -> {{ item.1 }}" + index_var: combo_index + changed_when: true + when: not ansible_check_mode + vars: + cidr: "{{ item.0 }}" + port: "{{ item.1 }}" + index: "{{ combo_index % (allowed_cidrs | length) }}" + port_index: "{{ combo_index // (allowed_cidrs | length) }}" + +- name: Build Azure VM create command + ansible.builtin.set_fact: + azure_vm_create_command: >- + az vm create + --subscription {{ azure_subscription_id | quote }} + --resource-group {{ azure_resource_group | quote }} + --name {{ azure_vm_name | quote }} + --computer-name {{ azure_computer_name | quote }} + --image {{ (azure_image_publisher ~ ':' ~ azure_image_offer ~ ':' ~ azure_image_sku ~ ':' ~ azure_image_version) | quote }} + --size {{ vm_size | quote }} + --admin-username {{ admin_username | quote }} + --vnet-name {{ azure_virtual_network | quote }} + --subnet {{ azure_subnet | quote }} + --nsg {{ azure_network_security_group | quote }} + --public-ip-sku Standard + --public-ip-address {{ azure_public_ip_name | quote }} + --storage-sku Premium_LRS + --os-disk-size-gb {{ disk_gb | int }} + --tags {{ tags | dict2items | map('join', '=') | join(' ') | quote }} + {% if os_family == 'windows' %} + --admin-password {{ azure_admin_password | quote }} + {% else %} + --ssh-key-values {{ ssh_public_key_path | quote }} + {% endif %} + +- name: Create Azure VM + ansible.builtin.shell: "{{ azure_vm_create_command }}" + args: + executable: /bin/bash + changed_when: true + when: not ansible_check_mode + +- name: Fetch Azure VM networking facts + ansible.builtin.command: + argv: + - az + - vm + - show + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_vm_name }}" + - --show-details + - --query + - "{publicIp:publicIps,privateIp:privateIps}" + - -o + - json + register: azure_vm_network_json + changed_when: false + when: not ansible_check_mode + +- name: Set Azure VM connection facts + ansible.builtin.set_fact: + cloud_vm_public_ip: "{{ (azure_vm_network_json.stdout | from_json).publicIp | default('127.0.0.1') }}" + cloud_vm_private_ip: "{{ (azure_vm_network_json.stdout | from_json).privateIp | default('127.0.0.1') }}" + cloud_vm_admin_user: "{{ admin_username }}" + when: not ansible_check_mode + +- name: Set Azure dry-run connection facts + ansible.builtin.set_fact: + cloud_vm_public_ip: "{{ cloud_vm_public_ip | default('198.51.100.10') }}" + cloud_vm_private_ip: "{{ cloud_vm_private_ip | default('10.42.1.10') }}" + cloud_vm_admin_user: "{{ admin_username }}" + when: ansible_check_mode + +- name: Prepare Azure Windows VM for initial WinRM bootstrap + ansible.builtin.command: + argv: + - az + - vm + - run-command + - invoke + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_vm_name }}" + - --command-id + - RunPowerShellScript + - --scripts + - | + $ProgressPreference = 'SilentlyContinue' + $profiles = Get-NetConnectionProfile + foreach ($profile in $profiles) { + if ($profile.NetworkCategory -ne 'Private') { + Set-NetConnectionProfile -InterfaceIndex $profile.InterfaceIndex -NetworkCategory Private + } + } + winrm quickconfig -quiet + Enable-PSRemoting -Force + Set-Service -Name WinRM -StartupType Automatic + Start-Service -Name WinRM + Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $true + Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $true + netsh advfirewall firewall add rule name="Allow WinRM 5985" dir=in action=allow protocol=TCP localport=5985 | Out-Null + Get-Service -Name WinRM | Select-Object Status, StartType, Name + register: azure_windows_winrm_prep + changed_when: true + when: + - not ansible_check_mode + - os_family == "windows" + +- name: Show Azure Windows WinRM prep output + ansible.builtin.debug: + var: azure_windows_winrm_prep.stdout + when: + - not ansible_check_mode + - os_family == "windows" + +- name: Wait for Azure Windows WinRM endpoint + ansible.builtin.wait_for: + host: "{{ cloud_vm_public_ip }}" + port: 5985 + delay: 10 + timeout: 300 + state: started + when: + - not ansible_check_mode + - os_family == "windows" diff --git a/roles/azure_dev_desktop_lifecycle/tasks/destroy.yml b/roles/azure_dev_desktop_lifecycle/tasks/destroy.yml new file mode 100644 index 0000000..90aec3d --- /dev/null +++ b/roles/azure_dev_desktop_lifecycle/tasks/destroy.yml @@ -0,0 +1,96 @@ +- name: Preview Azure destroy/cleanup request + ansible.builtin.debug: + msg: + - "azure_resource_group={{ azure_resource_group | default('n/a') }}" + - "azure_vm_name={{ azure_vm_name | default(profile_name | default('n/a')) }}" + - "azure_cleanup_mode={{ azure_cleanup_mode | default(false) }}" + - "cloud_vm_destroy_mode={{ cloud_vm_destroy_mode | default('destroy') }}" + +- name: Build Azure cleanup query + ansible.builtin.set_fact: + azure_cleanup_query: "[?tags.toolkit_scope=='cloud-dev-desktop' && tags.managed_by=='ansible'].[resourceGroup,name]" + when: azure_cleanup_mode | default(false) + +- name: List Azure expired VMs + ansible.builtin.command: + argv: + - az + - vm + - list + - --subscription + - "{{ azure_subscription_id }}" + - --show-details + - --query + - "{{ azure_cleanup_query }}" + - -o + - json + register: azure_expired_vm_list + changed_when: false + when: + - azure_cleanup_mode | default(false) + - not ansible_check_mode + +- name: Park Azure VM in lowest-consumption mode + ansible.builtin.command: + argv: + - az + - vm + - deallocate + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_vm_name }}" + changed_when: true + when: + - not azure_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'park' + - not ansible_check_mode + +- name: Delete Azure VM directly + ansible.builtin.command: + argv: + - az + - vm + - delete + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ azure_resource_group }}" + - --name + - "{{ azure_vm_name }}" + - --yes + changed_when: true + when: + - not azure_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'destroy' + - not ansible_check_mode + +- name: Delete Azure expired VMs + ansible.builtin.command: + argv: + - az + - vm + - delete + - --subscription + - "{{ azure_subscription_id }}" + - --resource-group + - "{{ item[0] }}" + - --name + - "{{ item[1] }}" + - --yes + loop: "{{ (azure_expired_vm_list.stdout | default('[]')) | from_json }}" + changed_when: true + when: + - azure_cleanup_mode | default(false) + - not ansible_check_mode + +- name: Remove Azure state file after destroy + ansible.builtin.file: + path: "{{ cloud_vm_state_file }}" + state: absent + when: + - cloud_vm_state_file is defined + - not azure_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'destroy' diff --git a/roles/azure_dev_desktop_lifecycle/tasks/facts.yml b/roles/azure_dev_desktop_lifecycle/tasks/facts.yml new file mode 100644 index 0000000..1d6f494 --- /dev/null +++ b/roles/azure_dev_desktop_lifecycle/tasks/facts.yml @@ -0,0 +1,64 @@ +- name: Normalize Azure desktop resource names + ansible.builtin.set_fact: + azure_subscription_id: "{{ azure_subscription_id | default(lookup('env', 'AZURE_SUBSCRIPTION_ID')) }}" + azure_resource_group: "{{ azure_resource_group | default('rg-devdesktop-' ~ cloud_vm_owner_slug) }}" + azure_location: "{{ region | default(azure_location | default('japaneast')) }}" + azure_network_security_group: "{{ azure_network_security_group | default('nsg-' ~ cloud_vm_profile_slug) }}" + azure_virtual_network: "{{ azure_virtual_network | default('vnet-devdesktop-' ~ cloud_vm_owner_slug) }}" + azure_subnet: "{{ azure_subnet | default('snet-devdesktop') }}" + azure_public_ip_name: "{{ azure_public_ip_name | default('pip-' ~ cloud_vm_profile_slug) }}" + azure_nic_name: "{{ azure_nic_name | default('nic-' ~ cloud_vm_profile_slug) }}" + azure_vm_name: "{{ azure_vm_name | default('vm-' ~ cloud_vm_profile_slug) }}" + azure_computer_name: >- + {{ + azure_computer_name + | default( + ('vm' ~ (cloud_vm_profile_slug | regex_replace('[^A-Za-z0-9]', '')))[:15] + ) + }} + +- name: Assert Azure subscription is available when requested + ansible.builtin.assert: + that: + - azure_subscription_id | length > 0 + fail_msg: "AZURE_SUBSCRIPTION_ID or azure_subscription_id is required for Azure operations." + when: not ansible_check_mode + +- name: Select Azure image defaults by OS family + ansible.builtin.set_fact: + azure_image_publisher: >- + {{ + azure_image_publisher + | default( + { + 'windows': 'MicrosoftWindowsDesktop', + 'fedora-gnome': 'Fedora', + 'debian-kde': 'Debian' + }[os_family] + ) + }} + azure_image_offer: >- + {{ + image_offer + | default( + { + 'windows': 'windows-11', + 'fedora-gnome': 'fedora-x86_64', + 'debian-kde': 'debian-13' + }[os_family] + ) + }} + azure_image_sku: >- + {{ + image_sku + | default( + { + 'windows': 'win11-24h2-pro', + 'fedora-gnome': '43-gen2', + 'debian-kde': '13-gen2' + }[os_family] + ) + }} + azure_image_version: "{{ image_version | default('latest') }}" + azure_admin_password: "{{ azure_admin_password | default(lookup('env', 'AZURE_WINDOWS_ADMIN_PASSWORD')) }}" + cloud_vm_platform: "azure" diff --git a/roles/azure_dev_desktop_lifecycle/tasks/main.yml b/roles/azure_dev_desktop_lifecycle/tasks/main.yml new file mode 100644 index 0000000..3881b54 --- /dev/null +++ b/roles/azure_dev_desktop_lifecycle/tasks/main.yml @@ -0,0 +1,16 @@ +- name: Gather Azure lifecycle facts + ansible.builtin.include_tasks: facts.yml + +- name: Run Azure create flow + ansible.builtin.include_tasks: create.yml + when: cloud_lifecycle_action == "create" + +- name: Run Azure destroy flow + ansible.builtin.include_tasks: destroy.yml + when: cloud_lifecycle_action == "destroy" + +- name: Run Azure cleanup flow + ansible.builtin.include_tasks: destroy.yml + vars: + azure_cleanup_mode: true + when: cloud_lifecycle_action == "cleanup" diff --git a/roles/cloud_cli_prereqs/defaults/main.yml b/roles/cloud_cli_prereqs/defaults/main.yml new file mode 100644 index 0000000..c70ceb2 --- /dev/null +++ b/roles/cloud_cli_prereqs/defaults/main.yml @@ -0,0 +1,10 @@ +cloud_cli_prereqs_install_azure_cli: true +cloud_cli_prereqs_install_gcloud_cli: true +cloud_cli_prereqs_gcloud_version: 527.0.0 +cloud_cli_prereqs_gcloud_install_root: /opt +cloud_cli_prereqs_gcloud_archive_map: + x86_64: google-cloud-cli-linux-x86_64.tar.gz + amd64: google-cloud-cli-linux-x86_64.tar.gz + aarch64: google-cloud-cli-linux-arm.tar.gz + arm64: google-cloud-cli-linux-arm.tar.gz + diff --git a/roles/cloud_cli_prereqs/tasks/linux.yml b/roles/cloud_cli_prereqs/tasks/linux.yml new file mode 100644 index 0000000..b821c54 --- /dev/null +++ b/roles/cloud_cli_prereqs/tasks/linux.yml @@ -0,0 +1,101 @@ +- name: Install Azure CLI on Debian family + ansible.builtin.shell: | + set -euo pipefail + curl -sL https://aka.ms/InstallAzureCLIDeb | bash + args: + executable: /bin/bash + when: + - cloud_cli_prereqs_install_azure_cli | bool + - ansible_os_family == "Debian" + - not ansible_check_mode + +- name: Install Azure CLI dependencies on RedHat family + ansible.builtin.package: + name: + - ca-certificates + - curl + - gnupg2 + state: present + when: + - cloud_cli_prereqs_install_azure_cli | bool + - ansible_os_family == "RedHat" + +- name: Add Azure CLI yum repository on RedHat family + ansible.builtin.copy: + dest: /etc/yum.repos.d/azure-cli.repo + mode: "0644" + content: | + [azure-cli] + name=Azure CLI + baseurl=https://packages.microsoft.com/yumrepos/azure-cli + enabled=1 + gpgcheck=1 + gpgkey=https://packages.microsoft.com/keys/microsoft.asc + when: + - cloud_cli_prereqs_install_azure_cli | bool + - ansible_os_family == "RedHat" + +- name: Install Azure CLI on RedHat family + ansible.builtin.package: + name: azure-cli + state: present + when: + - cloud_cli_prereqs_install_azure_cli | bool + - ansible_os_family == "RedHat" + +- name: Select Google Cloud CLI archive for Linux + ansible.builtin.set_fact: + cloud_cli_prereqs_gcloud_archive_name: >- + {{ + cloud_cli_prereqs_gcloud_archive_map[ansible_architecture] + | default('google-cloud-cli-linux-x86_64.tar.gz') + }} + when: cloud_cli_prereqs_install_gcloud_cli | bool + +- name: Build Google Cloud CLI download URL for Linux + ansible.builtin.set_fact: + cloud_cli_prereqs_gcloud_url: >- + https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/{{ cloud_cli_prereqs_gcloud_archive_name }} + when: cloud_cli_prereqs_install_gcloud_cli | bool + +- name: Download Google Cloud CLI archive on Linux + ansible.builtin.get_url: + url: "{{ cloud_cli_prereqs_gcloud_url }}" + dest: "/tmp/{{ cloud_cli_prereqs_gcloud_archive_name }}" + mode: "0644" + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - not ansible_check_mode + +- name: Extract Google Cloud CLI on Linux + ansible.builtin.unarchive: + src: "/tmp/{{ cloud_cli_prereqs_gcloud_archive_name }}" + dest: "{{ cloud_cli_prereqs_gcloud_install_root }}" + remote_src: true + creates: "{{ cloud_cli_prereqs_gcloud_install_root }}/google-cloud-sdk/bin/gcloud" + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - not ansible_check_mode + +- name: Ensure gcloud symlink exists on Linux + ansible.builtin.file: + src: "{{ cloud_cli_prereqs_gcloud_install_root }}/google-cloud-sdk/bin/gcloud" + dest: /usr/local/bin/gcloud + state: link + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - not ansible_check_mode + +- name: Verify Azure CLI on Linux + ansible.builtin.command: az version + changed_when: false + when: + - cloud_cli_prereqs_install_azure_cli | bool + - not ansible_check_mode + +- name: Verify Google Cloud CLI on Linux + ansible.builtin.command: gcloud version + changed_when: false + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - not ansible_check_mode diff --git a/roles/cloud_cli_prereqs/tasks/macos.yml b/roles/cloud_cli_prereqs/tasks/macos.yml new file mode 100644 index 0000000..e73c2d2 --- /dev/null +++ b/roles/cloud_cli_prereqs/tasks/macos.yml @@ -0,0 +1,13 @@ +- name: Ensure Homebrew is installed on macOS + ansible.builtin.command: brew --version + changed_when: false + +- name: Install Azure CLI on macOS + ansible.builtin.command: brew install azure-cli + changed_when: true + when: cloud_cli_prereqs_install_azure_cli | bool + +- name: Install Google Cloud CLI on macOS + ansible.builtin.command: brew install --cask google-cloud-sdk + changed_when: true + when: cloud_cli_prereqs_install_gcloud_cli | bool diff --git a/roles/cloud_cli_prereqs/tasks/main.yml b/roles/cloud_cli_prereqs/tasks/main.yml new file mode 100644 index 0000000..7355c88 --- /dev/null +++ b/roles/cloud_cli_prereqs/tasks/main.yml @@ -0,0 +1,14 @@ +- name: Install macOS cloud CLIs + ansible.builtin.include_tasks: macos.yml + when: ansible_system == "Darwin" + +- name: Install Windows cloud CLIs + ansible.builtin.include_tasks: windows.yml + when: ansible_os_family == "Windows" + +- name: Install Linux cloud CLIs + ansible.builtin.include_tasks: linux.yml + when: + - ansible_system == "Linux" + - ansible_os_family != "Windows" + diff --git a/roles/cloud_cli_prereqs/tasks/windows.yml b/roles/cloud_cli_prereqs/tasks/windows.yml new file mode 100644 index 0000000..1054e15 --- /dev/null +++ b/roles/cloud_cli_prereqs/tasks/windows.yml @@ -0,0 +1,84 @@ +- name: Detect winget on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "SilentlyContinue" + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Output "present" + exit 0 + } + Write-Output "missing" + exit 0 + changed_when: false + register: cloud_cli_prereqs_winget_check + +- name: Detect Chocolatey on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "SilentlyContinue" + if (Get-Command choco -ErrorAction SilentlyContinue) { + Write-Output "present" + exit 0 + } + Write-Output "missing" + exit 0 + changed_when: false + register: cloud_cli_prereqs_choco_check + +- name: Install Chocolatey fallback on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + changed_when: true + when: + - "'present' not in cloud_cli_prereqs_winget_check.stdout" + - "'present' not in cloud_cli_prereqs_choco_check.stdout" + +- name: Install Azure CLI on Windows with winget + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + winget install --id Microsoft.AzureCLI --exact --accept-package-agreements --accept-source-agreements --silent + changed_when: true + when: + - cloud_cli_prereqs_install_azure_cli | bool + - "'present' in cloud_cli_prereqs_winget_check.stdout" + +- name: Install Azure CLI on Windows with Chocolatey + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + choco install azure-cli -y --no-progress + changed_when: true + when: + - cloud_cli_prereqs_install_azure_cli | bool + - "'present' not in cloud_cli_prereqs_winget_check.stdout" + +- name: Install Google Cloud CLI on Windows with winget + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + winget install --id Google.CloudSDK --exact --accept-package-agreements --accept-source-agreements --silent + changed_when: true + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - "'present' in cloud_cli_prereqs_winget_check.stdout" + +- name: Install Google Cloud CLI on Windows with Chocolatey + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + choco install gcloudsdk -y --no-progress + changed_when: true + when: + - cloud_cli_prereqs_install_gcloud_cli | bool + - "'present' not in cloud_cli_prereqs_winget_check.stdout" + +- name: Verify Azure CLI on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + Get-Command az | Out-Null + changed_when: false + when: cloud_cli_prereqs_install_azure_cli | bool + +- name: Verify Google Cloud CLI on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + Get-Command gcloud | Out-Null + changed_when: false + when: cloud_cli_prereqs_install_gcloud_cli | bool diff --git a/roles/cloud_vm_inventory_emit/tasks/main.yml b/roles/cloud_vm_inventory_emit/tasks/main.yml new file mode 100644 index 0000000..66854c2 --- /dev/null +++ b/roles/cloud_vm_inventory_emit/tasks/main.yml @@ -0,0 +1,35 @@ +- name: Ensure cloud desktop state directory exists + ansible.builtin.file: + path: "{{ cloud_vm_state_root }}" + state: directory + mode: "0700" + when: cloud_vm_state_root is defined + +- name: Persist cloud desktop state file + ansible.builtin.copy: + dest: "{{ cloud_vm_state_file }}" + mode: "0600" + content: | + { + "provider": {{ provider | to_json }}, + "profile_name": {{ profile_name | to_json }}, + "os_family": {{ os_family | to_json }}, + "admin_username": {{ cloud_vm_admin_user | default(admin_username) | to_json }}, + "public_ip": {{ cloud_vm_public_ip | default('') | to_json }}, + "private_ip": {{ cloud_vm_private_ip | default('') | to_json }}, + "platform": {{ cloud_vm_platform | default(os_family) | to_json }}, + "desktop_access": {{ desktop_access | default({}) | to_json }}, + "tags": {{ tags | default({}) | to_json }}, + "created_at": {{ cloud_vm_created_at | default('') | to_json }}, + "expires_at": {{ cloud_vm_expires_at | default('') | to_json }} + } + when: + - cloud_vm_state_file is defined + - cloud_vm_public_ip is defined + - not ansible_check_mode + +- name: Show resulting cloud desktop state path + ansible.builtin.debug: + msg: + - "cloud_vm_state_file={{ cloud_vm_state_file | default('unset') }}" + - "cloud_vm_public_ip={{ cloud_vm_public_ip | default('pending') }}" diff --git a/roles/cloud_vm_request_validate/tasks/main.yml b/roles/cloud_vm_request_validate/tasks/main.yml new file mode 100644 index 0000000..e967648 --- /dev/null +++ b/roles/cloud_vm_request_validate/tasks/main.yml @@ -0,0 +1,119 @@ +- name: Ensure request validation mode is set + ansible.builtin.set_fact: + cloud_vm_request_validation_mode: "{{ cloud_vm_request_validation_mode | default('standard') }}" + +- name: Capture provider defaults + ansible.builtin.set_fact: + cloud_dev_desktop_required_common_keys: + - provider + - profile_name + - os_family + - admin_username + - allowed_cidrs + - ttl_hours + - owner + - purpose + +- name: Assert provider is supported + ansible.builtin.assert: + that: + - provider is defined + - provider in ['azure', 'gcp'] + fail_msg: "provider must be one of: azure, gcp" + +- name: Assert os_family is supported + ansible.builtin.assert: + that: + - os_family is defined + - os_family in ['windows', 'fedora-gnome', 'debian-kde'] + fail_msg: "os_family must be one of: windows, fedora-gnome, debian-kde" + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Assert required common fields are present + ansible.builtin.assert: + that: "{{ cloud_dev_desktop_required_common_keys | map('extract', vars) | list is not none }}" + fail_msg: "cloud dev desktop request is missing one or more required keys." + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Assert allowed CIDRs were supplied + ansible.builtin.assert: + that: + - allowed_cidrs is sequence + - allowed_cidrs | length > 0 + fail_msg: "allowed_cidrs must be a non-empty list." + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Assert provider-specific location fields exist for standard mode + ansible.builtin.assert: + that: + - "(provider == 'azure' and region is defined) or (provider == 'gcp' and zone is defined)" + fail_msg: "azure requests need region; gcp requests need zone." + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Normalize toolchain defaults + ansible.builtin.set_fact: + toolchains: "{{ {'codex': true, 'android_studio': false, 'vscode': true, 'flutter': false, 'dart': false} | combine(toolchains | default({}), recursive=True) }}" + +- name: Normalize SSH public key default + ansible.builtin.set_fact: + ssh_public_key_path: "{{ ssh_public_key_path | default('~/.ssh/id_rsa.pub') }}" + when: + - cloud_vm_request_validation_mode != "cleanup" + - os_family != "windows" + +- name: Normalize allowed TCP ports + ansible.builtin.set_fact: + allowed_tcp_ports: >- + {{ + allowed_tcp_ports + | default( + (os_family == 'windows') + | ternary([22, 3389, 5985], [22, 3389]) + ) + }} + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Normalize desktop access defaults + ansible.builtin.set_fact: + desktop_access: "{{ {'protocol': (os_family == 'windows') | ternary('rdp', 'native'), 'port': (os_family == 'windows') | ternary(3389, 22)} | combine(desktop_access | default({}), recursive=True) }}" + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Derive cloud desktop timestamps and names + ansible.builtin.set_fact: + cloud_vm_profile_slug: "{{ (profile_name | default('cleanup')) | lower | regex_replace('[^a-z0-9]+', '-') | regex_replace('(^-|-$)', '') }}" + cloud_vm_owner_slug: "{{ (owner | default('cleanup')) | lower | regex_replace('[^a-z0-9]+', '-') | regex_replace('(^-|-$)', '') }}" + cloud_vm_state_root: "{{ cloud_vm_state_root | default(playbook_dir ~ '/../.cloud-dev-desktop-state') }}" + cloud_vm_created_at: "{{ ansible_date_time.iso8601 }}" + cloud_vm_expires_at: "{{ lookup('pipe', 'python3 -c \"from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc)+timedelta(hours=' ~ (ttl_hours | int) ~ ')).isoformat())\"') }}" + when: + - ttl_hours is defined + - cloud_vm_request_validation_mode != "cleanup" + +- name: Derive cloud desktop cleanup names + ansible.builtin.set_fact: + cloud_vm_profile_slug: "{{ (profile_name | default('cleanup')) | lower | regex_replace('[^a-z0-9]+', '-') | regex_replace('(^-|-$)', '') }}" + cloud_vm_owner_slug: "{{ (owner | default('cleanup')) | lower | regex_replace('[^a-z0-9]+', '-') | regex_replace('(^-|-$)', '') }}" + when: cloud_vm_request_validation_mode == "cleanup" + +- name: Derive cloud desktop state file path + ansible.builtin.set_fact: + cloud_vm_state_file: "{{ cloud_vm_state_file | default(cloud_vm_state_root ~ '/' ~ provider ~ '-' ~ cloud_vm_profile_slug ~ '.json') }}" + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Build default tags and labels + ansible.builtin.set_fact: + cloud_vm_default_tags: + managed_by: ansible + toolkit_scope: cloud-dev-desktop + provider: "{{ provider }}" + profile_name: "{{ profile_name }}" + owner: "{{ owner }}" + purpose: "{{ purpose }}" + os_family: "{{ os_family }}" + expires_at: "{{ cloud_vm_expires_at | default('') }}" + when: cloud_vm_request_validation_mode != "cleanup" + +- name: Normalize tags and labels + ansible.builtin.set_fact: + tags: "{{ cloud_vm_default_tags | combine(tags | default({}), recursive=True) }}" + when: cloud_vm_request_validation_mode != "cleanup" diff --git a/roles/dev_desktop_common/defaults/main.yml b/roles/dev_desktop_common/defaults/main.yml new file mode 100644 index 0000000..52195e1 --- /dev/null +++ b/roles/dev_desktop_common/defaults/main.yml @@ -0,0 +1,11 @@ +cloud_dev_desktop_workspace_root: /opt/cloud-dev-desktop +cloud_dev_desktop_timezone: Etc/UTC +cloud_dev_desktop_locale: en_US.UTF-8 +cloud_dev_desktop_user_shell: /bin/bash +cloud_dev_desktop_extra_authorized_keys: [] +cloud_dev_desktop_toolchain_profile_path: /etc/profile.d/cloud-dev-desktop-toolchain.sh +cloud_dev_desktop_node_major: "22" +cloud_dev_desktop_go_release_index_url: https://go.dev/dl/?mode=json +cloud_dev_desktop_flutter_install_root: /opt/flutter +cloud_dev_desktop_flutter_version: "3.41.5" +cloud_dev_desktop_flutter_linux_sdk_url: "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_{{ cloud_dev_desktop_flutter_version }}-stable.tar.xz" diff --git a/roles/dev_desktop_common/tasks/main.yml b/roles/dev_desktop_common/tasks/main.yml new file mode 100644 index 0000000..4260cb3 --- /dev/null +++ b/roles/dev_desktop_common/tasks/main.yml @@ -0,0 +1,14 @@ +- name: Expand Linux root filesystem when the cloud image did not consume the full boot disk + ansible.builtin.import_tasks: storage.yml + +- name: Prepare common Linux users + ansible.builtin.import_tasks: users.yml + +- name: Install common Linux developer toolchains + ansible.builtin.import_tasks: toolchains.yml + +- name: Prepare common Linux network policy + ansible.builtin.import_tasks: network.yml + +- name: Persist TTL and profile metadata + ansible.builtin.import_tasks: ttl.yml diff --git a/roles/dev_desktop_common/tasks/network.yml b/roles/dev_desktop_common/tasks/network.yml new file mode 100644 index 0000000..407e7bc --- /dev/null +++ b/roles/dev_desktop_common/tasks/network.yml @@ -0,0 +1,9 @@ +- name: Record allowlisted desktop access policy + ansible.builtin.copy: + dest: "{{ cloud_dev_desktop_workspace_root }}/allowed-cidrs.txt" + mode: "0644" + content: | + {% for cidr in allowed_cidrs %} + {{ cidr }} + {% endfor %} + diff --git a/roles/dev_desktop_common/tasks/storage.yml b/roles/dev_desktop_common/tasks/storage.yml new file mode 100644 index 0000000..de498a0 --- /dev/null +++ b/roles/dev_desktop_common/tasks/storage.yml @@ -0,0 +1,87 @@ +- name: Install Linux filesystem growth helpers on Debian family + ansible.builtin.package: + name: + - cloud-guest-utils + - gdisk + - btrfs-progs + - xfsprogs + - e2fsprogs + state: present + when: + - os_family != "windows" + - ansible_os_family == "Debian" + - not ansible_check_mode + +- name: Install Linux filesystem growth helpers on RedHat family + ansible.builtin.package: + name: + - cloud-utils-growpart + - gdisk + - btrfs-progs + - xfsprogs + - e2fsprogs + state: present + when: + - os_family != "windows" + - ansible_os_family == "RedHat" + - not ansible_check_mode + +- name: Expand Linux root partition and filesystem when the image layout is undersized + ansible.builtin.shell: | + set -euo pipefail + + root_source="$(findmnt -no SOURCE /)" + root_block_device="${root_source%%[*}" + root_fstype="$(findmnt -no FSTYPE /)" + + if [[ -z "${root_block_device}" || "${root_block_device}" != /dev/* ]]; then + exit 0 + fi + + root_pkname="$(lsblk -no PKNAME "${root_block_device}")" + root_partnum="$(basename "${root_block_device}" | sed -E 's/^.*[^0-9]([0-9]+)$/\1/')" + + if [[ -z "${root_pkname}" || -z "${root_partnum}" ]]; then + exit 0 + fi + + disk_device="/dev/${root_pkname}" + root_device="/dev/$(basename "${root_block_device}")" + + disk_size_bytes="$(blockdev --getsize64 "${disk_device}")" + part_size_bytes="$(blockdev --getsize64 "${root_device}")" + slack_bytes="$((disk_size_bytes - part_size_bytes))" + + # Skip resize when the partition already occupies nearly the full boot disk. + if (( slack_bytes < 536870912 )); then + exit 0 + fi + + sgdisk -e "${disk_device}" >/dev/null 2>&1 || true + growpart_output="$(growpart "${disk_device}" "${root_partnum}" 2>&1 || true)" + if [[ -n "${growpart_output}" ]]; then + echo "${growpart_output}" + fi + if grep -q 'NOCHANGE:' <<<"${growpart_output}"; then + exit 0 + fi + + case "${root_fstype}" in + btrfs) + btrfs filesystem resize max / + ;; + ext2|ext3|ext4) + resize2fs "${root_device}" + ;; + xfs) + xfs_growfs / + ;; + *) + exit 0 + ;; + esac + args: + executable: /bin/bash + when: + - os_family != "windows" + - not ansible_check_mode diff --git a/roles/dev_desktop_common/tasks/toolchains.yml b/roles/dev_desktop_common/tasks/toolchains.yml new file mode 100644 index 0000000..faca9d7 --- /dev/null +++ b/roles/dev_desktop_common/tasks/toolchains.yml @@ -0,0 +1,124 @@ +- name: Detect current Linux Node.js version + ansible.builtin.command: node --version + register: cloud_dev_desktop_node_version + changed_when: false + failed_when: false + +- name: Determine whether Linux Node.js 22+ installation is required + ansible.builtin.set_fact: + cloud_dev_desktop_node_major_installed: "{{ (cloud_dev_desktop_node_version.stdout | default('v0.0.0') | regex_replace('^v([0-9]+).*$','\\1')) | int }}" + cloud_dev_desktop_needs_node_install: "{{ ((cloud_dev_desktop_node_version.stdout | default('v0.0.0') | regex_replace('^v([0-9]+).*$','\\1')) | int) < (cloud_dev_desktop_node_major | int) }}" + +- name: Install Node.js 22.x from NodeSource on Debian family + ansible.builtin.shell: | + set -euo pipefail + curl -fsSL "https://deb.nodesource.com/setup_{{ cloud_dev_desktop_node_major }}.x" | bash - + apt-get install -y nodejs + args: + executable: /bin/bash + when: + - ansible_os_family == "Debian" + - cloud_dev_desktop_needs_node_install + - not ansible_check_mode + +- name: Install Node.js 22.x from NodeSource on Fedora family + ansible.builtin.shell: | + set -euo pipefail + curl -fsSL "https://rpm.nodesource.com/setup_{{ cloud_dev_desktop_node_major }}.x" | bash - + dnf install -y nodejs + args: + executable: /bin/bash + when: + - ansible_os_family == "RedHat" + - cloud_dev_desktop_needs_node_install + - not ansible_check_mode + +- name: Detect current Linux Go toolchain + ansible.builtin.command: /usr/local/go/bin/go version + register: cloud_dev_desktop_go_version + changed_when: false + failed_when: false + +- name: Fetch latest stable Go release index + ansible.builtin.uri: + url: "{{ cloud_dev_desktop_go_release_index_url }}" + return_content: true + register: cloud_dev_desktop_go_release_index + when: not ansible_check_mode + +- name: Select latest stable Go archive for linux amd64 + ansible.builtin.set_fact: + cloud_dev_desktop_go_release: "{{ (cloud_dev_desktop_go_release_index.json | selectattr('stable', 'equalto', true) | list | first) }}" + cloud_dev_desktop_go_archive: >- + {{ + ( + (cloud_dev_desktop_go_release_index.json | selectattr('stable', 'equalto', true) | list | first).files + | selectattr('os', 'equalto', 'linux') + | selectattr('arch', 'equalto', 'amd64') + | selectattr('kind', 'equalto', 'archive') + | list + | first + ) + }} + when: not ansible_check_mode + +- name: Determine whether Linux Go installation is required + ansible.builtin.set_fact: + cloud_dev_desktop_go_needs_install: "{{ (cloud_dev_desktop_go_release.version | default('')) not in (cloud_dev_desktop_go_version.stdout | default('')) }}" + when: not ansible_check_mode + +- name: Remove outdated Go toolchain + ansible.builtin.file: + path: /usr/local/go + state: absent + when: + - not ansible_check_mode + - cloud_dev_desktop_go_needs_install | default(false) + +- name: Install latest stable Go toolchain + ansible.builtin.unarchive: + src: "https://go.dev/dl/{{ cloud_dev_desktop_go_archive.filename }}" + dest: /usr/local + remote_src: true + when: + - not ansible_check_mode + - cloud_dev_desktop_go_needs_install | default(false) + +- name: Install Codex CLI globally on Linux + ansible.builtin.command: + cmd: npm install -g @openai/codex + when: + - toolchains.codex | bool + - not ansible_check_mode + +- name: Persist common Linux toolchain PATH + ansible.builtin.copy: + dest: "{{ cloud_dev_desktop_toolchain_profile_path }}" + mode: "0644" + content: | + export PATH="/opt/flutter/bin:/usr/local/go/bin:$PATH" + +- name: Verify Linux Node.js version + ansible.builtin.command: node --version + register: cloud_dev_desktop_node_verify + changed_when: false + when: not ansible_check_mode + +- name: Assert Linux Node.js major version is 22 or newer + ansible.builtin.assert: + that: + - (cloud_dev_desktop_node_verify.stdout | regex_replace('^v([0-9]+).*$','\\1') | int) >= (cloud_dev_desktop_node_major | int) + fail_msg: "Node.js 22+ is required on Linux cloud desktops." + when: not ansible_check_mode + +- name: Verify Linux Go toolchain + ansible.builtin.command: /usr/local/go/bin/go version + changed_when: false + when: not ansible_check_mode + +- name: Verify Linux Codex CLI + ansible.builtin.command: codex --version + changed_when: false + when: + - toolchains.codex | bool + - not ansible_check_mode diff --git a/roles/dev_desktop_common/tasks/ttl.yml b/roles/dev_desktop_common/tasks/ttl.yml new file mode 100644 index 0000000..210c95a --- /dev/null +++ b/roles/dev_desktop_common/tasks/ttl.yml @@ -0,0 +1,13 @@ +- name: Persist cloud desktop profile metadata + ansible.builtin.copy: + dest: "{{ cloud_dev_desktop_workspace_root }}/profile.env" + mode: "0644" + content: | + PROFILE_NAME={{ profile_name }} + PROVIDER={{ provider }} + OS_FAMILY={{ os_family }} + OWNER={{ owner }} + PURPOSE={{ purpose }} + CREATED_AT={{ vars.get('cloud_vm_created_at', '') }} + EXPIRES_AT={{ vars.get('cloud_vm_expires_at', '') }} + TTL_HOURS={{ ttl_hours }} diff --git a/roles/dev_desktop_common/tasks/users.yml b/roles/dev_desktop_common/tasks/users.yml new file mode 100644 index 0000000..4608452 --- /dev/null +++ b/roles/dev_desktop_common/tasks/users.yml @@ -0,0 +1,40 @@ +- name: Ensure common Linux packages are installed + ansible.builtin.package: + name: + - git + - curl + - wget + - unzip + - tar + - jq + - ca-certificates + state: present + +- name: Ensure admin user exists on Linux + ansible.builtin.user: + name: "{{ admin_username }}" + shell: "{{ cloud_dev_desktop_user_shell }}" + create_home: true + state: present + +- name: Ensure admin SSH directory exists on Linux + ansible.builtin.file: + path: "/home/{{ admin_username }}/.ssh" + state: directory + owner: "{{ admin_username }}" + group: "{{ admin_username }}" + mode: "0700" + +- name: Authorize additional SSH public keys for admin user + ansible.posix.authorized_key: + user: "{{ admin_username }}" + key: "{{ item }}" + state: present + loop: "{{ cloud_dev_desktop_extra_authorized_keys | default([]) }}" + when: (cloud_dev_desktop_extra_authorized_keys | default([])) | length > 0 + +- name: Ensure workspace root exists + ansible.builtin.file: + path: "{{ cloud_dev_desktop_workspace_root }}" + state: directory + mode: "0755" diff --git a/roles/dev_desktop_debian_kde/tasks/desktop.yml b/roles/dev_desktop_debian_kde/tasks/desktop.yml new file mode 100644 index 0000000..9af9a7d --- /dev/null +++ b/roles/dev_desktop_debian_kde/tasks/desktop.yml @@ -0,0 +1,17 @@ +- name: Install KDE desktop packages on Debian family + ansible.builtin.apt: + name: + - plasma-desktop + - sddm + - xrdp + - dbus-x11 + state: present + update_cache: true + +- name: Ensure SDDM is enabled + ansible.builtin.service: + name: sddm + state: started + enabled: true + when: not ansible_check_mode + diff --git a/roles/dev_desktop_debian_kde/tasks/flutter_qt.yml b/roles/dev_desktop_debian_kde/tasks/flutter_qt.yml new file mode 100644 index 0000000..36e155e --- /dev/null +++ b/roles/dev_desktop_debian_kde/tasks/flutter_qt.yml @@ -0,0 +1,90 @@ +- name: Install Debian/Ubuntu Flutter Qt dependencies with Qt 6 + block: + - name: Install Debian/Ubuntu Flutter Qt 6 dependencies + ansible.builtin.apt: + name: + - build-essential + - clang + - lld + - cmake + - ninja-build + - pkg-config + - libgtk-3-dev + - libsecret-1-dev + - xvfb + - qt6-base-dev + - qt6-base-dev-tools + - qt6-tools-dev-tools + - libgl1-mesa-dev + - unzip + state: present + update_cache: true + rescue: + - name: Install Debian/Ubuntu Flutter Qt 5 fallback dependencies + ansible.builtin.apt: + name: + - build-essential + - clang + - lld + - cmake + - ninja-build + - pkg-config + - libgtk-3-dev + - libsecret-1-dev + - xvfb + - qtbase5-dev + - qtchooser + - qt5-qmake + - libgl1-mesa-dev + - unzip + state: present + update_cache: true + +- name: Probe installed Flutter SDK on Debian family + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter --version" + register: debian_flutter_version_probe + changed_when: false + failed_when: false + +- name: Remove stale Flutter SDK on Debian family + ansible.builtin.file: + path: "{{ cloud_dev_desktop_flutter_install_root }}" + state: absent + when: + - not ansible_check_mode + - debian_flutter_version_probe.rc == 0 + - ("Flutter " ~ cloud_dev_desktop_flutter_version ~ " ") not in debian_flutter_version_probe.stdout + +- name: Install Flutter SDK on Debian family + ansible.builtin.unarchive: + src: "{{ flutter_sdk_url | default(cloud_dev_desktop_flutter_linux_sdk_url) }}" + dest: /opt + remote_src: true + creates: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter" + +- name: Ensure Debian/Ubuntu Flutter SDK is writable by the desktop user + ansible.builtin.file: + path: "{{ cloud_dev_desktop_flutter_install_root }}" + state: directory + owner: "{{ admin_username }}" + group: "{{ admin_username }}" + recurse: true + when: not ansible_check_mode + +- name: Enable Flutter Linux desktop on Debian family + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter config --enable-linux-desktop" + become_user: "{{ admin_username }}" + environment: + HOME: "/home/{{ admin_username }}" + PUB_CACHE: "/home/{{ admin_username }}/.pub-cache" + changed_when: false + when: not ansible_check_mode + +- name: Verify Flutter doctor on Debian family + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter doctor -v" + become_user: "{{ admin_username }}" + environment: + HOME: "/home/{{ admin_username }}" + PUB_CACHE: "/home/{{ admin_username }}/.pub-cache" + changed_when: false + when: not ansible_check_mode diff --git a/roles/dev_desktop_debian_kde/tasks/main.yml b/roles/dev_desktop_debian_kde/tasks/main.yml new file mode 100644 index 0000000..75377ba --- /dev/null +++ b/roles/dev_desktop_debian_kde/tasks/main.yml @@ -0,0 +1,12 @@ +- name: Install Azure CLI and Google Cloud CLI on Debian/Ubuntu + ansible.builtin.import_role: + name: cloud_cli_prereqs + +- name: Install Debian/Ubuntu KDE desktop + ansible.builtin.import_tasks: desktop.yml + +- name: Install Debian/Ubuntu Flutter Qt toolchain + ansible.builtin.import_tasks: flutter_qt.yml + +- name: Configure Debian/Ubuntu native remote desktop + ansible.builtin.import_tasks: remote_access.yml diff --git a/roles/dev_desktop_debian_kde/tasks/remote_access.yml b/roles/dev_desktop_debian_kde/tasks/remote_access.yml new file mode 100644 index 0000000..2242a95 --- /dev/null +++ b/roles/dev_desktop_debian_kde/tasks/remote_access.yml @@ -0,0 +1,7 @@ +- name: Ensure xrdp service is enabled for KDE hosts + ansible.builtin.service: + name: xrdp + state: started + enabled: true + when: not ansible_check_mode + diff --git a/roles/dev_desktop_fedora_gnome/tasks/desktop.yml b/roles/dev_desktop_fedora_gnome/tasks/desktop.yml new file mode 100644 index 0000000..8a119f9 --- /dev/null +++ b/roles/dev_desktop_fedora_gnome/tasks/desktop.yml @@ -0,0 +1,15 @@ +- name: Install Fedora GNOME desktop packages + ansible.builtin.dnf: + name: + - "@workstation-product-environment" + - gnome-remote-desktop + - gdm + state: present + +- name: Ensure GDM is enabled + ansible.builtin.service: + name: gdm + state: started + enabled: true + when: not ansible_check_mode + diff --git a/roles/dev_desktop_fedora_gnome/tasks/flutter_gtk.yml b/roles/dev_desktop_fedora_gnome/tasks/flutter_gtk.yml new file mode 100644 index 0000000..3afd1aa --- /dev/null +++ b/roles/dev_desktop_fedora_gnome/tasks/flutter_gtk.yml @@ -0,0 +1,69 @@ +- name: Install Fedora Flutter GTK dependencies + ansible.builtin.dnf: + name: + - gcc-c++ + - clang + - cmake + - ninja-build + - pkgconf-pkg-config + - gtk3-devel + - gtk4-devel + - glib2-devel + - gobject-introspection-devel + - libsecret-devel + - libblkid-devel + - xorg-x11-server-Xvfb + - xz-devel + - make + - patch + - unzip + state: present + +- name: Probe installed Flutter SDK on Fedora + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter --version" + register: fedora_flutter_version_probe + changed_when: false + failed_when: false + +- name: Remove stale Flutter SDK on Fedora + ansible.builtin.file: + path: "{{ cloud_dev_desktop_flutter_install_root }}" + state: absent + when: + - not ansible_check_mode + - fedora_flutter_version_probe.rc == 0 + - ("Flutter " ~ cloud_dev_desktop_flutter_version ~ " ") not in fedora_flutter_version_probe.stdout + +- name: Install Flutter SDK on Fedora + ansible.builtin.unarchive: + src: "{{ flutter_sdk_url | default(cloud_dev_desktop_flutter_linux_sdk_url) }}" + dest: /opt + remote_src: true + creates: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter" + +- name: Ensure Fedora Flutter SDK is writable by the desktop user + ansible.builtin.file: + path: "{{ cloud_dev_desktop_flutter_install_root }}" + state: directory + owner: "{{ admin_username }}" + group: "{{ admin_username }}" + recurse: true + when: not ansible_check_mode + +- name: Enable Flutter Linux desktop on Fedora + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter config --enable-linux-desktop" + become_user: "{{ admin_username }}" + environment: + HOME: "/home/{{ admin_username }}" + PUB_CACHE: "/home/{{ admin_username }}/.pub-cache" + changed_when: false + when: not ansible_check_mode + +- name: Verify Flutter doctor on Fedora + ansible.builtin.command: "{{ cloud_dev_desktop_flutter_install_root }}/bin/flutter doctor -v" + become_user: "{{ admin_username }}" + environment: + HOME: "/home/{{ admin_username }}" + PUB_CACHE: "/home/{{ admin_username }}/.pub-cache" + changed_when: false + when: not ansible_check_mode diff --git a/roles/dev_desktop_fedora_gnome/tasks/main.yml b/roles/dev_desktop_fedora_gnome/tasks/main.yml new file mode 100644 index 0000000..8dc0cd5 --- /dev/null +++ b/roles/dev_desktop_fedora_gnome/tasks/main.yml @@ -0,0 +1,12 @@ +- name: Install Azure CLI and Google Cloud CLI on Fedora + ansible.builtin.import_role: + name: cloud_cli_prereqs + +- name: Install Fedora GNOME desktop + ansible.builtin.import_tasks: desktop.yml + +- name: Install Fedora Flutter GTK toolchain + ansible.builtin.import_tasks: flutter_gtk.yml + +- name: Configure Fedora native remote desktop + ansible.builtin.import_tasks: remote_access.yml diff --git a/roles/dev_desktop_fedora_gnome/tasks/remote_access.yml b/roles/dev_desktop_fedora_gnome/tasks/remote_access.yml new file mode 100644 index 0000000..39c6da8 --- /dev/null +++ b/roles/dev_desktop_fedora_gnome/tasks/remote_access.yml @@ -0,0 +1,8 @@ +- name: Enable GNOME Remote Desktop services + ansible.builtin.service: + name: gnome-remote-desktop + enabled: true + state: started + failed_when: false + when: not ansible_check_mode + diff --git a/roles/dev_desktop_windows/defaults/main.yml b/roles/dev_desktop_windows/defaults/main.yml new file mode 100644 index 0000000..1f809b0 --- /dev/null +++ b/roles/dev_desktop_windows/defaults/main.yml @@ -0,0 +1,16 @@ +cloud_dev_desktop_windows_android_cmdline_tools_url: https://dl.google.com/android/repository/commandlinetools-win-13114758_latest.zip +cloud_dev_desktop_windows_android_sdk_api_level: "36" +cloud_dev_desktop_windows_android_build_tools_version: "36.0.0" +cloud_dev_desktop_windows_android_system_image: system-images;android-36;google_apis;x86_64 +cloud_dev_desktop_windows_android_avd_name: cloud-dev-desktop-api36 +cloud_dev_desktop_windows_android_optional_features: + - Microsoft-Hyper-V-All + - HypervisorPlatform + - VirtualMachinePlatform +cloud_dev_desktop_windows_flutter_version: "3.41.5" +cloud_dev_desktop_windows_flutter_install_root: C:\tools\flutter +cloud_dev_desktop_windows_flutter_sdk_url: "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_{{ cloud_dev_desktop_windows_flutter_version }}-stable.zip" +cloud_dev_desktop_windows_visualstudio_packages: + - visualstudio2022buildtools + - visualstudio2022-workload-vctools + - visualstudio2022-workload-nativedesktop diff --git a/roles/dev_desktop_windows/tasks/main.yml b/roles/dev_desktop_windows/tasks/main.yml new file mode 100644 index 0000000..0778b14 --- /dev/null +++ b/roles/dev_desktop_windows/tasks/main.yml @@ -0,0 +1,13 @@ +- name: Install Azure CLI and Google Cloud CLI on Windows + ansible.builtin.import_role: + name: cloud_cli_prereqs + +- name: Install Windows desktop toolchain + ansible.builtin.import_tasks: tools.yml + +- name: Configure Windows SSH client for peer Linux desktops + ansible.builtin.import_tasks: ssh_client.yml + when: windows_ssh_private_key_b64 is defined and windows_ssh_config_b64 is defined + +- name: Configure Windows remote desktop access + ansible.builtin.import_tasks: remote_access.yml diff --git a/roles/dev_desktop_windows/tasks/remote_access.yml b/roles/dev_desktop_windows/tasks/remote_access.yml new file mode 100644 index 0000000..66efbd5 --- /dev/null +++ b/roles/dev_desktop_windows/tasks/remote_access.yml @@ -0,0 +1,45 @@ +- name: Enable Windows remote desktop + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + $sshdCapability = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*' + if ($sshdCapability.State -ne 'Installed') { + Add-WindowsCapability -Online -Name $sshdCapability.Name | Out-Null + } + Set-Service -Name sshd -StartupType Automatic + if ((Get-Service sshd).Status -ne 'Running') { + Start-Service sshd + } + if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null + } + else { + Enable-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' + } + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -Value 0 + Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' + $profiles = Get-NetConnectionProfile + foreach ($profile in $profiles) { + if ($profile.NetworkCategory -ne 'Private') { + Set-NetConnectionProfile -InterfaceIndex $profile.InterfaceIndex -NetworkCategory Private + } + } + winrm quickconfig -q + Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $true + Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $true + changed_when: true + +- name: Authorize administrator SSH public keys on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $authorizedKeysPath = 'C:\ProgramData\ssh\administrators_authorized_keys' + New-Item -ItemType Directory -Force -Path 'C:\ProgramData\ssh' | Out-Null + $authorizedKeys = @' + {{ (cloud_dev_desktop_extra_authorized_keys | default([])) | join('\r\n') }} + '@ + $authorizedKeys = $authorizedKeys.Trim() + Set-Content -Path $authorizedKeysPath -Encoding ascii -Value $authorizedKeys + icacls $authorizedKeysPath /inheritance:r | Out-Null + icacls $authorizedKeysPath /grant 'Administrators:F' | Out-Null + icacls $authorizedKeysPath /grant 'SYSTEM:F' | Out-Null + changed_when: true + when: (cloud_dev_desktop_extra_authorized_keys | default([])) | length > 0 diff --git a/roles/dev_desktop_windows/tasks/ssh_client.yml b/roles/dev_desktop_windows/tasks/ssh_client.yml new file mode 100644 index 0000000..940a042 --- /dev/null +++ b/roles/dev_desktop_windows/tasks/ssh_client.yml @@ -0,0 +1,28 @@ +- name: Ensure OpenSSH client is installed on Windows + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + $capability = Get-WindowsCapability -Online -Name OpenSSH.Client* + if ($capability.State -ne 'Installed') { + Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 | Out-Null + } + changed_when: true + +- name: Materialize Windows SSH key and config for peer Linux desktops + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + $sshDir = Join-Path $env:USERPROFILE '.ssh' + $keyPath = Join-Path $sshDir '{{ windows_ssh_identity_filename | default("cloud-dev-desktop-fleet") }}' + $pubPath = "${keyPath}.pub" + $configPath = Join-Path $sshDir 'config' + + New-Item -ItemType Directory -Path $sshDir -Force | Out-Null + + [System.IO.File]::WriteAllBytes($keyPath, [System.Convert]::FromBase64String('{{ windows_ssh_private_key_b64 }}')) + [System.IO.File]::WriteAllBytes($pubPath, [System.Convert]::FromBase64String('{{ windows_ssh_public_key_b64 }}')) + [System.IO.File]::WriteAllText($configPath, [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('{{ windows_ssh_config_b64 }}')), [System.Text.Encoding]::ASCII) + + icacls $sshDir /inheritance:r /grant:r "${env:USERNAME}:(OI)(CI)F" | Out-Null + icacls $keyPath /inheritance:r /grant:r "${env:USERNAME}:F" | Out-Null + icacls $pubPath /inheritance:r /grant:r "${env:USERNAME}:F" | Out-Null + icacls $configPath /inheritance:r /grant:r "${env:USERNAME}:F" | Out-Null + changed_when: true diff --git a/roles/dev_desktop_windows/tasks/tools.yml b/roles/dev_desktop_windows/tasks/tools.yml new file mode 100644 index 0000000..87d9611 --- /dev/null +++ b/roles/dev_desktop_windows/tasks/tools.yml @@ -0,0 +1,249 @@ +- name: Install Chocolatey + ansible.builtin.raw: | + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } + changed_when: true + +- name: Install Windows toolchain packages + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $packages = @() + $packages += 'git' + if ({{ toolchains.vscode | bool | ternary('$true', '$false') }}) { $packages += 'vscode' } + if ({{ toolchains.android_studio | bool | ternary('$true', '$false') }}) { $packages += 'androidstudio' } + if ({{ toolchains.codex | bool | ternary('$true', '$false') }}) { + $packages += 'vcredist140' + $packages += 'nodejs-lts' + } + if ({{ toolchains.flutter | bool | ternary('$true', '$false') }}) { + $packages += @({{ cloud_dev_desktop_windows_visualstudio_packages | map('to_json') | join(', ') }}) + } + if (({{ toolchains.dart | bool | ternary('$true', '$false') }}) -and (-not {{ toolchains.flutter | bool | ternary('$true', '$false') }})) { + $packages += 'dart-sdk' + } + foreach ($pkg in ($packages | Select-Object -Unique)) { + choco install $pkg -y --no-progress + } + changed_when: true + +- name: Install Codex CLI globally on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + npm install -g @openai/codex + changed_when: true + when: toolchains.codex | bool + +- name: Enable Windows virtualization features required for Android emulators + ansible.windows.win_optional_feature: + name: "{{ cloud_dev_desktop_windows_android_optional_features }}" + state: present + include_parent: true + register: windows_android_virtualization_features + when: toolchains.android_studio | bool + +- name: Reboot Windows after enabling Android virtualization features + ansible.windows.win_reboot: + reboot_timeout: 3600 + connect_timeout: 30 + post_reboot_delay: 60 + when: + - toolchains.android_studio | bool + - windows_android_virtualization_features.reboot_required | default(false) + +- name: Install Android SDK command-line tools on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $androidSdkRoot = Join-Path $env:LOCALAPPDATA 'Android\Sdk' + $cmdlineToolsRoot = Join-Path $androidSdkRoot 'cmdline-tools' + $cmdlineToolsLatest = Join-Path $cmdlineToolsRoot 'latest' + $cmdlineToolsBin = Join-Path $cmdlineToolsLatest 'bin' + $javaHome = 'C:\Program Files\Android\Android Studio\jbr' + New-Item -ItemType Directory -Force -Path $androidSdkRoot, $cmdlineToolsRoot | Out-Null + if (-not (Test-Path (Join-Path $cmdlineToolsBin 'sdkmanager.bat'))) { + $zipPath = Join-Path $env:TEMP 'android-commandlinetools.zip' + $extractDir = Join-Path $env:TEMP 'android-commandlinetools' + if (Test-Path $extractDir) { + Remove-Item -Recurse -Force $extractDir + } + Invoke-WebRequest -UseBasicParsing -Uri '{{ cloud_dev_desktop_windows_android_cmdline_tools_url }}' -OutFile $zipPath + Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force + if (Test-Path $cmdlineToolsLatest) { + Remove-Item -Recurse -Force $cmdlineToolsLatest + } + Move-Item -Path (Join-Path $extractDir 'cmdline-tools') -Destination $cmdlineToolsLatest + } + if (Test-Path $javaHome) { + [Environment]::SetEnvironmentVariable('JAVA_HOME', $javaHome, 'User') + $env:JAVA_HOME = $javaHome + } + [Environment]::SetEnvironmentVariable('ANDROID_HOME', $androidSdkRoot, 'User') + [Environment]::SetEnvironmentVariable('ANDROID_SDK_ROOT', $androidSdkRoot, 'User') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $androidPathEntries = @( + $androidSdkRoot, + (Join-Path $androidSdkRoot 'platform-tools'), + (Join-Path $androidSdkRoot 'emulator'), + $cmdlineToolsBin + ) + if ($env:JAVA_HOME) { + $androidPathEntries += (Join-Path $env:JAVA_HOME 'bin') + } + $userPathParts = @($userPath -split ';') | Where-Object { $_ } + foreach ($entry in $androidPathEntries) { + if (-not ($userPathParts -contains $entry)) { + $userPathParts += $entry + } + } + $newUserPath = ($userPathParts | Select-Object -Unique) -join ';' + [Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User') + $env:ANDROID_HOME = $androidSdkRoot + $env:ANDROID_SDK_ROOT = $androidSdkRoot + $env:Path = ([Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + $newUserPath + ';C:\ProgramData\chocolatey\bin') + $sdkmanager = Join-Path $cmdlineToolsBin 'sdkmanager.bat' + $sdkPackages = @( + 'platform-tools', + 'platforms;android-{{ cloud_dev_desktop_windows_android_sdk_api_level }}', + 'build-tools;{{ cloud_dev_desktop_windows_android_build_tools_version }}', + 'emulator', + '{{ cloud_dev_desktop_windows_android_system_image }}' + ) + 1..32 | ForEach-Object { 'y' } | & $sdkmanager --sdk_root="$androidSdkRoot" --licenses | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Android SDK license acceptance failed with exit code $LASTEXITCODE" + } + & $sdkmanager --sdk_root="$androidSdkRoot" @sdkPackages + if ($LASTEXITCODE -ne 0) { + throw "Android SDK package installation failed with exit code $LASTEXITCODE" + } + $avdmanager = Join-Path $cmdlineToolsBin 'avdmanager.bat' + $avdName = '{{ cloud_dev_desktop_windows_android_avd_name }}' + $avdPath = Join-Path $env:USERPROFILE ".android\avd\$avdName.avd" + if (-not (Test-Path $avdPath)) { + 'no' | & $avdmanager create avd --force -n $avdName -k '{{ cloud_dev_desktop_windows_android_system_image }}' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Android AVD creation failed with exit code $LASTEXITCODE" + } + } + changed_when: true + when: toolchains.android_studio | bool + +- name: Probe installed Flutter SDK on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $flutterBat = Join-Path '{{ cloud_dev_desktop_windows_flutter_install_root }}' 'bin\flutter.bat' + if (Test-Path $flutterBat) { + & $flutterBat --version + } + register: windows_flutter_version_probe + changed_when: false + failed_when: false + when: toolchains.flutter | bool + +- name: Install Flutter SDK on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $flutterRoot = '{{ cloud_dev_desktop_windows_flutter_install_root }}' + $installRoot = Split-Path -Parent $flutterRoot + $zipPath = Join-Path $env:TEMP 'flutter-windows-sdk.zip' + New-Item -ItemType Directory -Force -Path $installRoot | Out-Null + if (Test-Path $flutterRoot) { + Remove-Item -Recurse -Force $flutterRoot + } + Invoke-WebRequest -UseBasicParsing -Uri '{{ cloud_dev_desktop_windows_flutter_sdk_url }}' -OutFile $zipPath + Expand-Archive -LiteralPath $zipPath -DestinationPath $installRoot -Force + if (-not (Test-Path (Join-Path $flutterRoot 'bin\flutter.bat'))) { + throw "Flutter SDK install failed: missing $(Join-Path $flutterRoot 'bin\flutter.bat')" + } + changed_when: true + when: + - toolchains.flutter | bool + - windows_flutter_version_probe.rc != 0 or ("Flutter " ~ cloud_dev_desktop_windows_flutter_version ~ " ") not in windows_flutter_version_probe.stdout + +- name: Configure Flutter on Windows + ansible.windows.win_shell: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + '{{ cloud_dev_desktop_windows_flutter_install_root }}\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + if ({{ toolchains.android_studio | bool | ternary('$true', '$false') }}) { + flutter config --android-sdk (Join-Path $env:LOCALAPPDATA 'Android\Sdk') + } + flutter config --enable-windows-desktop + flutter doctor + changed_when: true + when: toolchains.flutter | bool + +- name: Verify Windows desktop toolchain versions + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $npmUserBin = if ($env:APPDATA) { Join-Path $env:APPDATA 'npm' } else { '' } + $extraPaths = @( + $npmUserBin, + 'C:\Program Files\nodejs', + '{{ cloud_dev_desktop_windows_flutter_install_root }}\bin', + 'C:\Program Files\Microsoft VS Code\bin', + 'C:\ProgramData\chocolatey\bin' + ) | Where-Object { $_ } + $env:Path = (($machinePath, $userPath, ($extraPaths -join ';')) -join ';') + $nodeMajor = [int]((node --version).Trim().TrimStart('v').Split('.')[0]) + if ($nodeMajor -lt 22) { + throw "Node.js 22+ is required, found $(node --version)" + } + if ({{ toolchains.codex | bool | ternary('$true', '$false') }}) { + $codexCmd = Join-Path $env:APPDATA 'npm\codex.cmd' + if (-not (Test-Path $codexCmd)) { + throw "Missing Codex CLI launcher at $codexCmd" + } + $codexCmdLine = '"' + $codexCmd + '" --version' + cmd.exe /d /c $codexCmdLine | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Codex CLI version probe failed with exit code $LASTEXITCODE" + } + } + if ({{ toolchains.vscode | bool | ternary('$true', '$false') }}) { + Get-Command code | Out-Null + } + if ({{ toolchains.android_studio | bool | ternary('$true', '$false') }}) { + if (-not (Test-Path 'C:\Program Files\Android\Android Studio\bin\studio64.exe')) { + throw 'Missing Android Studio executable at C:\Program Files\Android\Android Studio\bin\studio64.exe' + } + } + if ({{ toolchains.flutter | bool | ternary('$true', '$false') }}) { + flutter --version | Out-Null + } + changed_when: false + +- name: Create Windows desktop metadata directory + ansible.builtin.raw: | + $ErrorActionPreference = "Stop" + New-Item -ItemType Directory -Force -Path 'C:\cloud-dev-desktop' | Out-Null + @" + PROFILE_NAME={{ profile_name }} + PROVIDER={{ provider }} + OWNER={{ owner }} + PURPOSE={{ purpose }} + EXPIRES_AT={{ vars.get('cloud_vm_expires_at', '') }} + "@ | Set-Content -Encoding ASCII 'C:\cloud-dev-desktop\profile.env' + changed_when: true diff --git a/roles/gcp_dev_desktop_lifecycle/tasks/create.yml b/roles/gcp_dev_desktop_lifecycle/tasks/create.yml new file mode 100644 index 0000000..3d29af7 --- /dev/null +++ b/roles/gcp_dev_desktop_lifecycle/tasks/create.yml @@ -0,0 +1,149 @@ +- name: Preview GCP create request + ansible.builtin.debug: + msg: + - "gcp_project_id={{ gcp_project_id }}" + - "gcp_zone={{ gcp_zone }}" + - "gcp_vm_name={{ gcp_vm_name }}" + - "allowed_cidrs={{ allowed_cidrs | join(',') }}" + - "allowed_tcp_ports={{ allowed_tcp_ports | join(',') }}" + +- name: Ensure GCP network exists + ansible.builtin.command: + argv: + - gcloud + - compute + - networks + - create + - "{{ gcp_network }}" + - --project + - "{{ gcp_project_id }}" + - --subnet-mode + - custom + changed_when: true + failed_when: false + when: not ansible_check_mode + +- name: Ensure GCP subnet exists + ansible.builtin.command: + argv: + - gcloud + - compute + - networks + - subnets + - create + - "{{ gcp_subnet }}" + - --project + - "{{ gcp_project_id }}" + - --network + - "{{ gcp_network }}" + - --region + - "{{ gcp_region }}" + - --range + - "{{ gcp_subnet_cidr | default('10.52.1.0/24') }}" + changed_when: true + failed_when: false + when: not ansible_check_mode + +- name: Ensure GCP firewall rules exist + ansible.builtin.command: + argv: + - gcloud + - compute + - firewall-rules + - create + - "{{ gcp_firewall_rule }}-{{ port }}-{{ index }}" + - --project + - "{{ gcp_project_id }}" + - --network + - "{{ gcp_network }}" + - --allow + - "tcp:{{ port }}" + - --source-ranges + - "{{ cidr }}" + - --target-tags + - "{{ cloud_vm_profile_slug }}" + loop: "{{ allowed_cidrs | product(allowed_tcp_ports) | list }}" + loop_control: + label: "{{ item.0 }} -> {{ item.1 }}" + index_var: index + changed_when: true + failed_when: false + when: not ansible_check_mode + vars: + cidr: "{{ item.0 }}" + port: "{{ item.1 }}" + +- name: Build GCP create command + ansible.builtin.set_fact: + gcp_vm_create_command: >- + gcloud compute instances create {{ gcp_vm_name | quote }} + --project={{ gcp_project_id | quote }} + --zone={{ gcp_zone | quote }} + --machine-type={{ vm_size | quote }} + --boot-disk-size={{ (disk_gb | string) ~ 'GB' | quote }} + --network={{ gcp_network | quote }} + --subnet={{ gcp_subnet | quote }} + --tags={{ cloud_vm_profile_slug | quote }} + --labels={{ gcp_labels | dict2items | map('join', '=') | join(',') | quote }} + --image-family={{ gcp_image_family | quote }} + --image-project={{ gcp_image_project | quote }} + {% if os_family != 'windows' %} + --metadata=ssh-keys='{{ admin_username }}:{{ lookup("file", ssh_public_key_path) | trim }}' + {% endif %} + +- name: Create GCP VM + ansible.builtin.shell: "{{ gcp_vm_create_command }}" + args: + executable: /bin/bash + changed_when: true + when: not ansible_check_mode + +- name: Reset GCP Windows password when needed + ansible.builtin.command: + argv: + - gcloud + - compute + - reset-windows-password + - "{{ gcp_vm_name }}" + - --project + - "{{ gcp_project_id }}" + - --zone + - "{{ gcp_zone }}" + - --user + - "{{ admin_username }}" + - --quiet + changed_when: true + when: + - os_family == "windows" + - not ansible_check_mode + +- name: Fetch GCP VM networking facts + ansible.builtin.command: + argv: + - gcloud + - compute + - instances + - describe + - "{{ gcp_vm_name }}" + - --project + - "{{ gcp_project_id }}" + - --zone + - "{{ gcp_zone }}" + - --format=json + register: gcp_vm_json + changed_when: false + when: not ansible_check_mode + +- name: Set GCP VM connection facts + ansible.builtin.set_fact: + cloud_vm_public_ip: "{{ ((gcp_vm_json.stdout | from_json).networkInterfaces[0].accessConfigs | default([]) | first).natIP | default('198.51.100.11') }}" + cloud_vm_private_ip: "{{ (gcp_vm_json.stdout | from_json).networkInterfaces[0].networkIP | default('10.52.1.10') }}" + cloud_vm_admin_user: "{{ admin_username }}" + when: not ansible_check_mode + +- name: Set GCP dry-run connection facts + ansible.builtin.set_fact: + cloud_vm_public_ip: "{{ cloud_vm_public_ip | default('198.51.100.11') }}" + cloud_vm_private_ip: "{{ cloud_vm_private_ip | default('10.52.1.10') }}" + cloud_vm_admin_user: "{{ admin_username }}" + when: ansible_check_mode diff --git a/roles/gcp_dev_desktop_lifecycle/tasks/destroy.yml b/roles/gcp_dev_desktop_lifecycle/tasks/destroy.yml new file mode 100644 index 0000000..c556fad --- /dev/null +++ b/roles/gcp_dev_desktop_lifecycle/tasks/destroy.yml @@ -0,0 +1,91 @@ +- name: Preview GCP destroy/cleanup request + ansible.builtin.debug: + msg: + - "gcp_project_id={{ gcp_project_id | default('n/a') }}" + - "gcp_vm_name={{ gcp_vm_name | default(profile_name | default('n/a')) }}" + - "gcp_cleanup_mode={{ gcp_cleanup_mode | default(false) }}" + - "cloud_vm_destroy_mode={{ cloud_vm_destroy_mode | default('destroy') }}" + +- name: List GCP managed instances + ansible.builtin.command: + argv: + - gcloud + - compute + - instances + - list + - --project + - "{{ gcp_project_id }}" + - --filter + - "labels.toolkit_scope=cloud-dev-desktop AND labels.managed_by=ansible" + - --format=value(name,zone.basename()) + register: gcp_managed_instances + changed_when: false + when: + - gcp_cleanup_mode | default(false) + - not ansible_check_mode + +- name: Park GCP VM in lowest-consumption mode + ansible.builtin.command: + argv: + - gcloud + - compute + - instances + - stop + - "{{ gcp_vm_name }}" + - --project + - "{{ gcp_project_id }}" + - --zone + - "{{ gcp_zone }}" + - --quiet + changed_when: true + when: + - not gcp_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'park' + - not ansible_check_mode + +- name: Delete GCP VM directly + ansible.builtin.command: + argv: + - gcloud + - compute + - instances + - delete + - "{{ gcp_vm_name }}" + - --project + - "{{ gcp_project_id }}" + - --zone + - "{{ gcp_zone }}" + - --quiet + changed_when: true + when: + - not gcp_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'destroy' + - not ansible_check_mode + +- name: Delete GCP managed instances + ansible.builtin.command: + argv: + - gcloud + - compute + - instances + - delete + - "{{ item.split()[0] }}" + - --project + - "{{ gcp_project_id }}" + - --zone + - "{{ item.split()[1] }}" + - --quiet + loop: "{{ gcp_managed_instances.stdout_lines | default([]) }}" + changed_when: true + when: + - gcp_cleanup_mode | default(false) + - not ansible_check_mode + +- name: Remove GCP state file after destroy + ansible.builtin.file: + path: "{{ cloud_vm_state_file }}" + state: absent + when: + - cloud_vm_state_file is defined + - not gcp_cleanup_mode | default(false) + - (cloud_vm_destroy_mode | default('destroy')) == 'destroy' diff --git a/roles/gcp_dev_desktop_lifecycle/tasks/facts.yml b/roles/gcp_dev_desktop_lifecycle/tasks/facts.yml new file mode 100644 index 0000000..60d359c --- /dev/null +++ b/roles/gcp_dev_desktop_lifecycle/tasks/facts.yml @@ -0,0 +1,50 @@ +- name: Normalize GCP desktop resource names + ansible.builtin.set_fact: + gcp_project_id: "{{ gcp_project_id | default(lookup('env', 'GCP_PROJECT_ID')) }}" + gcp_zone: "{{ zone | default(gcp_zone | default('asia-northeast1-a')) }}" + gcp_network: "{{ gcp_network | default('devdesktop-' ~ cloud_vm_owner_slug) }}" + gcp_subnet: "{{ gcp_subnet | default('devdesktop-' ~ cloud_vm_owner_slug ~ '-subnet') }}" + gcp_firewall_rule: "{{ gcp_firewall_rule | default('allow-' ~ cloud_vm_profile_slug) }}" + gcp_vm_name: "{{ gcp_vm_name | default('vm-' ~ cloud_vm_profile_slug) }}" + gcp_labels: "{{ tags | default({}) }}" + cloud_vm_platform: "gcp" + +- name: Derive GCP region from zone + ansible.builtin.set_fact: + gcp_region: "{{ gcp_zone.rsplit('-', 1)[0] }}" + +- name: Assert GCP project id is available when requested + ansible.builtin.assert: + that: + - gcp_project_id | length > 0 + fail_msg: "GCP_PROJECT_ID or gcp_project_id is required for GCP operations." + when: not ansible_check_mode + +- name: Select GCP image defaults by OS family + ansible.builtin.set_fact: + gcp_image_project: >- + {{ + image_project + | default( + gcp_image_project + | default( + { + 'windows': 'windows-cloud', + 'fedora-gnome': 'fedora-cloud', + 'debian-kde': 'debian-cloud' + }[os_family] + ) + ) + }} + gcp_image_family: >- + {{ + image_family + | default( + { + 'windows': 'windows-11', + 'fedora-gnome': 'fedora-cloud-43', + 'debian-kde': 'debian-13' + }[os_family] + ) + }} + gcp_windows_password: "{{ gcp_windows_password | default(lookup('env', 'GCP_WINDOWS_ADMIN_PASSWORD')) }}" diff --git a/roles/gcp_dev_desktop_lifecycle/tasks/main.yml b/roles/gcp_dev_desktop_lifecycle/tasks/main.yml new file mode 100644 index 0000000..a057278 --- /dev/null +++ b/roles/gcp_dev_desktop_lifecycle/tasks/main.yml @@ -0,0 +1,16 @@ +- name: Gather GCP lifecycle facts + ansible.builtin.include_tasks: facts.yml + +- name: Run GCP create flow + ansible.builtin.include_tasks: create.yml + when: cloud_lifecycle_action == "create" + +- name: Run GCP destroy flow + ansible.builtin.include_tasks: destroy.yml + when: cloud_lifecycle_action == "destroy" + +- name: Run GCP cleanup flow + ansible.builtin.include_tasks: destroy.yml + vars: + gcp_cleanup_mode: true + when: cloud_lifecycle_action == "cleanup" diff --git a/roles/vhosts/agent-svc-plus/defaults/main.yml b/roles/vhosts/agent-svc-plus/defaults/main.yml index 874bfa8..6948bf2 100644 --- a/roles/vhosts/agent-svc-plus/defaults/main.yml +++ b/roles/vhosts/agent-svc-plus/defaults/main.yml @@ -4,14 +4,22 @@ agent_svc_plus_repo_url: "https://github.com/x-evor/agent.svc.plus.git" agent_svc_plus_repo_version: "main" agent_svc_plus_app_dir: "/opt/agent.svc.plus" +agent_svc_plus_manage_source_checkout: false +agent_svc_plus_binary_src: "" +agent_svc_plus_release_repo: "x-evor/agent.svc.plus" +agent_svc_plus_release_tag: "" +agent_svc_plus_release_base_url: "https://github.com/{{ agent_svc_plus_release_repo }}/releases/download" agent_svc_plus_go_version: "1.25.1" agent_svc_plus_go_root: "/usr/local/go" agent_svc_plus_go_bin: "{{ agent_svc_plus_go_root }}/bin/go" +agent_svc_plus_build_on_target: false agent_svc_plus_binary_name: "agent-svc-plus" agent_svc_plus_binary_path: "/usr/local/bin/{{ agent_svc_plus_binary_name }}" agent_svc_plus_build_target: "./cmd/agent" +agent_svc_plus_release_asset_name: "{{ agent_svc_plus_binary_name }}-linux-{{ agent_svc_plus_release_arch }}" +agent_svc_plus_release_asset_url: "{{ agent_svc_plus_release_base_url }}/{{ agent_svc_plus_release_tag }}/{{ agent_svc_plus_release_asset_name }}" agent_svc_plus_service_name: "agent-svc-plus" agent_svc_plus_service_description: "agent.svc.plus service" diff --git a/roles/vhosts/agent-svc-plus/tasks/main.yml b/roles/vhosts/agent-svc-plus/tasks/main.yml index 11aaf47..517b783 100644 --- a/roles/vhosts/agent-svc-plus/tasks/main.yml +++ b/roles/vhosts/agent-svc-plus/tasks/main.yml @@ -3,6 +3,15 @@ ansible.builtin.set_fact: agent_svc_plus_go_arch: >- {{ 'arm64' if ansible_architecture in ['aarch64', 'arm64'] else 'amd64' }} + agent_svc_plus_release_arch: >- + {{ 'arm64' if ansible_architecture in ['aarch64', 'arm64'] else 'amd64' }} + when: agent_svc_plus_build_on_target | bool + +- name: Map release artifact architecture when build is disabled + ansible.builtin.set_fact: + agent_svc_plus_release_arch: >- + {{ 'arm64' if ansible_architecture in ['aarch64', 'arm64'] else 'amd64' }} + when: not (agent_svc_plus_build_on_target | bool) - name: Ensure agent build prerequisites are installed ansible.builtin.apt: @@ -13,32 +22,40 @@ - tar state: present update_cache: true + when: agent_svc_plus_build_on_target | bool - name: Check installed Go version ansible.builtin.command: "{{ agent_svc_plus_go_bin }} version" register: agent_svc_plus_go_version_check changed_when: false failed_when: false + when: agent_svc_plus_build_on_target | bool - name: Download Go toolchain archive ansible.builtin.get_url: url: "https://go.dev/dl/go{{ agent_svc_plus_go_version }}.linux-{{ agent_svc_plus_go_arch }}.tar.gz" dest: "/tmp/go{{ agent_svc_plus_go_version }}.linux-{{ agent_svc_plus_go_arch }}.tar.gz" mode: "0644" - when: agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout + when: + - agent_svc_plus_build_on_target | bool + - agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout - name: Remove previous Go installation ansible.builtin.file: path: "{{ agent_svc_plus_go_root }}" state: absent - when: agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout + when: + - agent_svc_plus_build_on_target | bool + - agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout - name: Install Go toolchain ansible.builtin.unarchive: src: "/tmp/go{{ agent_svc_plus_go_version }}.linux-{{ agent_svc_plus_go_arch }}.tar.gz" dest: "/usr/local" remote_src: true - when: agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout + when: + - agent_svc_plus_build_on_target | bool + - agent_svc_plus_go_version_check.rc != 0 or ('go' ~ agent_svc_plus_go_version) not in agent_svc_plus_go_version_check.stdout - name: Create agent directories ansible.builtin.file: @@ -60,6 +77,7 @@ version: "{{ agent_svc_plus_repo_version }}" update: true notify: Restart agent-svc-plus + when: agent_svc_plus_manage_source_checkout | bool - name: Build agent.svc.plus binary ansible.builtin.command: >- @@ -71,6 +89,50 @@ PATH: "{{ agent_svc_plus_go_root }}/bin:{{ ansible_env.PATH }}" GOTOOLCHAIN: local notify: Restart agent-svc-plus + when: agent_svc_plus_build_on_target | bool + +- name: Deploy prebuilt agent binary from control machine + ansible.builtin.copy: + src: "{{ agent_svc_plus_binary_src }}" + dest: "{{ agent_svc_plus_binary_path }}" + owner: "{{ agent_svc_plus_user }}" + group: "{{ agent_svc_plus_group }}" + mode: "0755" + notify: Restart agent-svc-plus + when: + - not (agent_svc_plus_build_on_target | bool) + - agent_svc_plus_binary_src | length > 0 + +- name: Download prebuilt agent binary from GitHub Release + ansible.builtin.get_url: + url: "{{ agent_svc_plus_release_asset_url }}" + dest: "{{ agent_svc_plus_binary_path }}" + owner: "{{ agent_svc_plus_user }}" + group: "{{ agent_svc_plus_group }}" + mode: "0755" + notify: Restart agent-svc-plus + when: + - not (agent_svc_plus_build_on_target | bool) + - agent_svc_plus_binary_src | length == 0 + - agent_svc_plus_release_tag | length > 0 + +- name: Check existing agent binary on target host + ansible.builtin.stat: + path: "{{ agent_svc_plus_binary_path }}" + register: agent_svc_plus_binary_stat + +- name: Assert target host already has agent binary when build is disabled + ansible.builtin.assert: + that: + - agent_svc_plus_binary_stat.stat.exists + - not agent_svc_plus_binary_stat.stat.isdir + fail_msg: >- + {{ agent_svc_plus_binary_path }} does not exist on the target host. + This role is configured to avoid clone/build on target. Provide + agent_svc_plus_binary_src, or set agent_svc_plus_release_tag so the role + can download {{ agent_svc_plus_release_asset_name }}, or enable + agent_svc_plus_build_on_target. + when: not (agent_svc_plus_build_on_target | bool) - name: Ensure agent binary permissions ansible.builtin.file: diff --git a/roles/vhosts/xray-exporter/defaults/main.yml b/roles/vhosts/xray-exporter/defaults/main.yml index 4e06bb7..a9a8429 100644 --- a/roles/vhosts/xray-exporter/defaults/main.yml +++ b/roles/vhosts/xray-exporter/defaults/main.yml @@ -15,7 +15,7 @@ xray_exporter_env_path: "{{ xray_exporter_env_dir }}/xray-exporter" xray_exporter_listen_addr: "127.0.0.1:8080" xray_exporter_scrape_interval: "1m" -xray_exporter_node_id: "node-xhttp.svc.plus" +xray_exporter_node_id: "{{ xray_exporter_node_id_custom | default(inventory_hostname, true) }}" xray_exporter_env_name: "prod" xray_exporter_stats_url: "http://127.0.0.1:49227/debug/vars" xray_exporter_stats_token: ""