From 59b2c10c434ea84f4624ef1d570b8c940389588d Mon Sep 17 00:00:00 2001 From: Alexandre Pires Date: Fri, 10 Jul 2026 15:44:18 +0200 Subject: [PATCH] Add Linux development station role with systemd services for code-server and OpenCode - Implemented handlers for restarting code-server and OpenCode services. - Created main tasks for setting up a Linux development station, including user management, package installation, and configuration. - Added validation for OpenCode server credentials and code-server password. - Introduced tool package management for npm, uv, and go installers. - Developed templates for systemd service units for code-server and OpenCode. - Included scripts for starting code-server and OpenCode with SSH agent support. - Updated inventory and Terraform configuration to include new development host. --- ansible/ansible.cfg | 1 + ansible/host_vars/dev-02.lab.alexpires.me.yml | 167 +++++++ ansible/host_vars/gpu-01.lab.alexpires.me.yml | 67 ++- ansible/host_vars/mac-01.lab.alexpires.me.yml | 2 + ansible/playbook_linux_dev_station.yml | 14 + ansible/roles/cis/defaults/main/logind.yml | 1 + ansible/roles/cis/tasks/logindconf.yml | 3 +- .../cis/templates/etc/systemd/logind.conf.j2 | 3 + .../roles/linux_dev_station/defaults/main.yml | 103 ++++ .../files/install-copilot.sh | 164 +++++++ .../roles/linux_dev_station/handlers/main.yml | 24 + .../roles/linux_dev_station/tasks/main.yml | 452 ++++++++++++++++++ .../tasks/mcp_installers.yml | 138 ++++++ .../linux_dev_station/tasks/tool_packages.yml | 268 +++++++++++ .../templates/code-server.service.j2 | 24 + .../templates/opencode.json.j2 | 1 + .../templates/opencode.service.j2 | 28 ++ .../templates/ssh-agent-start.sh.j2 | 33 ++ .../templates/ssh-agent.service.j2 | 17 + .../templates/start-ide.sh.j2 | 15 + .../templates/start-opencode.sh.j2 | 11 + .../files/scripts/linux/llama_hf_sync.py | 2 + inventory/hosts | 8 + terraform/apps/dev-01/config.tf | 5 + 24 files changed, 1523 insertions(+), 28 deletions(-) create mode 100644 ansible/host_vars/dev-02.lab.alexpires.me.yml create mode 100644 ansible/playbook_linux_dev_station.yml create mode 100644 ansible/roles/cis/defaults/main/logind.yml create mode 100644 ansible/roles/linux_dev_station/defaults/main.yml create mode 100755 ansible/roles/linux_dev_station/files/install-copilot.sh create mode 100644 ansible/roles/linux_dev_station/handlers/main.yml create mode 100644 ansible/roles/linux_dev_station/tasks/main.yml create mode 100644 ansible/roles/linux_dev_station/tasks/mcp_installers.yml create mode 100644 ansible/roles/linux_dev_station/tasks/tool_packages.yml create mode 100644 ansible/roles/linux_dev_station/templates/code-server.service.j2 create mode 100644 ansible/roles/linux_dev_station/templates/opencode.json.j2 create mode 100644 ansible/roles/linux_dev_station/templates/opencode.service.j2 create mode 100644 ansible/roles/linux_dev_station/templates/ssh-agent-start.sh.j2 create mode 100644 ansible/roles/linux_dev_station/templates/ssh-agent.service.j2 create mode 100644 ansible/roles/linux_dev_station/templates/start-ide.sh.j2 create mode 100644 ansible/roles/linux_dev_station/templates/start-opencode.sh.j2 diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 5bd5cd3..ea7c305 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -1,5 +1,6 @@ [defaults] inventory = ../inventory/hosts +allow_world_readable_tmpfiles = True [ssh_connection] ssh_args = -o ControlMaster=auto -o ControlPersist=60s diff --git a/ansible/host_vars/dev-02.lab.alexpires.me.yml b/ansible/host_vars/dev-02.lab.alexpires.me.yml new file mode 100644 index 0000000..b65794e --- /dev/null +++ b/ansible/host_vars/dev-02.lab.alexpires.me.yml @@ -0,0 +1,167 @@ +hostname: dev-02.lab.alexpires.me +ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}" +ansible_host: "10.19.4.210" + +linux_dev_station_service_user_enabled: true +linux_dev_station_service_user: "devstation" +linux_dev_station_service_home: "/home/devstation" +linux_dev_station_workspace_dir: "/home/devstation/projects" +linux_dev_station_scripts_dir: "/home/devstation/scripts" +linux_dev_station_logs_dir: "/home/devstation/.local/state/logs" + +linux_dev_station_code_server_host: "0.0.0.0" +linux_dev_station_code_server_port: 3000 +linux_dev_station_code_server_auth: "password" + +linux_dev_station_opencode_server_host: "0.0.0.0" +linux_dev_station_opencode_server_port: 4096 +linux_dev_station_manage_systemd: true +linux_dev_station_oh_my_zsh_enabled: true + +linux_dev_station_code_server_extensions_enabled: true +linux_dev_station_code_server_extensions: + - ms-python.python + - ms-python.vscode-pylance + - ms-python.debugpy + - golang.go + - ms-azuretools.vscode-docker + - hashicorp.terraform + - opentofu.vscode-opentofu + - redhat.ansible + - redhat.vscode-yaml + - eamodio.gitlens + +linux_dev_station_ssh_agent_enabled: true +linux_dev_station_mcp_bootstrap_commands: [] + +linux_dev_station_mcp_installers_enabled: true +linux_dev_station_mcp_installers: + - name: "chrome-devtools-mcp" + kind: "npm" + package: "chrome-devtools-mcp" + version: "latest" + - name: "playwright-mcp" + kind: "npm" + package: "@playwright/mcp" + version: "latest" + - name: "aws-documentation-mcp" + kind: "uv" + package: "awslabs.aws-documentation-mcp-server" + version: "latest" + binary: "awslabs.aws-documentation-mcp-server" + - name: "gitea-mcp" + kind: "go" + module: "gitea.com/gitea/gitea-mcp" + version: "latest" + binary: "gitea-mcp" + +linux_dev_station_opencode_extra_environment: + GITEA_MCP_HOST: "https://code.alexpires.me" + GITEA_APP_TOKEN: "{{ lookup('env', 'GITEA_APP_TOKEN', default='') }}" + +linux_dev_station_opencode_config: + "$schema": "https://opencode.ai/config.json" + provider: + alexpires-me: + name: "alexpires.me" + npm: "@ai-sdk/openai-compatible" + options: + baseURL: "https://open-webui.lab.alexpires.me/api" + apiKey: "{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}" + timeout: 3000000 + chunkTimeout: 3000000 + models: + gemma-4-12b-q8-0: + name: "gemma-4-12b-q8-0" + gemma-4-26b-a4b-ud-iq4-xs: + name: "gemma-4-26b-a4b-ud-iq4-xs" + gemma-4-31b-iq4-xs: + name: "gemma-4-31b-iq4-xs" + gpt-oss-20b-q4-0: + name: "gpt-oss-20b-q4-0" + gpt-oss-20b-q8-0: + name: "gpt-oss-20b-q8-0" + qwen3-5-35b-a3b-q4-k-m: + name: "qwen3-5-35b-a3b-q4-k-m" + qwen3-5-9b-q8-0: + name: "qwen3-5-9b-q8-0" + qwen3-6-27b-mtp-q4-0: + name: "qwen3-6-27b-mtp-q4-0" + qwen3-6-27b-q4-k-m: + name: "qwen3-6-27b-q4-k-m" + qwen3-6-35b-a3b-mtp-ud-q4-m: + name: "qwen3-6-35b-a3b-mtp-ud-q4-m" + qwen3-6-35b-a3b-ud-q4-k-m: + name: "qwen3-6-35b-a3b-ud-q4-k-m" + mcp: + deepwiki: + type: "remote" + url: "https://mcp.deepwiki.com/mcp" + enabled: true + terraform: + type: "remote" + url: "http://terraform-mcp.lab.alexpires.me/mcp" + enabled: true + opentofu: + type: "remote" + url: "https://mcp.opentofu.org/mcp" + enabled: true + microsoft_learn: + type: "remote" + url: "https://learn.microsoft.com/api/mcp" + enabled: true + mozilla_developer: + type: "remote" + url: "https://mcp.mdn.mozilla.net/" + enabled: true + chrome_devtools: + type: "local" + command: + - "npx" + - "-y" + - "chrome-devtools-mcp@latest" + - "--headless" + - "--isolated" + enabled: true + playwright: + type: "local" + command: + - "npx" + - "-y" + - "@playwright/mcp@latest" + - "--headless" + - "--isolated" + enabled: true + aws_documentation: + type: "local" + command: + - "uvx" + - "awslabs.aws-documentation-mcp-server@latest" + enabled: true + gitea: + type: "local" + command: + - "gitea-mcp" + - "-t" + - "stdio" + - "-H" + - "{env:GITEA_MCP_HOST}" + - "-T" + - "{env:GITEA_APP_TOKEN}" + enabled: true + compaction: + auto: true + prune: true + reserved: 32768 + +linux_dev_station_service_user_ssh_enabled: true +linux_dev_station_service_user_ssh_authorized_keys: [] + +# Firewall ports +ufw_enable: true +fw_allowed_ports: + - { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" } # SSH Access (VPN device) + - { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # SSH Access (local network) + - { rule: "allow", port: "3000", proto: "tcp", from: "10.19.4.0/24" } # code-server (local network only) + - { rule: "allow", port: "4096", proto: "tcp", from: "10.19.4.0/24" } # opencode (local network only) + - { rule: "allow", port: "9090", proto: "tcp", from: "10.19.4.0/24" } # opencode (local network only) diff --git a/ansible/host_vars/gpu-01.lab.alexpires.me.yml b/ansible/host_vars/gpu-01.lab.alexpires.me.yml index 4876979..a6c59db 100644 --- a/ansible/host_vars/gpu-01.lab.alexpires.me.yml +++ b/ansible/host_vars/gpu-01.lab.alexpires.me.yml @@ -118,25 +118,25 @@ llama_server_devices: - "/dev/dri:/dev/dri:rw" llama_server_models: - # TODO: replace revision values with pinned commit SHAs for strict reproducibility. + # # TODO: replace revision values with pinned commit SHAs for strict reproducibility. - name: "gemma-4-12b-q8-0" model_id: "unsloth/gemma-4-12b-it-GGUF" quant: "Q8_0" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "gemma-4-26b-a4b-ud-iq4-xs" model_id: "unsloth/gemma-4-26B-A4B-it-GGUF" quant: "UD-IQ4_XS" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "gemma-4-31b-iq4-xs" model_id: "unsloth/gemma-4-31B-it-GGUF" quant: "IQ4_XS" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "gpt-oss-20b-q4-0" model_id: "unsloth/gpt-oss-20b-GGUF" quant: "Q4_0" @@ -149,44 +149,56 @@ llama_server_models: model_id: "unsloth/Qwen3.5-27B-GGUF" quant: "Q4_K_M" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "qwen3-5-35b-a3b-q4-k-m" model_id: "unsloth/Qwen3.5-35B-A3B-GGUF" quant: "Q4_K_M" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "qwen3-5-9b-q8-0" model_id: "unsloth/Qwen3.5-9B-GGUF" quant: "Q8_0" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 - name: "qwen3-6-27b-q4-k-m" model_id: "unsloth/Qwen3.6-27B-GGUF" quant: "Q4_K_M" revision: "main" - preset: - ctx-size: 98304 + # preset: + # ctx-size: 98304 + - name: "qwen3-6-35b-a3b-ud-q4-k-m" + model_id: "unsloth/Qwen3.6-35B-A3B-GGUF" + quant: "UD-Q4_K_M" + revision: "main" + # preset: + # ctx-size: 98304 - name: "qwen3-6-27b-mtp-q4-0" model_id: "unsloth/Qwen3.6-27B-MTP-GGUF" quant: "Q4_0" revision: "main" preset: - ctx-size: 98304 + temperature: 0.6 + top_p: 0.95 + top_k: 20 + min_p: 0.0 + presence_penalty: 0.0 + repeat_penalty: 1.0 + # ctx-size: 98304 - name: "qwen3-6-35b-a3b-mtp-ud-q4-m" model_id: "unsloth/Qwen3.6-35B-A3B-MTP-GGUF" quant: "UD-Q4_K_M" revision: "main" preset: - ctx-size: 98304 - - name: "qwen3-6-35b-a3b-ud-q4-k-m" - model_id: "unsloth/Qwen3.6-35B-A3B-GGUF" - quant: "UD-Q4_K_M" - revision: "main" - preset: - ctx-size: 98304 + temperature: 0.6 + top_p: 0.95 + top_k: 20 + min_p: 0.0 + presence_penalty: 0.0 + repeat_penalty: 1.0 + # ctx-size: 98304 llama_server_argv_extra: [ @@ -195,15 +207,15 @@ llama_server_argv_extra: "-np", -1, "-b", - 1024, + 2048, "-ub", 512, "-fa", "on", "-ctk", - "q4_0", - "-ctv", "q8_0", + "-ctv", + "q4_0", "-cram", -1, "-sm", @@ -216,11 +228,14 @@ llama_server_argv_extra: "off", "-mg", 1, + "--timeout", + 1800, "--mmap", "--spec-type", "draft-mtp", "--spec-draft-n-max", 2, + "--swa-full", ] auto_suspend_enabled: true diff --git a/ansible/host_vars/mac-01.lab.alexpires.me.yml b/ansible/host_vars/mac-01.lab.alexpires.me.yml index d04cf29..5ea243a 100644 --- a/ansible/host_vars/mac-01.lab.alexpires.me.yml +++ b/ansible/host_vars/mac-01.lab.alexpires.me.yml @@ -76,6 +76,8 @@ macos_dev_station_opencode_config: options: baseURL: "https://open-webui.lab.alexpires.me/api" apiKey: "{{ lookup('env', 'OPEN_WEBUI_API_KEY', default='') }}" + timeout: 3000000 + chunkTimeout: 3000000 models: gemma-4-12b-q8-0: name: "gemma-4-12b-q8-0" diff --git a/ansible/playbook_linux_dev_station.yml b/ansible/playbook_linux_dev_station.yml new file mode 100644 index 0000000..cad4e35 --- /dev/null +++ b/ansible/playbook_linux_dev_station.yml @@ -0,0 +1,14 @@ +--- +- name: Configure Linux dev station + hosts: linux_dev + gather_facts: true + + pre_tasks: + - name: Ensure target is Fedora + ansible.builtin.assert: + that: + - ansible_facts['distribution'] == "Fedora" + fail_msg: "This playbook supports Fedora hosts only" + + roles: + - linux_dev_station diff --git a/ansible/roles/cis/defaults/main/logind.yml b/ansible/roles/cis/defaults/main/logind.yml new file mode 100644 index 0000000..afac485 --- /dev/null +++ b/ansible/roles/cis/defaults/main/logind.yml @@ -0,0 +1 @@ +cis_logind_conf: {} \ No newline at end of file diff --git a/ansible/roles/cis/tasks/logindconf.yml b/ansible/roles/cis/tasks/logindconf.yml index 670e4ed..d942e12 100644 --- a/ansible/roles/cis/tasks/logindconf.yml +++ b/ansible/roles/cis/tasks/logindconf.yml @@ -11,5 +11,4 @@ notify: - Reload systemd tags: - - systemd - - logind + - roles::cis::logind diff --git a/ansible/roles/cis/templates/etc/systemd/logind.conf.j2 b/ansible/roles/cis/templates/etc/systemd/logind.conf.j2 index d564a9f..b40d424 100644 --- a/ansible/roles/cis/templates/etc/systemd/logind.conf.j2 +++ b/ansible/roles/cis/templates/etc/systemd/logind.conf.j2 @@ -6,3 +6,6 @@ KillExcludeUsers=root IdleAction=lock IdleActionSec=15min RemoveIPC=yes +{% for key, value in cis_logind_conf.items() %} +{{ key }}={{ value }} +{% endfor %} diff --git a/ansible/roles/linux_dev_station/defaults/main.yml b/ansible/roles/linux_dev_station/defaults/main.yml new file mode 100644 index 0000000..0e14a20 --- /dev/null +++ b/ansible/roles/linux_dev_station/defaults/main.yml @@ -0,0 +1,103 @@ +--- +linux_dev_station_service_user_enabled: true +linux_dev_station_service_user: "devstation" +linux_dev_station_service_shell: "/usr/sbin/nologin" +linux_dev_station_service_user_ssh_enabled: false +linux_dev_station_service_user_ssh_shell: "/bin/zsh" +linux_dev_station_service_user_ssh_authorized_keys: [] +linux_dev_station_service_home: "/home/{{ linux_dev_station_service_user }}" + +linux_dev_station_workspace_dir: "{{ linux_dev_station_service_home }}/projects" +linux_dev_station_scripts_dir: "{{ linux_dev_station_service_home }}/scripts" +linux_dev_station_logs_dir: "{{ linux_dev_station_service_home }}/.local/state/logs" +linux_dev_station_systemd_unit_dir: "/etc/systemd/system" +linux_dev_station_path: >- + {{ linux_dev_station_service_home }}/.local/bin:{{ linux_dev_station_service_home }}/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/usr/local/bin + +linux_dev_station_dnf_packages: + - git + - tmux + - wget + - curl + - jq + - openssl + - zsh + - golang + - ripgrep + - fzf + +linux_dev_station_tool_packages: + - name: uv + install_script_url: "https://astral.sh/uv/install.sh" + - name: node + manager: fnm + - name: rust + manager: rustup + - name: code-server + version: "4.127.0" + sha256: "" + - name: opencode + version: "1.17.18" + sha256: "" + +linux_dev_station_code_server_host: "0.0.0.0" +linux_dev_station_code_server_port: 3000 +linux_dev_station_code_server_auth: "password" +linux_dev_station_code_server_password: "{{ lookup('env', 'CODE_SERVER_PASSWORD', default='') }}" +linux_dev_station_code_server_workspace: "{{ linux_dev_station_workspace_dir }}" +linux_dev_station_code_server_extensions_enabled: true +linux_dev_station_code_server_extensions_fail_on_error: false +linux_dev_station_code_server_extensions: + - ms-python.python + - ms-python.vscode-pylance + - ms-azuretools.vscode-docker + - hashicorp.terraform + - redhat.ansible + - eamodio.gitlens + +linux_dev_station_copilot_enabled: false +linux_dev_station_copilot_extensions: + - GitHub.copilot + - GitHub.copilot-chat +linux_dev_station_copilot_fail_on_error: false + +linux_dev_station_oh_my_zsh_enabled: true +linux_dev_station_oh_my_zsh_install_url: "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh" + +linux_dev_station_npm_packages: [] + +linux_dev_station_git_init_default_branch: "main" +linux_dev_station_git_pull_rebase: false + +linux_dev_station_ssh_key_path: "{{ ansible_facts['env']['HOME'] }}/.ssh/id_ed25519" +linux_dev_station_ssh_key_comment: "dev-02" + +linux_dev_station_code_server_systemd_enabled: true +linux_dev_station_opencode_systemd_enabled: true +linux_dev_station_opencode_server_host: "0.0.0.0" +linux_dev_station_opencode_server_port: 4096 +linux_dev_station_opencode_server_username: "{{ lookup('env', 'OPENCODE_SERVER_USERNAME', default='') }}" +linux_dev_station_opencode_server_password: "{{ lookup('env', 'OPENCODE_SERVER_PASSWORD', default='') }}" +linux_dev_station_opencode_extra_environment: {} +linux_dev_station_opencode_command: "opencode serve --hostname {{ linux_dev_station_opencode_server_host }} --port {{ linux_dev_station_opencode_server_port }}" +linux_dev_station_opencode_config: {} +linux_dev_station_opencode_config_path: "{{ linux_dev_station_service_home }}/.config/opencode/opencode.json" + +# MCP installer definitions +# Supported kinds: +# - npm: package + optional version +# - uv: package + optional version + binary +# - go: module + optional version + binary +linux_dev_station_mcp_installers_enabled: true +linux_dev_station_mcp_installers: [] + +# Legacy ad-hoc hook kept for backward compatibility. +linux_dev_station_mcp_bootstrap_commands: [] +linux_dev_station_manage_systemd: true + +# SSH agent +linux_dev_station_ssh_agent_enabled: false +linux_dev_station_ssh_agent_keys: + - "{{ linux_dev_station_service_home }}/.ssh/id_ed25519" +linux_dev_station_ssh_agent_passphrase: "{{ lookup('env', 'SSH_KEY_PASSPHRASE', default='') }}" +linux_dev_station_ssh_agent_socket: "{{ linux_dev_station_service_home }}/.ssh/ssh-agent.sock" diff --git a/ansible/roles/linux_dev_station/files/install-copilot.sh b/ansible/roles/linux_dev_station/files/install-copilot.sh new file mode 100755 index 0000000..09c930b --- /dev/null +++ b/ansible/roles/linux_dev_station/files/install-copilot.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# ansible: linux_dev_station install-copilot.sh +# Installs GitHub Copilot and Copilot Chat extensions into code-server +# with engine-compatible versions resolved from the VS Marketplace API. +set -euo pipefail + +get_engine_version() { + local version_output + version_output=$(code-server --version | head -n1) + if echo "$version_output" | grep -q "with Code"; then + echo "$version_output" | sed -n 's/.*with Code \([0-9.]*\).*/\1/p' + else + echo "$version_output" | awk '{print $1}' + fi +} + +get_user_data_dir() { + local process_info + process_info=$(ps aux 2>/dev/null | grep -v grep | grep "code-server" | head -n 1) || + process_info=$(ps -ef 2>/dev/null | grep -v grep | grep "code-server" | head -n 1) + if [ -n "$process_info" ]; then + local dir + dir=$(echo "$process_info" | grep -o -- '--user-data-dir=[^ ]*' | sed 's/--user-data-dir=//') + if [ -n "$dir" ]; then + echo "$dir"; return + fi + dir=$(echo "$process_info" | grep -o -- '--user-data-dir [^ ]*' | sed 's/--user-data-dir //') + if [ -n "$dir" ]; then + echo "$dir"; return + fi + fi +} + +find_compatible_version() { + local extension_id="$1" + local engine_version="$2" + local response + response=$(curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json;api-version=3.0-preview.1" \ + -d "{ + \"filters\": [{ + \"criteria\": [ + {\"filterType\": 7, \"value\": \"$extension_id\"}, + {\"filterType\": 12, \"value\": \"4096\"} + ], + \"pageSize\": 50 + }], + \"flags\": 4112 + }") + echo "$response" | jq -r --arg engine_version "$engine_version" ' + .results[0].extensions[0].versions[] | + select(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]*$")) | + select(all(.properties[]; .key != "Microsoft.VisualStudio.Code.PreRelease" or .value != "true")) | + { + version: .version, + engine: (.properties[] | select(.key == "Microsoft.VisualStudio.Code.Engine") | .value) + } | + select(.engine | ltrimstr("^") | split(".") | + map(split("-")[0] | tonumber? // 0) as $engine_parts | + ($engine_version | split(".") | map(split("-")[0] | tonumber? // 0)) as $server_parts | + ( + ($engine_parts[0] // 0) < $server_parts[0] or + (($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) < $server_parts[1]) or + (($engine_parts[0] // 0) == $server_parts[0] and ($engine_parts[1] // 0) == $server_parts[1] and ($engine_parts[2] // 0) <= $server_parts[2]) + ) + ) | + .version' | head -n 1 +} + +install_extension() { + local extension_id="$1" + local version="$2" + local user_data_dir="$3" + local extension_name + extension_name=$(echo "$extension_id" | cut -d'.' -f2) + local temp_dir="/tmp/code-extensions" + echo "Installing $extension_id v$version..." + mkdir -p "$temp_dir" + echo " Downloading..." + curl -L "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/GitHub/vsextensions/$extension_name/$version/vspackage" \ + -o "$temp_dir/$extension_name.vsix.gz" + if [ ! -f "$temp_dir/$extension_name.vsix.gz" ]; then + echo " [SKIP] Download failed for $extension_id" + return 1 + fi + if command -v gunzip >/dev/null 2>&1; then + gunzip -f "$temp_dir/$extension_name.vsix.gz" + else + gzip -df "$temp_dir/$extension_name.vsix.gz" + fi + if [ -n "$user_data_dir" ]; then + code-server --user-data-dir="$user_data_dir" --force --install-extension "$temp_dir/$extension_name.vsix" + else + code-server --force --install-extension "$temp_dir/$extension_name.vsix" + fi + rm -f "$temp_dir/$extension_name.vsix" + echo " [OK] $extension_id installed successfully!" + return 0 +} + +check_dependencies() { + local missing_deps=() + for cmd in curl jq code-server; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_deps+=("$cmd") + fi + done + if ! command -v gunzip >/dev/null 2>&1 && ! command -v gzip >/dev/null 2>&1; then + missing_deps+=("gunzip/gzip") + fi + if [ "${#missing_deps[@]}" -gt 0 ]; then + echo "Error: Missing required dependencies: ${missing_deps[*]}" + echo "Please install the missing dependencies and try again." + exit 1 + fi +} + +echo "========================================" +echo " GitHub Copilot Extensions Installer" +echo "========================================" +echo "" + +check_dependencies + +ENGINE_VERSION="$(get_engine_version)" +if [ -z "$ENGINE_VERSION" ]; then + echo "Error: Could not extract engine version from code-server" + exit 1 +fi +echo "Detected code-server engine version: $ENGINE_VERSION" + +USER_DATA_DIR="$(get_user_data_dir)" +if [ -n "$USER_DATA_DIR" ]; then + echo "Detected user-data-dir: $USER_DATA_DIR" +fi +echo "" + +EXTENSIONS="GitHub.copilot GitHub.copilot-chat" +FAILED=0 + +for ext in $EXTENSIONS; do + echo "Processing $ext..." + version="$(find_compatible_version "$ext" "$ENGINE_VERSION")" + if [ -z "$version" ]; then + echo " [SKIP] No compatible version found for $ext" + FAILED="$((FAILED + 1))" + else + echo " Found compatible version: $version" + if ! install_extension "$ext" "$version" "$USER_DATA_DIR"; then + FAILED="$((FAILED + 1))" + fi + fi + echo "" +done + +echo "========================================" +if [ $FAILED -eq 0 ]; then + echo "[OK] All extensions installed successfully!" + rm -rf /tmp/code-extensions +else + echo "[WARN] Completed with $FAILED error(s)" + exit 1 +fi diff --git a/ansible/roles/linux_dev_station/handlers/main.yml b/ansible/roles/linux_dev_station/handlers/main.yml new file mode 100644 index 0000000..5ad8edc --- /dev/null +++ b/ansible/roles/linux_dev_station/handlers/main.yml @@ -0,0 +1,24 @@ +--- +- name: Reload and restart code-server + become: true + ansible.builtin.systemd: + name: code-server + state: restarted + daemon_reload: true + when: + - linux_dev_station_manage_systemd | bool + - linux_dev_station_code_server_systemd_enabled | bool + listen: Restart code-server + +- name: Reload and restart opencode + become: true + ansible.builtin.systemd: + name: opencode + state: restarted + daemon_reload: true + when: + - linux_dev_station_manage_systemd | bool + - linux_dev_station_opencode_systemd_enabled | bool + listen: Restart opencode + + diff --git a/ansible/roles/linux_dev_station/tasks/main.yml b/ansible/roles/linux_dev_station/tasks/main.yml new file mode 100644 index 0000000..6ec8ad7 --- /dev/null +++ b/ansible/roles/linux_dev_station/tasks/main.yml @@ -0,0 +1,452 @@ +--- +- name: Ensure host is Fedora + ansible.builtin.assert: + that: + - ansible_facts['distribution'] == "Fedora" + fail_msg: "Role linux_dev_station supports Fedora hosts only" + +- name: Ensure OpenCode server credentials are provided + ansible.builtin.assert: + that: + - linux_dev_station_opencode_server_username | length > 0 + - linux_dev_station_opencode_server_password | length > 0 + fail_msg: >- + Set OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD + in environment (recommended via sectool) + before running the playbook. + when: linux_dev_station_opencode_systemd_enabled | bool + +- name: Ensure code-server password is provided + ansible.builtin.assert: + that: + - linux_dev_station_code_server_password | length > 0 + fail_msg: >- + Set CODE_SERVER_PASSWORD in environment + (recommended via sectool) before running the playbook. + when: + - linux_dev_station_code_server_systemd_enabled | bool + - linux_dev_station_code_server_auth == "password" + +- name: Install dnf packages + become: true + ansible.builtin.dnf: + name: "{{ linux_dev_station_dnf_packages }}" + state: present + +- name: Ensure dedicated service user exists + become: true + ansible.builtin.user: + name: "{{ linux_dev_station_service_user }}" + shell: >- + {{ (linux_dev_station_service_user_ssh_enabled | bool) | + ternary(linux_dev_station_service_user_ssh_shell, linux_dev_station_service_shell) }} + home: "{{ linux_dev_station_service_home }}" + create_home: true + state: present + when: linux_dev_station_service_user_enabled | bool + +- name: Ensure service user cache directory exists for tool installs + become: true + ansible.builtin.file: + path: "{{ linux_dev_station_service_home }}/.cache" + state: directory + mode: "0750" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + when: linux_dev_station_service_user_enabled | bool + +- name: Install tool-managed packages + ansible.builtin.include_tasks: tool_packages.yml + +- name: Discover npm executable path + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: /bin/sh -lc "command -v npm" + register: __linux_dev_station_npm_path + changed_when: false + failed_when: false + environment: + PATH: "{{ linux_dev_station_path }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_npm_packages | length > 0 + +- name: Install global npm packages + become: true + become_user: "{{ linux_dev_station_service_user }}" + community.general.npm: + name: "{{ item.name }}" + version: "{{ item.version }}" + executable: "{{ __linux_dev_station_npm_path.stdout | trim }}" + global: true + environment: + PATH: "{{ linux_dev_station_path }}" + loop: "{{ linux_dev_station_npm_packages }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_npm_packages | length > 0 + - __linux_dev_station_npm_path.rc | default(1) == 0 + +- name: Ensure required user directories exist + become: true + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0750" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + loop: + - "{{ linux_dev_station_service_home }}" + - "{{ linux_dev_station_workspace_dir }}" + - "{{ linux_dev_station_scripts_dir }}" + - "{{ linux_dev_station_logs_dir }}" + +- name: Ensure service home permissions are restrictive + become: true + ansible.builtin.file: + path: "{{ linux_dev_station_service_home }}" + state: directory + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0700" + +- name: Ensure service user config directories exist + become: true + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0750" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + loop: + - "{{ linux_dev_station_service_home }}/.local" + - "{{ linux_dev_station_service_home }}/.local/bin" + - "{{ linux_dev_station_service_home }}/.local/lib" + - "{{ linux_dev_station_service_home }}/.local/lib/node_modules" + - "{{ linux_dev_station_service_home }}/.config" + - "{{ linux_dev_station_service_home }}/.config/opencode" + when: linux_dev_station_service_user_enabled | bool + +- name: Ensure OpenCode config object is valid when provided + ansible.builtin.assert: + that: + - linux_dev_station_opencode_config is mapping + fail_msg: "linux_dev_station_opencode_config must be a mapping (dictionary)." + +- name: Render OpenCode config from host vars + become: true + ansible.builtin.template: + src: opencode.json.j2 + dest: "{{ linux_dev_station_opencode_config_path }}" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0600" + notify: Restart opencode + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_opencode_config | length > 0 + +- name: Download Oh My Zsh installer + become: true + ansible.builtin.get_url: + url: "{{ linux_dev_station_oh_my_zsh_install_url }}" + dest: "{{ linux_dev_station_service_home }}/.cache/ohmyzsh-install.sh" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0755" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_oh_my_zsh_enabled | bool + +- name: Install Oh My Zsh for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + /bin/sh {{ linux_dev_station_service_home }}/.cache/ohmyzsh-install.sh --unattended + args: + creates: "{{ linux_dev_station_service_home }}/.oh-my-zsh" + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + RUNZSH: "no" + CHSH: "no" + KEEP_ZSHRC: "yes" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_oh_my_zsh_enabled | bool + +- name: Ensure .zshrc has ssh-agent startup for devstation + become: true + ansible.builtin.blockinfile: + path: "{{ linux_dev_station_service_home }}/.zshrc" + block: | + # ansible: linux_dev_station ssh-agent + export SSH_AUTH_SOCK="{{ linux_dev_station_ssh_agent_socket }}" + if [ -S "$SSH_AUTH_SOCK" ] && ssh-add -l &>/dev/null; then + # Agent socket exists and has keys — nothing to do + true + else + if [ -S "$SSH_AUTH_SOCK" ]; then + # Socket exists but agent is dead or has no keys — restart + rm -f "$SSH_AUTH_SOCK" + eval "$(ssh-agent -a "$SSH_AUTH_SOCK" 2>/dev/null)" || true + else + # No socket — start fresh agent + eval "$(ssh-agent -a "$SSH_AUTH_SOCK" 2>/dev/null)" || true + fi + if [ -f "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" ]; then + source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" + fi + fi + marker: "# {mark} ansible: linux_dev_station ssh-agent" + create: true + mode: "0644" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_ssh_agent_enabled | bool + +- name: Install managed MCP server runtimes + ansible.builtin.include_tasks: mcp_installers.yml + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_mcp_installers_enabled | bool + - linux_dev_station_mcp_installers | length > 0 + +- name: Run MCP bootstrap commands + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.shell: "{{ item }}" + register: __linux_dev_station_mcp_bootstrap_result + changed_when: __linux_dev_station_mcp_bootstrap_result.rc == 0 + args: + executable: /bin/sh + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + loop: "{{ linux_dev_station_mcp_bootstrap_commands }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_mcp_bootstrap_commands | length > 0 + +- name: List installed code-server extensions for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: code-server --list-extensions + register: __linux_dev_station_code_server_extensions_installed + changed_when: false + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_code_server_extensions_enabled | bool + - linux_dev_station_code_server_extensions | length > 0 + +- name: Install base code-server extensions for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + code-server --install-extension {{ item }} + register: __linux_dev_station_code_server_extension_install + changed_when: __linux_dev_station_code_server_extension_install.rc == 0 + failed_when: >- + (linux_dev_station_code_server_extensions_fail_on_error | bool) + and (__linux_dev_station_code_server_extension_install.rc != 0) + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + notify: Restart code-server + loop: "{{ linux_dev_station_code_server_extensions }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_code_server_extensions_enabled | bool + - item not in (__linux_dev_station_code_server_extensions_installed.stdout_lines | default([])) + +- name: Report skipped code-server extensions that are unavailable + ansible.builtin.debug: + msg: >- + Skipping unavailable code-server extension '{{ item.item }}': + {{ (item.stderr_lines | default([])) | join(' ') }} + loop: "{{ __linux_dev_station_code_server_extension_install.results | default([]) }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_code_server_extensions_enabled | bool + - not (linux_dev_station_code_server_extensions_fail_on_error | bool) + - item.rc is defined + - item.rc != 0 + +- name: Copy Copilot installer script + become: true + ansible.builtin.copy: + src: install-copilot.sh + dest: "{{ linux_dev_station_service_home }}/.cache/install-copilot.sh" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0755" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_copilot_enabled | bool + +- name: Install GitHub Copilot extensions via compatible VSIX + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: "{{ linux_dev_station_service_home }}/.cache/install-copilot.sh" + register: __linux_dev_station_copilot_install_result + changed_when: __linux_dev_station_copilot_install_result.rc == 0 + failed_when: >- + (linux_dev_station_copilot_fail_on_error | bool) + and (__linux_dev_station_copilot_install_result.rc != 0) + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_copilot_enabled | bool + +- name: Ensure devstation SSH directory exists when SSH access is enabled + become: true + ansible.builtin.file: + path: "{{ linux_dev_station_service_home }}/.ssh" + state: directory + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0700" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_service_user_ssh_enabled | bool + +- name: Ensure devstation authorized keys are present when SSH access is enabled + become: true + ansible.posix.authorized_key: + user: "{{ linux_dev_station_service_user }}" + key: "{{ item }}" + state: present + loop: "{{ linux_dev_station_service_user_ssh_authorized_keys }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_service_user_ssh_enabled | bool + +- name: Render ssh-agent starter script + become: true + ansible.builtin.template: + src: ssh-agent-start.sh.j2 + dest: "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" + mode: "0755" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_ssh_agent_enabled | bool + +- name: Ensure ssh_config has ssh-agent settings for devstation + become: true + ansible.builtin.blockinfile: + path: "{{ linux_dev_station_service_home }}/.ssh/config" + block: | + Host * + AddKeysToAgent yes + ServerAliveInterval 60 + ServerAliveCountMax 3 + marker: "# {mark} ansible: linux_dev_station ssh-agent" + create: true + mode: "0600" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_ssh_agent_enabled | bool + +- name: Generate SSH key if missing + ansible.builtin.command: >- + ssh-keygen -t ed25519 -C {{ linux_dev_station_ssh_key_comment }} + -f {{ linux_dev_station_ssh_key_path }} -N '' + args: + creates: "{{ linux_dev_station_ssh_key_path }}" + +- name: Configure Git init default branch + community.general.git_config: + name: init.defaultBranch + scope: global + value: "{{ linux_dev_station_git_init_default_branch }}" + +- name: Configure Git pull.rebase + community.general.git_config: + name: pull.rebase + scope: global + value: "{{ linux_dev_station_git_pull_rebase | ternary('true', 'false') }}" + +- name: Render code-server launcher script + become: true + ansible.builtin.template: + src: start-ide.sh.j2 + dest: "{{ linux_dev_station_scripts_dir }}/start-ide.sh" + mode: "0755" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + +- name: Render opencode launcher script + become: true + ansible.builtin.template: + src: start-opencode.sh.j2 + dest: "{{ linux_dev_station_scripts_dir }}/start-opencode.sh" + mode: "0755" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + +- name: Render tmux configuration + ansible.builtin.copy: + dest: "{{ ansible_facts['env']['HOME'] }}/.tmux.conf" + mode: "0644" + content: | + set -g mouse on + set -g history-limit 100000 + +- name: Render code-server systemd unit + become: true + ansible.builtin.template: + src: code-server.service.j2 + dest: "{{ linux_dev_station_systemd_unit_dir }}/code-server.service" + mode: "0644" + when: linux_dev_station_code_server_systemd_enabled | bool + notify: Restart code-server + +- name: Render opencode systemd unit + become: true + ansible.builtin.template: + src: opencode.service.j2 + dest: "{{ linux_dev_station_systemd_unit_dir }}/opencode.service" + mode: "0644" + when: linux_dev_station_opencode_systemd_enabled | bool + notify: Restart opencode + +- name: Ensure code-server service is started and enabled + become: true + ansible.builtin.systemd: + name: code-server + state: started + enabled: true + daemon_reload: true + when: + - linux_dev_station_manage_systemd | bool + - linux_dev_station_code_server_systemd_enabled | bool + +- name: Ensure opencode service is started and enabled + become: true + ansible.builtin.systemd: + name: opencode + state: started + enabled: true + daemon_reload: true + when: + - linux_dev_station_manage_systemd | bool + - linux_dev_station_opencode_systemd_enabled | bool + +- name: Ensure systemd services are started + ansible.builtin.meta: flush_handlers + when: linux_dev_station_manage_systemd | bool diff --git a/ansible/roles/linux_dev_station/tasks/mcp_installers.yml b/ansible/roles/linux_dev_station/tasks/mcp_installers.yml new file mode 100644 index 0000000..474026e --- /dev/null +++ b/ansible/roles/linux_dev_station/tasks/mcp_installers.yml @@ -0,0 +1,138 @@ +--- +- name: Ensure MCP installer definitions list is valid + ansible.builtin.assert: + that: + - linux_dev_station_mcp_installers is iterable + fail_msg: "linux_dev_station_mcp_installers must be a list of installer definitions." + +- name: Validate MCP installer kind values + ansible.builtin.assert: + that: + - item.kind is defined + - item.kind in ['npm', 'uv', 'go'] + fail_msg: >- + MCP installer '{{ item.name | default('unnamed') }}' + has unsupported kind '{{ item.kind | default('undefined') }}'. + Supported: npm, uv, go. + loop: "{{ linux_dev_station_mcp_installers }}" + when: item.enabled | default(true) | bool + +- name: Validate npm MCP installers + ansible.builtin.assert: + that: + - item.package is defined + - item.package | length > 0 + fail_msg: "npm MCP installer '{{ item.name | default('unnamed') }}' requires 'package'." + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'npm' + +- name: Validate uv MCP installers + ansible.builtin.assert: + that: + - item.package is defined + - item.package | length > 0 + - item.binary is defined + - item.binary | length > 0 + fail_msg: "uv MCP installer '{{ item.name | default('unnamed') }}' requires 'package' and 'binary'." + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'uv' + +- name: Validate go MCP installers + ansible.builtin.assert: + that: + - item.module is defined + - item.module | length > 0 + - item.binary is defined + - item.binary | length > 0 + fail_msg: "go MCP installer '{{ item.name | default('unnamed') }}' requires 'module' and 'binary'." + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'go' + +- name: Discover npm executable path for MCP installers + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: /bin/sh -lc "command -v npm" + register: __linux_dev_station_mcp_npm_path + changed_when: false + failed_when: false + environment: + PATH: "{{ linux_dev_station_path }}" + when: (linux_dev_station_mcp_installers | selectattr('kind', 'equalto', 'npm') | list | length) > 0 + +- name: Install npm MCP packages for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: + argv: + - "{{ __linux_dev_station_mcp_npm_path.stdout | trim }}" + - install + - --prefix + - "{{ linux_dev_station_service_home }}/.local" + - --global + - "{{ item.package }}@{{ item.version | default('latest') }}" + args: + creates: "{{ linux_dev_station_service_home }}/.local/lib/node_modules/{{ item.package }}" + register: __linux_dev_station_mcp_npm_install + changed_when: __linux_dev_station_mcp_npm_install.rc == 0 + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + notify: Restart opencode + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'npm' + - __linux_dev_station_mcp_npm_path.rc | default(1) == 0 + +- name: Install uv MCP tools for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: + argv: + - uv + - tool + - install + - "{{ item.package }}@{{ item.version | default('latest') }}" + args: + creates: "{{ linux_dev_station_service_home }}/.local/bin/{{ item.binary }}" + register: __linux_dev_station_mcp_uv_install + changed_when: __linux_dev_station_mcp_uv_install.rc == 0 + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + notify: Restart opencode + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'uv' + +- name: Install go MCP binaries for service user + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: + argv: + - go + - install + - "{{ item.module }}@{{ item.version | default('latest') }}" + args: + creates: "{{ linux_dev_station_service_home }}/.local/bin/{{ item.binary }}" + register: __linux_dev_station_mcp_go_install + changed_when: __linux_dev_station_mcp_go_install.rc == 0 + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + GOBIN: "{{ linux_dev_station_service_home }}/.local/bin" + notify: Restart opencode + loop: "{{ linux_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'go' diff --git a/ansible/roles/linux_dev_station/tasks/tool_packages.yml b/ansible/roles/linux_dev_station/tasks/tool_packages.yml new file mode 100644 index 0000000..0459ec7 --- /dev/null +++ b/ansible/roles/linux_dev_station/tasks/tool_packages.yml @@ -0,0 +1,268 @@ +--- +- name: Ensure tool packages list is valid + ansible.builtin.assert: + that: + - linux_dev_station_tool_packages is iterable + fail_msg: "linux_dev_station_tool_packages must be a list of tool definitions." + +- name: Download uv installer + become: true + ansible.builtin.get_url: + url: https://astral.sh/uv/install.sh + dest: "{{ linux_dev_station_service_home }}/.cache/uv-install.sh" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0755" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'uv') | list | length > 0 + +- name: Install uv + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + /bin/sh {{ linux_dev_station_service_home }}/.cache/uv-install.sh + args: + creates: "{{ linux_dev_station_service_home }}/.local/bin/uv" + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}" + UV_UNINSTALL_MISSING_PACKAGES: "1" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'uv') | list | length > 0 + +- name: Download rustup installer + become: true + ansible.builtin.get_url: + url: https://sh.rustup.rs + dest: "{{ linux_dev_station_service_home }}/.cache/rustup-install.sh" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0755" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'rust') | list | length > 0 + +- name: Install rustup + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + /bin/sh {{ linux_dev_station_service_home }}/.cache/rustup-install.sh -y --default-toolchain stable + args: + creates: "{{ linux_dev_station_service_home }}/.cargo/bin/rustc" + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.cargo/bin" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'rust') | list | length > 0 + +- name: Download fnm (Fast Node Manager) binary + become: true + ansible.builtin.get_url: + url: "https://github.com/Schniz/fnm/releases/download/{{ fnm_version }}/fnm-linux.zip" + dest: "{{ linux_dev_station_service_home }}/.cache/fnm.zip" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0644" + vars: + fnm_version: v1.38.1 + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Create .fnm directory + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.file: + path: "{{ linux_dev_station_service_home }}/.fnm" + state: directory + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0755" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Extract fnm to .fnm directory + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.unarchive: + src: "{{ linux_dev_station_service_home }}/.cache/fnm.zip" + dest: "{{ linux_dev_station_service_home }}/.fnm" + remote_src: true + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Ensure fnm binary is executable + become: true + ansible.builtin.file: + path: "{{ linux_dev_station_service_home }}/.fnm/fnm" + mode: "0755" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Install LTS Node.js version via fnm + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + {{ linux_dev_station_service_home }}/.fnm/fnm install --lts + args: + creates: "{{ linux_dev_station_service_home }}/.fnm/node-versions" + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.fnm" + FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Get installed Node.js version from fnm node-versions directory + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: ls "{{ linux_dev_station_service_home }}/.fnm/node-versions" + register: fnm_versions_list + changed_when: false + environment: + FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + +- name: Set default Node.js version via fnm + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.command: >- + {{ linux_dev_station_service_home }}/.fnm/fnm default {{ node_version }} + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + PATH: "{{ linux_dev_station_path }}:{{ linux_dev_station_service_home }}/.fnm" + FNM_DIR: "{{ linux_dev_station_service_home }}/.fnm" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'node') | list | length > 0 + - fnm_versions_list is defined + - fnm_versions_list.stdout | trim | length > 0 + vars: + node_version: "{{ fnm_versions_list.stdout | trim }}" + +- name: Download code-server tarball + become: true + ansible.builtin.get_url: + url: "https://github.com/coder/code-server/releases/download/v{{ tool.version }}/code-server-{{ tool.version }}-linux-amd64.tar.gz" + dest: "{{ linux_dev_station_service_home }}/.cache/code-server.tar.gz" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0644" + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0 + +- name: Create code-server installation directories + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ linux_dev_station_service_home }}/.local/lib" + - "{{ linux_dev_station_service_home }}/.local/bin" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0 + +- name: Extract code-server tarball to lib directory + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.unarchive: + src: "{{ linux_dev_station_service_home }}/.cache/code-server.tar.gz" + dest: "{{ linux_dev_station_service_home }}/.local/lib" + remote_src: true + environment: + HOME: "{{ linux_dev_station_service_home }}" + USER: "{{ linux_dev_station_service_user }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0 + +- name: Create symlink for code-server binary + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.file: + src: "{{ linux_dev_station_service_home }}/.local/lib/code-server-{{ tool.version }}-linux-amd64/bin/code-server" + dest: "{{ linux_dev_station_service_home }}/.local/bin/code-server" + state: link + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'code-server') | list | length > 0 + +- name: Create opencode installation directories + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ linux_dev_station_service_home }}/.local/lib" + - "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}" + - "{{ linux_dev_station_service_home }}/.local/bin" + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0 + +- name: Download opencode tarball + become: true + ansible.builtin.get_url: + url: "https://github.com/anomalyco/opencode/releases/download/v{{ tool.version }}/opencode-linux-x64.tar.gz" + dest: "{{ linux_dev_station_service_home }}/.cache/opencode-{{ tool.version }}.tar.gz" + owner: "{{ linux_dev_station_service_user }}" + group: "{{ linux_dev_station_service_user }}" + mode: "0644" + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0 + +- name: Extract opencode tarball to versioned directory + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.unarchive: + src: "{{ linux_dev_station_service_home }}/.cache/opencode-{{ tool.version }}.tar.gz" + dest: "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}" + remote_src: true + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0 + +- name: Create symlink for opencode binary + become: true + become_user: "{{ linux_dev_station_service_user }}" + ansible.builtin.file: + src: "{{ linux_dev_station_service_home }}/.local/lib/opencode-{{ tool.version }}/opencode" + dest: "{{ linux_dev_station_service_home }}/.local/bin/opencode" + state: link + vars: + tool: "{{ linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | first }}" + when: + - linux_dev_station_service_user_enabled | bool + - linux_dev_station_tool_packages | selectattr('name', 'equalto', 'opencode') | list | length > 0 diff --git a/ansible/roles/linux_dev_station/templates/code-server.service.j2 b/ansible/roles/linux_dev_station/templates/code-server.service.j2 new file mode 100644 index 0000000..9396480 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/code-server.service.j2 @@ -0,0 +1,24 @@ +[Unit] +Description=code-server - VS Code on remote Linux +After=network.target + +[Service] +Type=simple +User={{ linux_dev_station_service_user }} +Group={{ linux_dev_station_service_user }} +WorkingDirectory={{ linux_dev_station_workspace_dir }} +ExecStart=/bin/bash {{ linux_dev_station_scripts_dir }}/start-ide.sh +Restart=always +RestartSec=5 +StandardOutput=append:{{ linux_dev_station_logs_dir }}/code-server.log +StandardError=append:{{ linux_dev_station_logs_dir }}/code-server.err.log + +Environment="HOME={{ linux_dev_station_service_home }}" +Environment="PATH={{ linux_dev_station_path }}" +Environment="PASSWORD={{ linux_dev_station_code_server_password }}" +{% if linux_dev_station_ssh_agent_enabled | bool %} +Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}" +{% endif %} + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/linux_dev_station/templates/opencode.json.j2 b/ansible/roles/linux_dev_station/templates/opencode.json.j2 new file mode 100644 index 0000000..8060bb2 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/opencode.json.j2 @@ -0,0 +1 @@ +{{ linux_dev_station_opencode_config | to_nice_json }} diff --git a/ansible/roles/linux_dev_station/templates/opencode.service.j2 b/ansible/roles/linux_dev_station/templates/opencode.service.j2 new file mode 100644 index 0000000..3a173b5 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/opencode.service.j2 @@ -0,0 +1,28 @@ +[Unit] +Description=OpenCode - AI-powered terminal IDE +After=network.target + +[Service] +Type=simple +User={{ linux_dev_station_service_user }} +Group={{ linux_dev_station_service_user }} +WorkingDirectory={{ linux_dev_station_workspace_dir }} +ExecStart=/bin/bash {{ linux_dev_station_scripts_dir }}/start-opencode.sh +Restart=always +RestartSec=5 +StandardOutput=append:{{ linux_dev_station_logs_dir }}/opencode.log +StandardError=append:{{ linux_dev_station_logs_dir }}/opencode.err.log + +Environment="HOME={{ linux_dev_station_service_home }}" +Environment="PATH={{ linux_dev_station_path }}" +Environment="OPENCODE_SERVER_USERNAME={{ linux_dev_station_opencode_server_username }}" +Environment="OPENCODE_SERVER_PASSWORD={{ linux_dev_station_opencode_server_password }}" +{% for env_key, env_value in linux_dev_station_opencode_extra_environment.items() %} +Environment="{{ env_key }}={{ env_value }}" +{% endfor %} +{% if linux_dev_station_ssh_agent_enabled | bool %} +Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}" +{% endif %} + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/linux_dev_station/templates/ssh-agent-start.sh.j2 b/ansible/roles/linux_dev_station/templates/ssh-agent-start.sh.j2 new file mode 100644 index 0000000..61fffd3 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/ssh-agent-start.sh.j2 @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# SSH agent starter — idempotent, safe to source from concurrent sessions. +# Starts ssh-agent if not already running, then adds keys. + +export SSH_AUTH_SOCK="{{ linux_dev_station_ssh_agent_socket }}" + +# Start ssh-agent if socket doesn't exist +if [ ! -S "$SSH_AUTH_SOCK" ]; then + eval "$(/usr/bin/ssh-agent -a "{{ linux_dev_station_ssh_agent_socket }}" -s)" > /dev/null 2>&1 +fi + +# Wait for the agent socket to appear +__wait=0 +while [ ! -S "$SSH_AUTH_SOCK" ] && [ "$__wait" -lt 15 ]; do + sleep 1 + __wait=$(( __wait + 1 )) +done + +# Add keys if the agent is running but has none loaded +if [ -S "$SSH_AUTH_SOCK" ]; then + if ssh-add -l 2>/dev/null | grep -q "The agent has no identities"; then + for _key in {{ linux_dev_station_ssh_agent_keys | map('quote') | join(' ') }}; do + if [ -f "$_key" ]; then + if [ -n "{{ linux_dev_station_ssh_agent_passphrase }}" ]; then + printf '%s\n' "{{ linux_dev_station_ssh_agent_passphrase }}" | ssh-add "$_key" 2>/dev/null || true + else + ssh-add "$_key" 2>/dev/null || true + fi + fi + done + fi +fi diff --git a/ansible/roles/linux_dev_station/templates/ssh-agent.service.j2 b/ansible/roles/linux_dev_station/templates/ssh-agent.service.j2 new file mode 100644 index 0000000..328153e --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/ssh-agent.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description=SSH Agent for devstation +After=network.target + +[Service] +Type=simple +User={{ linux_dev_station_service_user }} +Group={{ linux_dev_station_service_user }} +ExecStart=/usr/bin/ssh-agent -a {{ linux_dev_station_ssh_agent_socket }} +Restart=always +RestartSec=5 +StandardOutput=append:{{ linux_dev_station_logs_dir }}/ssh-agent.log +StandardError=append:{{ linux_dev_station_logs_dir }}/ssh-agent.err.log + +Environment="HOME={{ linux_dev_station_service_home }}" +Environment="PATH={{ linux_dev_station_path }}" +Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}" diff --git a/ansible/roles/linux_dev_station/templates/start-ide.sh.j2 b/ansible/roles/linux_dev_station/templates/start-ide.sh.j2 new file mode 100644 index 0000000..3a18a00 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/start-ide.sh.j2 @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +{% if linux_dev_station_ssh_agent_enabled | bool %} +source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" +{% endif %} + +cd {{ linux_dev_station_workspace_dir }} + +exec code-server \ + --bind-addr {{ linux_dev_station_code_server_host }}:{{ linux_dev_station_code_server_port }} \ + --auth {{ linux_dev_station_code_server_auth }} \ + --disable-telemetry \ + {{ linux_dev_station_code_server_workspace }} diff --git a/ansible/roles/linux_dev_station/templates/start-opencode.sh.j2 b/ansible/roles/linux_dev_station/templates/start-opencode.sh.j2 new file mode 100644 index 0000000..054a3c2 --- /dev/null +++ b/ansible/roles/linux_dev_station/templates/start-opencode.sh.j2 @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +{% if linux_dev_station_ssh_agent_enabled | bool %} +source "{{ linux_dev_station_scripts_dir }}/ssh-agent-start.sh" +{% endif %} + +cd {{ linux_dev_station_workspace_dir }} + +exec {{ linux_dev_station_opencode_command }} diff --git a/ansible/roles/llama_server/files/scripts/linux/llama_hf_sync.py b/ansible/roles/llama_server/files/scripts/linux/llama_hf_sync.py index 3606e30..bd31861 100644 --- a/ansible/roles/llama_server/files/scripts/linux/llama_hf_sync.py +++ b/ansible/roles/llama_server/files/scripts/linux/llama_hf_sync.py @@ -120,6 +120,8 @@ def _normalize_preset_options(options: dict[str, Any], scope: str) -> dict[str, normalized: dict[str, str] = {} for key, value in options.items(): key_str = str(key).strip() + # convert _ to - for compatibility with llama.cpp router preset format + key_str = key_str.replace("_", "-") if not key_str: raise RuntimeError(f"{scope} preset option keys must be non-empty") if value is None: diff --git a/inventory/hosts b/inventory/hosts index 5a13f51..74cb85b 100644 --- a/inventory/hosts +++ b/inventory/hosts @@ -3,12 +3,14 @@ prod-01.alexpires.me dev-01.lab.alexpires.me vh-01.lab.alexpires.me gpu-01.lab.alexpires.me +dev-02.lab.alexpires.me [hardening] prod-01.alexpires.me dev-01.lab.alexpires.me vh-01.lab.alexpires.me gpu-01.lab.alexpires.me +dev-02.lab.alexpires.me [ubuntu] prod-01.alexpires.me @@ -17,6 +19,7 @@ dev-01.lab.alexpires.me [fedora] vh-01.lab.alexpires.me gpu-01.lab.alexpires.me +dev-02.lab.alexpires.me [certbot] vh-01.lab.alexpires.me @@ -29,6 +32,7 @@ prod-01.alexpires.me dev-01.lab.alexpires.me vh-01.lab.alexpires.me gpu-01.lab.alexpires.me +dev-02.lab.alexpires.me [gitea] prod-01.alexpires.me @@ -141,3 +145,7 @@ ci-01.lab.alexpires.me [darwin_dev] mac-01.lab.alexpires.me +[linux_dev] +dev-02.lab.alexpires.me + + diff --git a/terraform/apps/dev-01/config.tf b/terraform/apps/dev-01/config.tf index a270290..e299386 100644 --- a/terraform/apps/dev-01/config.tf +++ b/terraform/apps/dev-01/config.tf @@ -111,6 +111,11 @@ locals { type = "A" content = "10.19.4.207" }, + { + name = "dev-02" + type = "A" + content = "10.19.4.210" + }, { name = "vaultwarden" type = "CNAME"