feat(playbooks): add cloud desktop bootstrap flow

This commit is contained in:
Haitao Pan 2026-04-10 17:09:59 +08:00
parent 19e1f4ef1d
commit e7d9140b86
47 changed files with 2540 additions and 74 deletions

View File

@ -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

View File

@ -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"

View File

@ -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) }}

2
deploy_apisix.yml Normal file
View File

@ -0,0 +1,2 @@
---
- import_playbook: deploy_apisix_svc.plus.yaml

View File

@ -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

View File

@ -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)"

View File

@ -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) }}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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'

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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') }}"

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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 %}

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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')) }}"

View File

@ -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"

View File

@ -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"

View File

@ -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:

View File

@ -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: ""