From 040f7a382f9e73a39455c49e20052c252ee04bf2 Mon Sep 17 00:00:00 2001 From: Alexandre Pires Date: Sat, 4 Jul 2026 15:37:35 +0200 Subject: [PATCH] Add macOS development station playbook and role - Created a new Ansible playbook for configuring macOS development stations. - Added role `macos_dev_station` with default variables, handlers, and tasks. - Implemented tasks for installing Homebrew packages, managing user configurations, and setting up launch agents for code-server and opencode. - Included templates for launchd plist files and startup scripts for code-server and opencode. - Added validation for required environment variables and configurations. - Updated inventory to include macOS hosts and added a setup script for initial prerequisites. - Modified Terraform configuration to include DNS records for new services. --- Readme.MD | 45 ++ ansible/host_vars/mac-01.lab.alexpires.me.yml | 178 +++++++ ansible/playbook_macos_dev_station.yml | 14 + .../roles/macos_dev_station/defaults/main.yml | 86 ++++ .../roles/macos_dev_station/handlers/main.yml | 60 +++ .../roles/macos_dev_station/tasks/main.yml | 445 ++++++++++++++++++ .../tasks/mcp_installers.yml | 132 ++++++ .../templates/code-server.plist.j2 | 40 ++ .../templates/opencode.json.j2 | 1 + .../templates/opencode.plist.j2 | 46 ++ .../templates/start-ide.sh.j2 | 11 + .../templates/start-opencode.sh.j2 | 7 + ansible/sectool.env | 6 +- inventory/hosts | 3 + scripts/setup-dev-station.sh | 56 +++ terraform/apps/dev-01/config.tf | 65 ++- 16 files changed, 1177 insertions(+), 18 deletions(-) create mode 100644 ansible/host_vars/mac-01.lab.alexpires.me.yml create mode 100644 ansible/playbook_macos_dev_station.yml create mode 100644 ansible/roles/macos_dev_station/defaults/main.yml create mode 100644 ansible/roles/macos_dev_station/handlers/main.yml create mode 100644 ansible/roles/macos_dev_station/tasks/main.yml create mode 100644 ansible/roles/macos_dev_station/tasks/mcp_installers.yml create mode 100644 ansible/roles/macos_dev_station/templates/code-server.plist.j2 create mode 100644 ansible/roles/macos_dev_station/templates/opencode.json.j2 create mode 100644 ansible/roles/macos_dev_station/templates/opencode.plist.j2 create mode 100644 ansible/roles/macos_dev_station/templates/start-ide.sh.j2 create mode 100644 ansible/roles/macos_dev_station/templates/start-opencode.sh.j2 create mode 100755 scripts/setup-dev-station.sh diff --git a/Readme.MD b/Readme.MD index 74a69f9..da26221 100644 --- a/Readme.MD +++ b/Readme.MD @@ -105,3 +105,48 @@ Create a python environment use: ``` python -m venv .venv --system-site-packages ``` + +# macOS Dev Station + +Use the repository hybrid flow: bootstrap once with shell script, then manage day-2 changes with Ansible. + +## 1) Bootstrap prerequisites on the Mac + +``` +./scripts/setup-dev-station.sh +``` + +This installs only prerequisites (Homebrew, base tools, Ansible) and avoids persistent shell side effects. + +## 2) Configure the Mac with Ansible + +1. Add or adjust the host in `inventory/hosts` under `[darwin_dev]`. +2. Configure host vars in `ansible/host_vars/mac-01.lab.alexpires.me.yml`. +3. Run: + +``` +cd ansible +sectool exec ansible-playbook playbook_macos_dev_station.yml --limit darwin_dev +``` + +The macOS role configures tools, tmux, code-server and opencode as system launchd daemons running under a dedicated service user. This allows services to start at boot without requiring an interactive login. + +OpenCode server auth is injected from environment variables. Ensure `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` are available through `ansible/sectool.env` values before running the playbook. +code-server password auth is also env-backed. Ensure `CODE_SERVER_PASSWORD` is available through `ansible/sectool.env` values before running the playbook. +Hard mode (system daemons + dedicated service user) also requires sudo rights. Use passwordless sudo for your ansible user or set `ANSIBLE_BECOME_PASSWORD` through `ansible/sectool.env` values before running the playbook. + +## 3) Optional DNS entry in dev-01 CoreDNS + +From `terraform/apps/dev-01`, you can inject an optional DNS record for the Mac dev station: + +``` +export TF_VAR_mac_dev_station_dns_name="mac-mini" +export TF_VAR_mac_dev_station_dns_type="A" +export TF_VAR_mac_dev_station_dns_target="10.19.4.250" + +sectool -f ../../../sectool.json exec tofu validate +sectool -f ../../../sectool.json exec tofu plan +sectool -f ../../../sectool.json exec tofu apply -auto-approve +``` + +Use `CNAME` + hostname target instead of `A` when you want an alias. diff --git a/ansible/host_vars/mac-01.lab.alexpires.me.yml b/ansible/host_vars/mac-01.lab.alexpires.me.yml new file mode 100644 index 0000000..f737d8d --- /dev/null +++ b/ansible/host_vars/mac-01.lab.alexpires.me.yml @@ -0,0 +1,178 @@ +hostname: mac-01.lab.alexpires.me +ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}" + +ansible_become_password: "{{ lookup('env', 'ANSIBLE_BECOME_PASSWORD', default='') }}" + +macos_dev_station_launchd_mode: "daemon" +macos_dev_station_service_user_enabled: true +macos_dev_station_service_user: "devstation" +macos_dev_station_service_group: "staff" +macos_dev_station_service_home: "/Users/devstation" +macos_dev_station_workspace_dir: "/Users/devstation/projects" +macos_dev_station_scripts_dir: "/Users/devstation/scripts" +macos_dev_station_logs_dir: "/Users/devstation/Library/Logs" + +macos_dev_station_code_server_host: "0.0.0.0" +macos_dev_station_code_server_port: 3000 +macos_dev_station_code_server_auth: "password" + +macos_dev_station_colima_enabled: true +macos_dev_station_colima_cpu: 4 +macos_dev_station_colima_memory: 8 + +macos_dev_station_opencode_server_host: "0.0.0.0" +macos_dev_station_opencode_server_port: 4096 +macos_dev_station_manage_launchd: true +macos_dev_station_oh_my_zsh_enabled: true + +macos_dev_station_code_server_extensions_enabled: true +macos_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 + - sst-dev.opencode + +macos_dev_station_mcp_bootstrap_commands: [] + +macos_dev_station_mcp_installers_enabled: true +macos_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" + +macos_dev_station_opencode_extra_environment: + GITEA_MCP_HOST: "https://code.alexpires.me" + GITEA_APP_TOKEN: "{{ lookup('env', 'GITEA_APP_TOKEN', default='') }}" + +macos_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='') }}" + 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" + "gemma4:e4b": + name: "gemma4:e4b (scaleway)" + "qwen3.5:9b": + name: "qwen3.5:9b (scaleway)" + gpt-oss-120b: + name: "gpt-oss-120b (scaleway)" + qwen3.5-397b-a17b: + name: "qwen3.5-397b-a17b (scaleway)" + qwen3.6-35b-a3b: + name: "qwen3.6-35b-a3b (scaleway)" + gemma-4-26b-a4b-it: + name: "gemma-4-26b-a4b-it (scaleway)" + 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 + +macos_dev_station_service_user_ssh_enabled: true +macos_dev_station_service_user_ssh_authorized_keys: + - >- + ecdsa-sha2-nistp521 + AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAHHOPBR9p9kq5Cqzpe4cr3jHnweaYrHPXv5sXNzt+sCXP54uc5rWUBhxW2OQVvQzJ47dEVhEKi4WSC7LcuKS2G5AQDzWXNiasHvYIYQU3F/EknVCZnsiXYqXphYkJA6rJCNRnISZCIC1mocq6PB7J08ONdRFCvjfUBuVRT8BNGXNmQ/zQ== diff --git a/ansible/playbook_macos_dev_station.yml b/ansible/playbook_macos_dev_station.yml new file mode 100644 index 0000000..7c2604f --- /dev/null +++ b/ansible/playbook_macos_dev_station.yml @@ -0,0 +1,14 @@ +--- +- name: Configure macOS dev station + hosts: darwin_dev + gather_facts: true + + pre_tasks: + - name: Ensure target is macOS + ansible.builtin.assert: + that: + - ansible_system == "Darwin" + fail_msg: "This playbook supports macOS hosts only" + + roles: + - macos_dev_station diff --git a/ansible/roles/macos_dev_station/defaults/main.yml b/ansible/roles/macos_dev_station/defaults/main.yml new file mode 100644 index 0000000..c7ce942 --- /dev/null +++ b/ansible/roles/macos_dev_station/defaults/main.yml @@ -0,0 +1,86 @@ +--- +macos_dev_station_launchd_mode: "daemon" # daemon-only role +macos_dev_station_service_user_enabled: true +macos_dev_station_service_user: "devstation" +macos_dev_station_service_group: "staff" +macos_dev_station_service_shell: "/usr/bin/false" +macos_dev_station_service_user_ssh_enabled: false +macos_dev_station_service_user_ssh_shell: "/bin/zsh" +macos_dev_station_service_user_ssh_authorized_keys: [] +macos_dev_station_service_home: "/Users/{{ macos_dev_station_service_user }}" + +macos_dev_station_workspace_dir: "{{ macos_dev_station_service_home }}/projects" +macos_dev_station_scripts_dir: "{{ macos_dev_station_service_home }}/scripts" +macos_dev_station_logs_dir: "{{ macos_dev_station_service_home }}/Library/Logs" +macos_dev_station_launchd_plist_dir: "/Library/LaunchDaemons" +macos_dev_station_path: >- + {{ macos_dev_station_service_home }}/.local/bin:{{ macos_dev_station_service_home }}/go/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + +macos_dev_station_code_server_host: "0.0.0.0" +macos_dev_station_code_server_port: 3000 +macos_dev_station_code_server_auth: "password" +macos_dev_station_code_server_password: "{{ lookup('env', 'CODE_SERVER_PASSWORD', default='') }}" +macos_dev_station_code_server_workspace: "{{ macos_dev_station_workspace_dir }}" +macos_dev_station_code_server_extensions_enabled: true +macos_dev_station_code_server_extensions_fail_on_error: false +macos_dev_station_code_server_extensions: + - ms-python.python + - ms-python.vscode-pylance + - ms-azuretools.vscode-docker + - hashicorp.terraform + - redhat.ansible + - eamodio.gitlens + +macos_dev_station_oh_my_zsh_enabled: true +macos_dev_station_oh_my_zsh_install_url: "https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh" + +macos_dev_station_colima_enabled: true +macos_dev_station_colima_cpu: 4 +macos_dev_station_colima_memory: 8 + +macos_dev_station_brew_packages: + - git + - tmux + - wget + - curl + - jq + - openssl + - uv + - node + - python + - go + - rust + - docker + - colima + - code-server + +macos_dev_station_npm_packages: [] + +macos_dev_station_git_init_default_branch: "main" +macos_dev_station_git_pull_rebase: false + +macos_dev_station_ssh_key_path: "{{ ansible_facts['env']['HOME'] }}/.ssh/id_ed25519" +macos_dev_station_ssh_key_comment: "macmini-dev" + +macos_dev_station_code_server_launchd_enabled: true +macos_dev_station_opencode_launchd_enabled: true +macos_dev_station_opencode_server_host: "127.0.0.1" +macos_dev_station_opencode_server_port: 4096 +macos_dev_station_opencode_server_username: "{{ lookup('env', 'OPENCODE_SERVER_USERNAME', default='') }}" +macos_dev_station_opencode_server_password: "{{ lookup('env', 'OPENCODE_SERVER_PASSWORD', default='') }}" +macos_dev_station_opencode_extra_environment: {} +macos_dev_station_opencode_command: "opencode serve --hostname {{ macos_dev_station_opencode_server_host }} --port {{ macos_dev_station_opencode_server_port }}" +macos_dev_station_opencode_config: {} +macos_dev_station_opencode_config_path: "{{ macos_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 +macos_dev_station_mcp_installers_enabled: true +macos_dev_station_mcp_installers: [] + +# Legacy ad-hoc hook kept for backward compatibility. +macos_dev_station_mcp_bootstrap_commands: [] +macos_dev_station_manage_launchd: true diff --git a/ansible/roles/macos_dev_station/handlers/main.yml b/ansible/roles/macos_dev_station/handlers/main.yml new file mode 100644 index 0000000..536ad97 --- /dev/null +++ b/ansible/roles/macos_dev_station/handlers/main.yml @@ -0,0 +1,60 @@ +--- +- name: Unload code-server launch agent if already loaded + become: true + ansible.builtin.command: >- + launchctl bootout system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.code-server.plist + failed_when: false + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + listen: Restart code-server launch agent + +- name: Load code-server launch agent + become: true + ansible.builtin.command: >- + launchctl bootstrap system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.code-server.plist + changed_when: true + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + listen: Restart code-server launch agent + +- name: Enable code-server launch agent + become: true + ansible.builtin.command: launchctl enable system/com.a13labs.code-server + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + listen: Restart code-server launch agent + +- name: Unload opencode launch agent if already loaded + become: true + ansible.builtin.command: >- + launchctl bootout system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.opencode.plist + failed_when: false + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + listen: Restart opencode launch agent + +- name: Load opencode launch agent + become: true + ansible.builtin.command: >- + launchctl bootstrap system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.opencode.plist + changed_when: true + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + listen: Restart opencode launch agent + +- name: Enable opencode launch agent + become: true + ansible.builtin.command: launchctl enable system/com.a13labs.opencode + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + listen: Restart opencode launch agent diff --git a/ansible/roles/macos_dev_station/tasks/main.yml b/ansible/roles/macos_dev_station/tasks/main.yml new file mode 100644 index 0000000..e042cf9 --- /dev/null +++ b/ansible/roles/macos_dev_station/tasks/main.yml @@ -0,0 +1,445 @@ +--- +- name: Ensure host is macOS + ansible.builtin.assert: + that: + - ansible_system == "Darwin" + fail_msg: "Role macos_dev_station supports macOS hosts only" + +- name: Ensure launchd mode is valid + ansible.builtin.assert: + that: + - macos_dev_station_launchd_mode == "daemon" + fail_msg: "macos_dev_station currently supports daemon mode only. Set macos_dev_station_launchd_mode to 'daemon'." + +- name: Check passwordless sudo availability for daemon mode + ansible.builtin.command: sudo -n true + register: __macos_dev_station_sudo_check + changed_when: false + failed_when: false + +- name: Ensure sudo access is available for daemon mode + ansible.builtin.assert: + that: + - __macos_dev_station_sudo_check.rc == 0 or (ansible_become_password | default('') | length > 0) + fail_msg: "Daemon mode needs sudo. Configure passwordless sudo for ansible user or set ANSIBLE_BECOME_PASSWORD via sectool." + +- name: Ensure OpenCode server credentials are provided + ansible.builtin.assert: + that: + - macos_dev_station_opencode_server_username | length > 0 + - macos_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: macos_dev_station_opencode_launchd_enabled | bool + +- name: Ensure code-server password is provided + ansible.builtin.assert: + that: + - macos_dev_station_code_server_password | length > 0 + fail_msg: "Set CODE_SERVER_PASSWORD in environment (recommended via sectool) before running the playbook." + when: + - macos_dev_station_code_server_launchd_enabled | bool + - macos_dev_station_code_server_auth == "password" + +- name: Install Homebrew packages + become: false + community.general.homebrew: + name: "{{ macos_dev_station_brew_packages }}" + state: present + +- name: Ensure dedicated service user exists + become: true + ansible.builtin.user: + name: "{{ macos_dev_station_service_user }}" + shell: "{{ (macos_dev_station_service_user_ssh_enabled | bool) | ternary(macos_dev_station_service_user_ssh_shell, macos_dev_station_service_shell) }}" + home: "{{ macos_dev_station_service_home }}" + create_home: true + state: present + when: macos_dev_station_service_user_enabled | bool + +- name: Discover npm executable path + ansible.builtin.command: /bin/sh -lc "command -v npm" + register: __macos_npm_path + changed_when: false + failed_when: __macos_npm_path.rc != 0 + environment: + PATH: "{{ macos_dev_station_path }}" + when: macos_dev_station_npm_packages | length > 0 + +- name: Install global npm packages + community.general.npm: + name: "{{ item.name }}" + version: "{{ item.version }}" + executable: "{{ __macos_npm_path.stdout | trim }}" + global: true + environment: + PATH: "{{ macos_dev_station_path }}" + loop: "{{ macos_dev_station_npm_packages }}" + when: macos_dev_station_npm_packages | length > 0 + +- name: Ensure required user directories exist + become: true + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0750" + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + loop: + - "{{ macos_dev_station_service_home }}" + - "{{ macos_dev_station_workspace_dir }}" + - "{{ macos_dev_station_scripts_dir }}" + - "{{ macos_dev_station_logs_dir }}" + +- name: Ensure service home permissions are restrictive + become: true + ansible.builtin.file: + path: "{{ macos_dev_station_service_home }}" + state: directory + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + mode: "0700" + +- name: Ensure service user config directories exist + become: true + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + mode: "0750" + loop: + - "{{ macos_dev_station_service_home }}/.cache" + - "{{ macos_dev_station_service_home }}/.local" + - "{{ macos_dev_station_service_home }}/.local/bin" + - "{{ macos_dev_station_service_home }}/.local/lib" + - "{{ macos_dev_station_service_home }}/.local/lib/node_modules" + - "{{ macos_dev_station_service_home }}/.config" + - "{{ macos_dev_station_service_home }}/.config/opencode" + when: macos_dev_station_service_user_enabled | bool + +- name: Ensure OpenCode config object is valid when provided + ansible.builtin.assert: + that: + - macos_dev_station_opencode_config is mapping + fail_msg: "macos_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: "{{ macos_dev_station_opencode_config_path }}" + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + mode: "0600" + notify: Restart opencode launch agent + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_opencode_config | length > 0 + +- name: Download Oh My Zsh installer + become: true + ansible.builtin.get_url: + url: "{{ macos_dev_station_oh_my_zsh_install_url }}" + dest: "{{ macos_dev_station_service_home }}/.cache/ohmyzsh-install.sh" + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + mode: "0755" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_oh_my_zsh_enabled | bool + +- name: Install Oh My Zsh for service user + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: >- + /bin/sh {{ macos_dev_station_service_home }}/.cache/ohmyzsh-install.sh --unattended + args: + creates: "{{ macos_dev_station_service_home }}/.oh-my-zsh" + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + RUNZSH: "no" + CHSH: "no" + KEEP_ZSHRC: "yes" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_oh_my_zsh_enabled | bool + +- name: Install managed MCP server runtimes + ansible.builtin.include_tasks: mcp_installers.yml + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_mcp_installers_enabled | bool + - macos_dev_station_mcp_installers | length > 0 + +- name: Run MCP bootstrap commands + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.shell: "{{ item }}" + register: __mcp_bootstrap_result + changed_when: __mcp_bootstrap_result.rc == 0 + args: + executable: /bin/sh + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + loop: "{{ macos_dev_station_mcp_bootstrap_commands }}" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_mcp_bootstrap_commands | length > 0 + +- name: List installed code-server extensions for service user + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: code-server --list-extensions + register: __code_server_extensions_installed + changed_when: false + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_code_server_extensions_enabled | bool + - macos_dev_station_code_server_extensions | length > 0 + +- name: Install base code-server extensions for service user + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: >- + code-server --install-extension {{ item }} + register: __code_server_extension_install + changed_when: __code_server_extension_install.rc == 0 + failed_when: >- + (macos_dev_station_code_server_extensions_fail_on_error | bool) + and (__code_server_extension_install.rc != 0) + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + notify: Restart code-server launch agent + loop: "{{ macos_dev_station_code_server_extensions }}" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_code_server_extensions_enabled | bool + - item not in (__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: "{{ __code_server_extension_install.results | default([]) }}" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_code_server_extensions_enabled | bool + - not (macos_dev_station_code_server_extensions_fail_on_error | bool) + - item.rc is defined + - item.rc != 0 + +- name: Ensure devstation SSH directory exists when SSH access is enabled + become: true + ansible.builtin.file: + path: "{{ macos_dev_station_service_home }}/.ssh" + state: directory + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + mode: "0700" + when: + - macos_dev_station_service_user_enabled | bool + - macos_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: "{{ macos_dev_station_service_user }}" + key: "{{ item }}" + state: present + loop: "{{ macos_dev_station_service_user_ssh_authorized_keys }}" + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_service_user_ssh_enabled | bool + +- name: Check whether macOS Remote Login allowlist group exists + become: true + ansible.builtin.command: dscl . -read /Groups/com.apple.access_ssh + register: __macos_access_ssh_group + changed_when: false + failed_when: false + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_service_user_ssh_enabled | bool + +- name: Check whether service user is in macOS Remote Login allowlist + become: true + ansible.builtin.command: >- + dseditgroup -o checkmember -m {{ macos_dev_station_service_user }} com.apple.access_ssh + register: __macos_access_ssh_member + changed_when: false + failed_when: false + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_service_user_ssh_enabled | bool + - __macos_access_ssh_group.rc == 0 + +- name: Ensure service user is allowed by macOS Remote Login allowlist + become: true + ansible.builtin.command: >- + dseditgroup -o edit -a {{ macos_dev_station_service_user }} -t user com.apple.access_ssh + changed_when: true + when: + - macos_dev_station_service_user_enabled | bool + - macos_dev_station_service_user_ssh_enabled | bool + - __macos_access_ssh_group.rc == 0 + - __macos_access_ssh_member.rc != 0 + +- name: Remove legacy user launch agent plists for hard mode + ansible.builtin.file: + path: "{{ ansible_facts['env']['HOME'] }}/Library/LaunchAgents/{{ item }}" + state: absent + loop: + - com.a13labs.code-server.plist + - com.a13labs.opencode.plist + +- name: Ensure SSH directory exists + ansible.builtin.file: + path: "{{ ansible_facts['env']['HOME'] }}/.ssh" + state: directory + mode: "0700" + +- name: Generate SSH key if missing + ansible.builtin.command: >- + ssh-keygen -t ed25519 -C {{ macos_dev_station_ssh_key_comment }} + -f {{ macos_dev_station_ssh_key_path }} -N '' + args: + creates: "{{ macos_dev_station_ssh_key_path }}" + +- name: Configure Git init default branch + community.general.git_config: + name: init.defaultBranch + scope: global + value: "{{ macos_dev_station_git_init_default_branch }}" + +- name: Configure Git pull.rebase + community.general.git_config: + name: pull.rebase + scope: global + value: "{{ macos_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: "{{ macos_dev_station_scripts_dir }}/start-ide.sh" + mode: "0755" + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + +- name: Render opencode launcher script + become: true + ansible.builtin.template: + src: start-opencode.sh.j2 + dest: "{{ macos_dev_station_scripts_dir }}/start-opencode.sh" + mode: "0755" + owner: "{{ macos_dev_station_service_user }}" + group: "{{ macos_dev_station_service_group }}" + +- 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: Ensure Colima runtime is started + ansible.builtin.command: >- + colima start --cpu {{ macos_dev_station_colima_cpu }} --memory {{ macos_dev_station_colima_memory }} + register: __colima_start + changed_when: "'already running' not in ((__colima_start.stdout | default('')) + (__colima_start.stderr | default('')))" + when: macos_dev_station_colima_enabled | bool + environment: + PATH: "{{ macos_dev_station_path }}" + +- name: Render code-server launchd unit + become: true + ansible.builtin.template: + src: code-server.plist.j2 + dest: "{{ macos_dev_station_launchd_plist_dir }}/com.a13labs.code-server.plist" + mode: "0644" + owner: root + group: wheel + when: macos_dev_station_code_server_launchd_enabled | bool + notify: Restart code-server launch agent + +- name: Render opencode launchd unit + become: true + ansible.builtin.template: + src: opencode.plist.j2 + dest: "{{ macos_dev_station_launchd_plist_dir }}/com.a13labs.opencode.plist" + mode: "0644" + owner: root + group: wheel + when: macos_dev_station_opencode_launchd_enabled | bool + notify: Restart opencode launch agent + +- name: Check code-server daemon status + become: true + ansible.builtin.command: launchctl print system/com.a13labs.code-server + register: __code_server_daemon_status + failed_when: false + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + +- name: Bootstrap code-server daemon if missing + become: true + ansible.builtin.command: >- + launchctl bootstrap system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.code-server.plist + changed_when: true + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + - __code_server_daemon_status.rc != 0 + +- name: Ensure code-server daemon is enabled + become: true + ansible.builtin.command: launchctl enable system/com.a13labs.code-server + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_code_server_launchd_enabled | bool + +- name: Check opencode daemon status + become: true + ansible.builtin.command: launchctl print system/com.a13labs.opencode + register: __opencode_daemon_status + failed_when: false + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + +- name: Bootstrap opencode daemon if missing + become: true + ansible.builtin.command: >- + launchctl bootstrap system {{ macos_dev_station_launchd_plist_dir }}/com.a13labs.opencode.plist + changed_when: true + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + - __opencode_daemon_status.rc != 0 + +- name: Ensure opencode daemon is enabled + become: true + ansible.builtin.command: launchctl enable system/com.a13labs.opencode + changed_when: false + when: + - macos_dev_station_manage_launchd | bool + - macos_dev_station_opencode_launchd_enabled | bool + +- name: Ensure launch agents are loaded now + ansible.builtin.meta: flush_handlers + when: macos_dev_station_manage_launchd | bool diff --git a/ansible/roles/macos_dev_station/tasks/mcp_installers.yml b/ansible/roles/macos_dev_station/tasks/mcp_installers.yml new file mode 100644 index 0000000..8f35758 --- /dev/null +++ b/ansible/roles/macos_dev_station/tasks/mcp_installers.yml @@ -0,0 +1,132 @@ +--- +- name: Ensure MCP installer definitions list is valid + ansible.builtin.assert: + that: + - macos_dev_station_mcp_installers is iterable + fail_msg: "macos_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: "{{ macos_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: "{{ macos_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: "{{ macos_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: "{{ macos_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'go' + +- name: Discover npm executable path for MCP installers + ansible.builtin.command: /bin/sh -lc "command -v npm" + register: __macos_mcp_npm_path + changed_when: false + failed_when: __macos_mcp_npm_path.rc != 0 + environment: + PATH: "{{ macos_dev_station_path }}" + when: (macos_dev_station_mcp_installers | selectattr('kind', 'equalto', 'npm') | list | length) > 0 + +- name: Install npm MCP packages for service user + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: + argv: + - "{{ __macos_mcp_npm_path.stdout | trim }}" + - install + - --prefix + - "{{ macos_dev_station_service_home }}/.local" + - --global + - "{{ item.package }}@{{ item.version | default('latest') }}" + args: + creates: "{{ macos_dev_station_service_home }}/.local/lib/node_modules/{{ item.package }}" + register: __mcp_npm_install + changed_when: __mcp_npm_install.rc == 0 + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + notify: Restart opencode launch agent + loop: "{{ macos_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'npm' + +- name: Install uv MCP tools for service user + become: true + become_user: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: + argv: + - uv + - tool + - install + - "{{ item.package }}@{{ item.version | default('latest') }}" + args: + creates: "{{ macos_dev_station_service_home }}/.local/bin/{{ item.binary }}" + register: __mcp_uv_install + changed_when: __mcp_uv_install.rc == 0 + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + notify: Restart opencode launch agent + loop: "{{ macos_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: "{{ macos_dev_station_service_user }}" + ansible.builtin.command: + argv: + - go + - install + - "{{ item.module }}@{{ item.version | default('latest') }}" + args: + creates: "{{ macos_dev_station_service_home }}/.local/bin/{{ item.binary }}" + register: __mcp_go_install + changed_when: __mcp_go_install.rc == 0 + environment: + HOME: "{{ macos_dev_station_service_home }}" + USER: "{{ macos_dev_station_service_user }}" + PATH: "{{ macos_dev_station_path }}" + GOBIN: "{{ macos_dev_station_service_home }}/.local/bin" + notify: Restart opencode launch agent + loop: "{{ macos_dev_station_mcp_installers }}" + when: + - item.enabled | default(true) | bool + - item.kind == 'go' diff --git a/ansible/roles/macos_dev_station/templates/code-server.plist.j2 b/ansible/roles/macos_dev_station/templates/code-server.plist.j2 new file mode 100644 index 0000000..a01590f --- /dev/null +++ b/ansible/roles/macos_dev_station/templates/code-server.plist.j2 @@ -0,0 +1,40 @@ + + + + + Label + com.a13labs.code-server + + UserName + {{ macos_dev_station_service_user }} + + WorkingDirectory + {{ macos_dev_station_workspace_dir }} + + ProgramArguments + + /bin/bash + {{ macos_dev_station_scripts_dir }}/start-ide.sh + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {{ macos_dev_station_logs_dir }}/code-server.log + + StandardErrorPath + {{ macos_dev_station_logs_dir }}/code-server.err.log + + EnvironmentVariables + + PATH + {{ macos_dev_station_path }} + PASSWORD + {{ macos_dev_station_code_server_password }} + + + diff --git a/ansible/roles/macos_dev_station/templates/opencode.json.j2 b/ansible/roles/macos_dev_station/templates/opencode.json.j2 new file mode 100644 index 0000000..cf36003 --- /dev/null +++ b/ansible/roles/macos_dev_station/templates/opencode.json.j2 @@ -0,0 +1 @@ +{{ macos_dev_station_opencode_config | to_nice_json }} diff --git a/ansible/roles/macos_dev_station/templates/opencode.plist.j2 b/ansible/roles/macos_dev_station/templates/opencode.plist.j2 new file mode 100644 index 0000000..88f371c --- /dev/null +++ b/ansible/roles/macos_dev_station/templates/opencode.plist.j2 @@ -0,0 +1,46 @@ + + + + + Label + com.a13labs.opencode + + UserName + {{ macos_dev_station_service_user }} + + WorkingDirectory + {{ macos_dev_station_workspace_dir }} + + ProgramArguments + + /bin/bash + {{ macos_dev_station_scripts_dir }}/start-opencode.sh + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {{ macos_dev_station_logs_dir }}/opencode.log + + StandardErrorPath + {{ macos_dev_station_logs_dir }}/opencode.err.log + + EnvironmentVariables + + PATH + {{ macos_dev_station_path }} + OPENCODE_SERVER_USERNAME + {{ macos_dev_station_opencode_server_username }} + OPENCODE_SERVER_PASSWORD + {{ macos_dev_station_opencode_server_password }} +{% for env_key, env_value in macos_dev_station_opencode_extra_environment.items() %} + {{ env_key }} + {{ env_value }} +{% endfor %} + + + diff --git a/ansible/roles/macos_dev_station/templates/start-ide.sh.j2 b/ansible/roles/macos_dev_station/templates/start-ide.sh.j2 new file mode 100644 index 0000000..a108118 --- /dev/null +++ b/ansible/roles/macos_dev_station/templates/start-ide.sh.j2 @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd {{ macos_dev_station_workspace_dir }} + +exec code-server \ + --bind-addr {{ macos_dev_station_code_server_host }}:{{ macos_dev_station_code_server_port }} \ + --auth {{ macos_dev_station_code_server_auth }} \ + --disable-telemetry \ + {{ macos_dev_station_code_server_workspace }} diff --git a/ansible/roles/macos_dev_station/templates/start-opencode.sh.j2 b/ansible/roles/macos_dev_station/templates/start-opencode.sh.j2 new file mode 100644 index 0000000..cd9f7eb --- /dev/null +++ b/ansible/roles/macos_dev_station/templates/start-opencode.sh.j2 @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd {{ macos_dev_station_workspace_dir }} + +exec {{ macos_dev_station_opencode_command }} diff --git a/ansible/sectool.env b/ansible/sectool.env index 4eea01a..5d21237 100644 --- a/ansible/sectool.env +++ b/ansible/sectool.env @@ -16,4 +16,8 @@ CLOUDNS_AUTH_ID=$CLOUDNS_AUTH_ID CLOUDNS_PASSWORD=$CLOUDNS_PASSWORD WIN_PROVISION_PASSWORD=$WIN_PROVISION_PASSWORD WIN_GAME_PASSWORD=$WIN_GAME_PASSWORD -WIN_CI_01_PASSWORD=$WIN_CI_01_PASSWORD \ No newline at end of file +WIN_CI_01_PASSWORD=$WIN_CI_01_PASSWORD +OPENCODE_SERVER_USERNAME=$OPENCODE_SERVER_USERNAME +OPENCODE_SERVER_PASSWORD=$OPENCODE_SERVER_PASSWORD +CODE_SERVER_PASSWORD=$CODE_SERVER_PASSWORD +OPEN_WEBUI_API_KEY=$OPEN_WEBUI_API_KEY diff --git a/inventory/hosts b/inventory/hosts index 0f74d58..5a13f51 100644 --- a/inventory/hosts +++ b/inventory/hosts @@ -138,3 +138,6 @@ ci-01.lab.alexpires.me [windows_gitea_runner_windows] ci-01.lab.alexpires.me +[darwin_dev] +mac-01.lab.alexpires.me + diff --git a/scripts/setup-dev-station.sh b/scripts/setup-dev-station.sh new file mode 100755 index 0000000..55d52eb --- /dev/null +++ b/scripts/setup-dev-station.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +LOG_FILE="${HOME}/.setup-dev-station.log" + +log() { + printf '[%s] %s\n' "${SCRIPT_NAME}" "$*" | tee -a "${LOG_FILE}" +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + log "This script is for macOS only." + exit 1 +fi + +log "Starting macOS bootstrap for dev station prerequisites." + +if ! xcode-select -p >/dev/null 2>&1; then + log "Installing Xcode Command Line Tools (follow GUI prompt once)." + xcode-select --install || true +else + log "Xcode Command Line Tools already installed." +fi + +if ! command -v brew >/dev/null 2>&1; then + log "Installing Homebrew." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +else + log "Homebrew already installed." +fi + +if [[ -x /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" +fi + +if ! grep -Fq '/opt/homebrew/bin/brew shellenv' "${HOME}/.zprofile" 2>/dev/null; then + log "Persisting Homebrew shellenv into ~/.zprofile." + printf '\n# Homebrew\neval "$(/opt/homebrew/bin/brew shellenv)"\n' >> "${HOME}/.zprofile" +else + log "Homebrew shellenv already present in ~/.zprofile." +fi + +log "Updating Homebrew metadata." +brew update + +log "Installing bootstrap packages." +brew install git curl jq python ansible + +log "Preparing workspace directories." +mkdir -p "${HOME}/projects" "${HOME}/scripts" + +log "Bootstrap complete." +log "Next step: run Ansible playbook for full dev-station configuration:" +log " cd ~/projects/a13labs.infra/ansible" +log " ansible-playbook playbook_macos_dev_station.yml --limit darwin_dev" \ No newline at end of file diff --git a/terraform/apps/dev-01/config.tf b/terraform/apps/dev-01/config.tf index f5ac8d4..a270290 100644 --- a/terraform/apps/dev-01/config.tf +++ b/terraform/apps/dev-01/config.tf @@ -32,7 +32,6 @@ locals { ip = "10.19.4.136" }, ] - zones = [ { fqdn = "${local.lab_domain}" @@ -52,31 +51,21 @@ locals { type = "A" content = "10.19.4.136" }, + { + name = "mac-01" + type = "A" + content = "10.19.4.203" + }, { name = "ollama" type = "CNAME" content = "dev-01.${local.lab_domain}." }, - { - name = "kokoro" - type = "CNAME" - content = "gpu-01.${local.lab_domain}." - }, { name = "open-webui" type = "CNAME" content = "dev-01.${local.lab_domain}." }, - { - name = "comfyui-backend" - type = "CNAME" - content = "gpu-01.${local.lab_domain}." - }, - { - name = "comfyui" - type = "CNAME" - content = "dev-01.${local.lab_domain}." - }, { name = "smb" type = "CNAME" @@ -131,7 +120,17 @@ locals { name = "terraform-mcp" type = "CNAME" content = "dev-01.${local.lab_domain}." - } + }, + { + name = "opencode" + type = "CNAME" + content = "dev-01.${local.lab_domain}." + }, + { + name = "vscode" + type = "CNAME" + content = "dev-01.${local.lab_domain}." + }, ] } ] @@ -153,6 +152,38 @@ locals { # }, # ] # } + "opencode" = { + http_port = 4096 + tls = true + fqdn = ["opencode.lab.alexpires.me"] + upstream = "http://10.19.4.203:4096" + read_timeout = "3600s" + + locations = [ + { + path = "/" + headers = { + "Upgrade" = "$http_upgrade" + "Connection" = "upgrade" + } + }, + ] + }, + "code-server" = { + http_port = 3000 + tls = true + fqdn = ["vscode.lab.alexpires.me"] + upstream = "http://10.19.4.203:3000" + locations = [ + { + path = "/" + headers = { + "Upgrade" = "$http_upgrade" + "Connection" = "upgrade" + } + }, + ] + } } streams = {