diff --git a/.env.example b/.env.example index e1fb4c2..145a7a5 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Copy to .env and fill in. .env is git-ignored. +# +# Compose reads this file for the ${...} values in docker-compose.yml and also +# bind-mounts it into the VM's shared folder, where oem/install.ps1 reads it. +# Keep one KEY=VALUE per line, comments on their own lines, no "$" in values. -# --- Runner registration --------------------------------------------------- +# --- Runner registration ---------------------------------------------------- # Org: https://github.com/ # Repo: https://github.com// RUNNER_URL= @@ -13,13 +17,48 @@ PAT= # Number of runners registered from this VM RUNNERS=2 -# Optional overrides of the image defaults +# Pin an actions/runner release; empty means the latest release at install time # GITHUB_RUNNER_VERSION=2.338.0 -# GITHUB_RUNNER_NAME=windows_x64_vagrant -# GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_vagrant_action - -# --- VM sizing ------------------------------------------------------------- -# MEMORY in MiB, DISK_SIZE in GiB -MEMORY=8000 -CPU=4 -DISK_SIZE=100 + +# Runner name prefix and labels +# GITHUB_RUNNER_NAME=windows_x64_dockur +# GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_dockur_action +# GITHUB_RUNNER_GROUP= + +# Middle part of the runner name (__). Defaults to the +# Windows computer name, which dockur picks at random on install. Set it when +# you run several VMs against the same org or want names that survive a wipe. +# RUNNER_HOST_ID= + +# service (default): Windows services that start with the VM, no desktop. +# interactive: scheduled tasks in the auto-logon desktop session, for jobs +# that drive GUI tooling. +# RUNNER_MODE=service + +# Account the runner services run as. Default is the Windows user below. +# Built-in accounts need no password, e.g. NT AUTHORITY\NETWORK SERVICE +# RUNNER_SERVICE_ACCOUNT= + +# --- Toolchain -------------------------------------------------------------- +# Git, jq, 7-Zip, PowerShell 7 and Chocolatey are always installed. +# INSTALL_VISUAL_STUDIO=true +# enterprise | professional | community | buildtools (see licensing note in README) +# VS_EDITION=enterprise +# Comma-separated workload ids. For buildtools use Microsoft.VisualStudio.Workload.VCTools +# VS_WORKLOADS=Microsoft.VisualStudio.Workload.NativeDesktop +# INSTALL_RTOOLS=true + +# --- VM (dockur/windows) ---------------------------------------------------- +# All optional; these are the defaults from docker-compose.yml. +# Windows release; 2022 = Windows Server 2022 evaluation +VERSION=2022 +# RAM_SIZE and CPU_CORES apply at the next 'docker compose down && up'. +# DISK_SIZE can only grow; C: is extended by install.ps1 at the next boot. +# Never lower it (or drop it after raising it): dockur refuses to shrink the +# disk and the container will not start until the value is put back. +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=100G +# Local administrator created by the unattended install; also the RDP login +WIN_USERNAME=admin +WIN_PASSWORD=runner diff --git a/.github/PSScriptAnalyzerSettings.psd1 b/.github/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..afc0108 --- /dev/null +++ b/.github/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,13 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + # Console output of a one-shot provisioning script; the transcript captures it. + 'PSAvoidUsingWriteHost', + # Microsoft's documented one-liners for install-powershell.ps1 and Chocolatey. + 'PSAvoidUsingInvokeExpression', + # Install-Runners / Install-BaseTools read better plural. + 'PSUseSingularNouns', + # Internal helpers of a non-interactive script; -WhatIf is meaningless here. + 'PSUseShouldProcessForStateChangingFunctions' + ) +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0dbf6e4..dcd732d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,8 +9,8 @@ updates: patterns: - "*" - # Tracks the base image tag in the Dockerfile - - package-ecosystem: docker + # Tracks the dockurr/windows tag in docker-compose.yml + - package-ecosystem: docker-compose directory: / schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9ad8601..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Windows custom runner - -on: - release: - types: [published] - -permissions: - contents: read - packages: write - -jobs: - publish-image: - runs-on: ubuntu-latest - name: publish image - steps: - - name: Remove unnecessary files - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - - - name: Checkout current repository - uses: actions/checkout@v7 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - # list of Docker images to use as base name for tags - images: | - ghcr.io/${{ github.repository }} - docker.io/${{ github.repository }} - # generate Docker tags based on the following events/attributes - tags: | - type=semver,pattern={{version}} - type=raw,value=latest - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to Github packages - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Dockerhub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_REGISTRY_USERNAME }} - password: ${{ secrets.DOCKERHUB_REGISTRY_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: . - file: Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8a80a1f..1d7a522 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,13 +16,19 @@ jobs: - name: Checkout current repository uses: actions/checkout@v7 - - name: Dockerfile (hadolint) - uses: hadolint/hadolint-action@v3.5.0 - with: - dockerfile: Dockerfile + - name: docker-compose.yml + run: cp .env.example .env && docker compose config -q - - name: Vagrantfile (Ruby syntax) - run: ruby -c Vagrantfile + - name: install.ps1 (parse + PSScriptAnalyzer) + shell: pwsh + run: | + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile("$PWD/oem/install.ps1", [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + $findings = Invoke-ScriptAnalyzer -Path oem/install.ps1 -Settings .github/PSScriptAnalyzerSettings.psd1 + $findings | Format-Table -AutoSize + if ($findings) { exit 1 } - name: Workflows (actionlint) uses: raven-actions/actionlint@v2 diff --git a/.gitignore b/.gitignore index bd99fe3..4eb04f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Local configuration (contains PAT/TOKEN) .env -# Vagrant state -.vagrant/ +# VM disk, install ISO and the shared folder +storage/ +shared/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0eda78f..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM ghcr.io/vaggeliskls/windows-in-docker-container:1.0.1 - -LABEL org.opencontainers.image.source="https://github.com/vaggeliskls/windows-github-custom-runner" \ - org.opencontainers.image.description="Self-hosted GitHub Actions runners on a Windows VM (Vagrant + libvirt) inside a Linux container" - -# GitHub Actions runner defaults. All of these can be overridden at runtime via .env; -# the download URL is derived from GITHUB_RUNNER_VERSION inside the Vagrantfile. -ENV GITHUB_RUNNER_VERSION=2.338.0 -ENV GITHUB_RUNNER_NAME=windows_x64_vagrant -ENV GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_vagrant_action - -# Provision elevated and in an interactive session so runners can drive GUI tooling -ENV PRIVILEGED=true -ENV INTERACTIVE=true - -# Replace the base image's generic Vagrantfile with the runner one. -# The base image's /app/startup.sh (libvirtd startup, KVM/QEMU fallback, graceful halt) is reused as-is. -COPY Vagrantfile /app/Vagrantfile diff --git a/README.md b/README.md index 3c98227..84611db 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,160 @@ # 🏃 Windows Github Custom Runner -Explore an innovative, efficient, and cost-effective approach to deploying a custom GitHub Runner that runs in a containerized Windows OS (x64) environment on a Linux system. This project leverages the robust capabilities of Vagrant VM, libvirt, and Docker Compose, which allows for seamless management of a Windows instance just like any Docker container. The added value here lies in the creation of a plug-and-play solution, significantly enhancing convenience, optimizing resource allocation, and integrating flawlessly with existing workflows. This strategy enriches CI/CD pipeline experiences in various dev-ops environments, providing a smooth and comprehensive approach that does not require prior knowledge of VM creation. +Self-hosted GitHub Actions runners on a Windows Server 2022 VM that you manage like any other Docker container on a Linux host. The container is [dockur/windows](https://github.com/dockur/windows): it installs Windows from the Microsoft evaluation ISO on first start, then this repo's `oem/` scripts install the build toolchain and register the runners. Later starts just boot the installed VM. No custom image, no Vagrant, no libvirt, no privileged container. ⭐ **Don't forget to star the project if it helped you!** +> **Version 2 replaces the Vagrant/libvirt image.** Releases up to 1.3.0 shipped a container image that ran the VM through Vagrant and libvirt and re-provisioned it on every start. That approach is deprecated and no longer maintained. See [Migrating from 1.x](#-migrating-from-1x). + # 📋 Prerequisites -- **Linux host** with hardware virtualization enabled and `/dev/kvm` present. - Check with `lscpu | grep -i Virtualization` (`VT-x` or `AMD-V`). Without KVM the VM falls back to plain QEMU and is several times slower. +- **Linux host** with hardware virtualization enabled: `/dev/kvm` and `/dev/net/tun` present. Check with `lscpu | grep -i Virtualization` (`VT-x` or `AMD-V`). - [Docker](https://www.docker.com/) 20 or higher with Compose v2 (`docker compose`). -- `privileged: true` and `cgroup: host` in the compose file — libvirt needs both. +- Disk: `DISK_SIZE` (default 100 GB) plus about 10 GB for the install ISO under `storage/`. # 🚥 Authentication for Self-Hosted Runners Two authentication methods are supported. Set **one** of them in `.env`: -1. Personal Access Token (`PAT`) — a long-lived token you create on GitHub. It needs the *Self-hosted runners: Read and write* permission for organization runners (or admin access to the repository for repository runners). The runner only uses it to fetch a registration token; it is not stored in the VM. -2. Registration Token (`TOKEN`) — the short-lived token GitHub shows under *Settings → Actions → Runners → New self-hosted runner*. It expires after one hour, so it must be fresh when the container starts. +1. Personal Access Token (`PAT`) — a long-lived token you create on GitHub. It needs the *Self-hosted runners: Read and write* permission for organization runners (or admin access to the repository for repository runners). The runner only uses it to fetch a registration token. +2. Registration Token (`TOKEN`) — the short-lived token GitHub shows under *Settings → Actions → Runners → New self-hosted runner*. It expires after one hour, so it must be fresh when the first start reaches the runner step (40 minutes or more in). Prefer `PAT`. # 🚀 Deployment Guide -1. Create `.env` from the template and fill it in: +1. Clone the repository and create `.env` from the template: ```bash + git clone https://github.com/vaggeliskls/windows-github-custom-runner.git + cd windows-github-custom-runner cp .env.example .env ``` - | Variable | Required | Description | + | Variable | Default | Description | |---|---|---| - | `RUNNER_URL` | yes | Org (`https://github.com/`) or repo (`https://github.com//`) the runners register with | - | `PAT` / `TOKEN` | one of | See [Authentication](#-authentication-for-self-hosted-runners) | - | `RUNNERS` | no (`1`) | Number of runners started in the VM | - | `MEMORY` | no (`8000`) | VM memory in MiB | - | `CPU` | no (`4`) | VM vCPUs | - | `DISK_SIZE` | no (`100`) | VM disk in GiB | - | `GITHUB_RUNNER_VERSION` | no | Override the [actions/runner](https://github.com/actions/runner/releases) version baked into the image — no rebuild needed | - | `GITHUB_RUNNER_NAME` | no | Runner name prefix (default `windows_x64_vagrant`) | - | `GITHUB_RUNNER_LABELS` | no | Comma-separated labels (default `windows,win_x64,windows_x64,windows_vagrant_action`) | - -2. Create `docker-compose.yml`: - - ```yaml - services: - windows-github-runner-vm: - image: docker.io/vaggeliskls/windows-github-custom-runner:latest - platform: linux/amd64 - hostname: windows-github-runner - env_file: .env - stdin_open: true - tty: true - privileged: true - cgroup: host - restart: unless-stopped - ports: - - 3389:3389 # RDP - - 2222:2222 # SSH + | `RUNNER_URL` | required | Org (`https://github.com/`) or repo (`https://github.com//`) the runners register with | + | `PAT` / `TOKEN` | one required | See [Authentication](#-authentication-for-self-hosted-runners) | + | `RUNNERS` | `1` | Number of runners started in the VM | + | `GITHUB_RUNNER_VERSION` | latest | [actions/runner](https://github.com/actions/runner/releases) release, without the `v` | + | `GITHUB_RUNNER_NAME` | `windows_x64_dockur` | Runner name prefix | + | `GITHUB_RUNNER_LABELS` | `windows,win_x64,windows_x64,windows_dockur_action` | Comma-separated labels | + | `GITHUB_RUNNER_GROUP` | | Runner group (organization runners only) | + | `RUNNER_HOST_ID` | Windows computer name | Middle part of the runner name, see [Runner names](#-runner-names-and-modes) | + | `RUNNER_MODE` | `service` | `service` or `interactive`, see [Runner names](#-runner-names-and-modes) | + | `RUNNER_SERVICE_ACCOUNT` | the Windows user | Service logon account. Built-in accounts such as `NT AUTHORITY\NETWORK SERVICE` need no password | + | `INSTALL_VISUAL_STUDIO` | `true` | Install Visual Studio 2022 | + | `VS_EDITION` | `enterprise` | `enterprise`, `professional`, `community` or `buildtools` | + | `VS_WORKLOADS` | `Microsoft.VisualStudio.Workload.NativeDesktop` | Comma-separated workload ids; `buildtools` wants `Microsoft.VisualStudio.Workload.VCTools` | + | `INSTALL_RTOOLS` | `true` | Install Rtools 4.0 with `mingw-w64-x86_64-make` | + | `VERSION` | `2022` | dockur Windows release (`2022` = Windows Server 2022 evaluation) | + | `RAM_SIZE` / `CPU_CORES` / `DISK_SIZE` | `8G` / `4` / `100G` | VM size | + | `WIN_USERNAME` / `WIN_PASSWORD` | `admin` / `runner` | Local administrator created by the install, also the RDP login | + +2. Start it and follow the container side (ISO download, QEMU boot): + + ```bash + docker compose up -d + docker compose logs -f + ``` + +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the same log is copied to the host every 2 seconds: + + ```bash + tail -f shared/install.log ``` -3. Run `docker compose up -d` and follow progress with `docker compose logs -f`. + `shared/install.done` or `shared/install.failed` appears when it finishes. The first lines take a few minutes to appear while the shared folder comes up. + +> **First start takes 40 to 70 minutes**: ISO download (about 5 GB), Windows setup, Visual Studio, Rtools, runner registration. The runners register at the very end. Later starts take a minute or two and need no provisioning. + +The [docker-compose.yml](docker-compose.yml) is self-contained. If you do not want to clone the repo, copy it together with `oem/` and `.env.example`. + +## How configuration reaches the VM + +`.env` is used twice. Compose reads it for the `${...}` values in [docker-compose.yml](docker-compose.yml) (VM size, Windows user). It is also bind-mounted to `/shared/runner.env`, which dockur exports to the VM as `\\host.lan\Data\runner.env` (drive `Z:`). [install.ps1](oem/install.ps1) reads `RUNNER_URL`, `PAT`, `RUNNERS` and the rest from there. + +> **Create `.env` before the first `docker compose up`.** If it is missing, Docker creates `.env` as an empty *directory* in its place, the VM sees a folder named `runner.env`, and `install.log` ends with `runner.env is a directory, not a file`. To recover: `docker compose down`, `rmdir .env`, create `.env` from the template, `docker compose up -d`. The VM finishes the install at the next boot, see [Changing settings later](#-changing-settings-later). + +> **Remove the mount after the first install.** The shared folder is readable by every process in the VM, including the CI jobs your runners execute, so a workflow could read the `PAT` and the Windows password from `Z:\runner.env`. Once `shared/install.done` appears, comment out the `./.env:/shared/runner.env` line in [docker-compose.yml](docker-compose.yml) and run `docker compose up -d`. Put it back when you change `.env`; without it the logon check described below has nothing to apply. A one-hour `TOKEN` limits the exposure further. -> **First boot takes a long time.** The Windows box is baked into the image, but the VM still has to boot and run the full provisioning (Chocolatey, Visual Studio, Rtools, runner download). Expect 20–40 minutes depending on hardware and bandwidth; the runners register at the very end, once the toolchain is installed. +The `oem/` folder itself holds no secrets on purpose: dockur bakes it into the install ISO that stays in `storage/`, and a PAT should not be kept there. `.env`, `storage/` and `shared/` are git-ignored. -## Runner names +# 🏷 Runner names and modes -Runners are registered as `__`, e.g. `windows_x64_vagrant_windows-github-runner_1`. The names are deliberately stable: when the container restarts, `config.cmd --replace` takes over the existing registrations instead of leaving offline duplicates behind in GitHub. +Runners register as `__`, e.g. `windows_x64_dockur_WIN-ABC123_1`. dockur gives the VM a random computer name at install time, so set `RUNNER_HOST_ID` if you run several VMs against the same org or want names that survive a wipe of `storage/`. Registration uses `--replace`, so re-running takes over an existing registration instead of leaving offline duplicates in GitHub. -If you run more than one container against the same org or repo, give each one a different `hostname:` in its compose file. +- **service** (default): each runner is a Windows service that runs as the Windows user and starts with the VM. No desktop session, which is fine for command-line builds. +- **interactive**: each runner is a scheduled task in the auto-logon desktop session, restarted at every logon. Use it for jobs that drive GUI tooling. This matches what the 1.x image did. # 🧰 What's in the VM -Provisioning installs, on top of the base [Windows Server 2022 box](https://github.com/vaggeliskls/windows-in-docker-container): +On top of Windows Server 2022, [install.ps1](oem/install.ps1) installs: - PowerShell 7, Chocolatey, 7-Zip, Git, jq -- Visual Studio 2022 Enterprise with the *Desktop development with C++* workload (`--includeRecommended`) +- Visual Studio 2022 Enterprise with the *Desktop development with C++* workload (`--includeRecommended`), configurable through `VS_EDITION` and `VS_WORKLOADS` - Rtools 4.0 with `mingw-w64-x86_64-make` -- Long paths enabled, firewall disabled, `C:` grown to the full virtual disk +- Long paths enabled system-wide and in Git; `C:` grown to the full virtual disk -> **Licensing:** Visual Studio Enterprise requires a matching subscription for everyone whose builds use this runner. If that does not fit, fork the repo and switch [Vagrantfile](Vagrantfile) to `vs_buildtools.exe` with the `Microsoft.VisualStudio.Workload.VCTools` workload. +> **Licensing:** Visual Studio Enterprise requires a matching subscription for everyone whose builds use this runner. Set `VS_EDITION=buildtools` and `VS_WORKLOADS=Microsoft.VisualStudio.Workload.VCTools` for a C++ toolchain without that requirement. Windows itself is the 180-day evaluation that dockur installs. -# 🌐 Access +# 🔧 Changing settings later -For debugging or testing you can connect to the VM directly. +Edit `.env` on the host and restart the container: -### Remote Desktop (RDP) — port `3389` +```bash +docker compose down && docker compose up -d +``` -| OS | Software | -|---|---| -| **Linux** | [`rdesktop`](https://github.com/rdesktop/rdesktop) → `rdesktop :3389`, or [Remmina](https://remmina.org/) | -| **macOS** | [Windows App](https://apps.apple.com/us/app/windows-app/id1295203466?mt=12) (formerly Microsoft Remote Desktop) | -| **Windows** | Built-in **Remote Desktop Connection** | +The restart matters: editors replace the file on save, and a bind-mounted file keeps pointing at the old copy until the container is recreated. At every logon the VM runs `install.ps1 -AtLogon` from a scheduled task named *GitHub runner setup*. It checks that every toolchain part is present (PowerShell 7, Chocolatey, Git, jq, 7-Zip, and Visual Studio and Rtools when enabled) and installs only what is missing. It then compares `runner.env` with the copy the runners were last registered from and, when it changed, removes the old registrations and re-registers with the new count, labels, version or mode. Lowering `RUNNERS` removes the surplus runners from GitHub. That is also how a wrong `PAT` or `RUNNER_URL` on the first start is fixed: correct `.env`, restart, wait for `shared/install.done`. When everything is present and nothing changed, the task exits without touching `shared/`. The result of the check is in `shared/install.log` as `Toolchain: all present` or `Toolchain: installing ...`. -### SSH — port `2222` +The same can be done from an RDP session, in an elevated PowerShell and without a restart: -```bash -ssh vagrant@ -p 2222 +```powershell +powershell -ExecutionPolicy Bypass -File C:\OEM\install.ps1 -RunnersOnly ``` +Without `-RunnersOnly` the toolchain steps run again too. To start over completely: `docker compose down`, delete `storage/`, `docker compose up -d`. + +`RAM_SIZE`, `CPU_CORES` and `DISK_SIZE` are passed to QEMU, so they apply at the next restart without any install step. `DISK_SIZE` can only grow; the logon task extends `C:` to the new size at that boot. + +# 🌐 Access + +| | | +|---|---| +| Web console | `http://:8006` | +| RDP | `:3389` — Linux: [`rdesktop`](https://github.com/rdesktop/rdesktop) or [Remmina](https://remmina.org/); macOS: [Windows App](https://apps.apple.com/us/app/windows-app/id1295203466?mt=12); Windows: Remote Desktop Connection | +| Shared folder | `./shared` on the host is `Z:` in the VM | + # 🔑 User Login -The default users from the Vagrant box are: +The unattended install creates one local administrator, `WIN_USERNAME` / `WIN_PASSWORD` from `.env` (default `admin` / `runner`). Change the password before exposing RDP beyond your network. + +# 🔁 Migrating from 1.x + +| | 1.x (Vagrant + libvirt image) | 2.x (dockur/windows) | +|---|---|---| +| Deployment | `image: vaggeliskls/windows-github-custom-runner` | `dockurr/windows` plus this repo's `oem/` folder, see [docker-compose.yml](docker-compose.yml) | +| Host flags | `privileged: true`, `cgroup: host` | `devices: /dev/kvm, /dev/net/tun`, `cap_add: NET_ADMIN` | +| VM sizing | `MEMORY=8000`, `CPU=4`, `DISK_SIZE=100` | `RAM_SIZE=8G`, `CPU_CORES=4`, `DISK_SIZE=100G` | +| Provisioning | every container start, 20 to 40 min | once, persisted in `storage/` | +| Runner process | `run.cmd` in an interactive session | Windows service (`RUNNER_MODE=interactive` restores the old behaviour) | +| Runner name | `__` | `__` | +| Default labels | `...,windows_vagrant_action` | `...,windows_dockur_action`. Workflows that use `runs-on: windows_vagrant_action` should switch to `windows_x64`, or add the old label to `GITHUB_RUNNER_LABELS` | +| Windows login | `vagrant` / `vagrant` | `admin` / `runner` | +| SSH on 2222 | yes | no; use RDP or the web console | -1. Administrator - - Username: `Administrator` - - Password: `vagrant` -2. User - - Username: `vagrant` - - Password: `vagrant` +Remove the old runners from GitHub (*Settings → Actions → Runners*) after the new ones are online; their names differ, so `--replace` does not take them over. # 🛠 Development -- The image only adds a [Vagrantfile](Vagrantfile) on top of [windows-in-docker-container](https://github.com/vaggeliskls/windows-in-docker-container); the container startup script comes from the base image. -- `docker compose build` builds the image locally (the base image is large — it contains the Windows box). -- Pull requests run [lint.yml](.github/workflows/lint.yml): `hadolint` on the Dockerfile, `ruby -c` on the Vagrantfile, `actionlint` on the workflows. Publishing a GitHub release builds and pushes the image to Docker Hub and GHCR via [ci.yml](.github/workflows/ci.yml). -- Dependabot keeps the GitHub Actions and the base image tag current. The runner version is set by `GITHUB_RUNNER_VERSION` in the [Dockerfile](Dockerfile). +- Everything lives in [oem/install.bat](oem/install.bat), [oem/install.ps1](oem/install.ps1) and [docker-compose.yml](docker-compose.yml). There is no image to build. +- To test script changes, edit `oem/`, delete `storage/` and run `docker compose up -d` again. For runner-only changes, re-run `install.ps1 -RunnersOnly` inside the VM. +- Every wipe of `storage/` downloads the 5 GB ISO again. To avoid that, download the stock evaluation ISO once (the URL is printed in `docker compose logs` at the start of a first install), keep it outside `storage/`, and enable the `/custom.iso` line in [docker-compose.yml](docker-compose.yml). Do not reuse the ISO from `storage/` for this: dockur has already baked `oem/` into it, so it would install the old scripts. +- Pull requests run [lint.yml](.github/workflows/lint.yml): `docker compose config`, a PowerShell parse plus [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) on `install.ps1`, and actionlint on the workflows. +- Dependabot keeps the GitHub Actions and the `dockurr/windows` tag current. # 📚 Further Reading and Resources -- [Windows in docker container](https://github.com/vaggeliskls/windows-in-docker-container) (base image) +- [dockur/windows](https://github.com/dockur/windows) — Windows in a Docker container - [GitHub: self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners) -- [Windows Vagrant Tutorial](https://github.com/SecurityWeekly/vulhub-lab) -- [Vagrant box: peru/windows-server-2022-standard-x64-eval](https://portal.cloud.hashicorp.com/vagrant/discover/peru/windows-server-2022-standard-x64-eval) -- [Vagrant by HashiCorp](https://www.vagrantup.com/) -- [Windows Virtual Machine in a Linux Docker Container](https://medium.com/axon-technologies/installing-a-windows-virtual-machine-in-a-linux-docker-container-c78e4c3f9ba1) +- [actions/runner releases](https://github.com/actions/runner/releases) +- [Visual Studio workload and component IDs](https://learn.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-enterprise) diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index b23d8f6..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,88 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : -# -# Everything is driven by environment variables: the Dockerfile sets the -# GITHUB_RUNNER_* defaults and .env supplies the rest (see README). -require "socket" - -cpu_count = (ENV["CPU"] || 4).to_i -memory_size = (ENV["MEMORY"] || 8000).to_i -disk_size = (ENV["DISK_SIZE"] || 100).to_i -privileged = (ENV["PRIVILEGED"] || "true") == "true" -interactive = (ENV["INTERACTIVE"] || "true") == "true" - -runners = (ENV["RUNNERS"] || 1).to_i -runner_url = ENV["RUNNER_URL"].to_s -runner_version = ENV["GITHUB_RUNNER_VERSION"].to_s -runner_name = ENV["GITHUB_RUNNER_NAME"] || "windows_x64_vagrant" -runner_labels = ENV["GITHUB_RUNNER_LABELS"] || "windows,win_x64,windows_x64,windows_vagrant_action" -runner_file = "actions-runner-win-x64-#{runner_version}.zip" -runner_pkg_url = ENV["GITHUB_RUNNER_URL"] || "https://github.com/actions/runner/releases/download/v#{runner_version}/#{runner_file}" -pat = ENV["PAT"].to_s -token = ENV["TOKEN"].to_s - -# Runner names must be stable across container restarts so `config.cmd --replace` -# reuses the existing registration instead of leaving offline duplicates in GitHub. -# The container hostname is the stable part; set `hostname:` in docker-compose.yml. -host_id = ENV["HOSTNAME"] || Socket.gethostname - -abort "RUNNER_URL must be set to an org (https://github.com/) or repo (https://github.com//) URL" if runner_url.empty? -abort "GITHUB_RUNNER_VERSION must be set" if runner_version.empty? -abort "Set PAT or TOKEN so the runners can register" if pat.empty? && token.empty? -auth_arg = pat.empty? ? "--token #{token}" : "--pat #{pat}" - -Vagrant.configure("2") do |config| - config.vm.box = ENV["VAGRANT_BOX"] || "peru/windows-server-2022-standard-x64-eval" - config.vm.box_check_update = false - config.vm.network "forwarded_port", guest: 22, host: 2222, id: "ssh" - config.vm.network "forwarded_port", guest: 3389, host: 3389, id: "rdp" - - config.vm.provider "libvirt" do |libvirt| - libvirt.driver = ENV["LIBVIRT_DRIVER"] || "kvm" - libvirt.memory = memory_size - libvirt.cpus = cpu_count - libvirt.machine_virtual_size = disk_size - libvirt.forward_ssh_port = true - end - config.winrm.max_tries = 300 # default is 20 - config.winrm.retry_delay = 5 # seconds (default) - - config.vm.provision "shell", inline: "Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False" - config.vm.provision "shell", powershell_elevated_interactive: interactive, privileged: privileged, inline: <<~SHELL - # PowerShell 7 and Chocolatey (also pulls in 7-Zip) - Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -AddToPath" - Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) - choco install 7zip.install git.install jq -y - # Work from C:\\ - Set-Location / - # Visual Studio 2022 Enterprise, native desktop (C++) workload. - # The bootstrapper returns immediately unless told to wait, which would let - # runners register and pick up jobs before the toolchain exists. - Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vs_enterprise.exe" -OutFile "vs_enterprise.exe" - Start-Process -FilePath ".\\vs_enterprise.exe" -ArgumentList "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.NativeDesktop --includeRecommended" -Wait - Remove-Item -Path ./vs_enterprise.exe - # Rtools 4.0 + mingw-w64 make - Invoke-WebRequest -Uri "https://cran.r-project.org/bin/windows/Rtools/rtools40-x86_64.exe" -OutFile "rtools.exe" - Start-Process "./rtools.exe" -ArgumentList "/Silent" -PassThru -Wait - [Environment]::SetEnvironmentVariable("PATH", $env:Path + ";C:\\rtools40\\usr\\bin;C:\\rtools40\\mingw64\\bin", [EnvironmentVariableTarget]::Machine) - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - C:\\rtools40\\msys2.exe pacman -Sy --noconfirm mingw-w64-x86_64-make - Remove-Item -Path ./rtools.exe - # Grow C: to the full virtual disk - Resize-Partition -DriveLetter "C" -Size (Get-PartitionSupportedSize -DriveLetter "C").SizeMax - # Enable long paths - New-ItemProperty -Path "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force - # GitHub Actions runners: one directory each, run as the vagrant user - $credentials = New-Object System.Management.Automation.PSCredential -ArgumentList @("VAGRANTVM\\vagrant", (ConvertTo-SecureString -String "vagrant" -AsPlainText -Force)) - Invoke-WebRequest -Uri "#{runner_pkg_url}" -OutFile "#{runner_file}" - Remove-Item -Path C:\\runner-* -Recurse -Force -ErrorAction SilentlyContinue - for ($i = 1; $i -le #{runners}; $i++) { - $dir = "C:\\runner-$i" - Write-Host "Configuring runner $i of #{runners} in $dir" - Expand-Archive -LiteralPath "#{runner_file}" -DestinationPath $dir -Force - & "$dir\\config.cmd" --unattended --replace --name "#{runner_name}_#{host_id}_$i" --url "#{runner_url}" --labels "#{runner_labels}" #{auth_arg} - Start-Process -FilePath "$dir\\run.cmd" -Credential $credentials - } - Remove-Item -Path "#{runner_file}" - SHELL -end diff --git a/docker-compose.yml b/docker-compose.yml index 550c30b..159e744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,42 @@ +# GitHub Actions runners on a Windows Server 2022 VM, built on dockur/windows. +# dockur boots QEMU directly and installs Windows from the Microsoft evaluation +# ISO on first start; oem/install.bat then installs the toolchain and registers +# the runners. Later starts boot the installed VM; a logon task in the VM +# re-applies runner.env only when it changed or an earlier run did not finish. +# +# Settings live in .env (copy .env.example). Compose reads it for the ${...} +# values below, and the same file is bind-mounted into the VM's shared folder +# so install.ps1 can read RUNNER_URL, PAT, RUNNERS, ... from there. Nothing from +# .env goes into /oem: dockur bakes that folder into the install ISO it keeps +# under ./storage, and a PAT should not end up in there. services: - windows-github-runner-vm: - image: docker.io/vaggeliskls/windows-github-custom-runner:latest - build: - context: . - dockerfile: Dockerfile - platform: linux/amd64 - # Part of every runner's name; give each container its own hostname - hostname: windows-github-runner - env_file: .env - stdin_open: true - tty: true - privileged: true - cgroup: host - restart: unless-stopped - # runtime: nvidia + windows-github-runner: + image: dockurr/windows:6.06 + environment: + VERSION: "${VERSION:-2022}" + RAM_SIZE: "${RAM_SIZE:-8G}" + CPU_CORES: "${CPU_CORES:-4}" + DISK_SIZE: "${DISK_SIZE:-100G}" + USERNAME: "${WIN_USERNAME:-admin}" + PASSWORD: "${WIN_PASSWORD:-runner}" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN ports: - - 3389:3389 # RDP - - 2222:2222 # SSH + - 8006:8006 # web console (noVNC), handy during the first install + - 3389:3389/tcp # RDP + - 3389:3389/udp + volumes: + - ./oem:/oem:ro # install.bat + install.ps1, copied to C:\OEM at install time + - ./storage:/storage # Windows disk + install ISO, persists across restarts + # Stock Windows ISO kept outside storage/: a wipe of storage/ then skips + # the 5 GB download. Get the URL from the container log of a first start. + # - ./win2022.iso:/custom.iso:ro + - ./shared:/shared # \\host.lan\Data (Z:) in the VM; install.log lands here + # read by install.ps1. Every process in the VM, CI jobs included, can read + # this share: comment the line out once the runners are registered. + - ./.env:/shared/runner.env:ro + restart: unless-stopped + stop_grace_period: 2m diff --git a/oem/install.bat b/oem/install.bat new file mode 100644 index 0000000..38004d1 --- /dev/null +++ b/oem/install.bat @@ -0,0 +1,8 @@ +@echo off +rem dockur/windows copies this folder to C:\OEM and runs install.bat once, at the +rem end of the unattended installation, as the auto-logon administrator, in a +rem visible "Install" window. Everything happens in install.ps1; its output is +rem shown here and recorded in C:\OEM\install.log, which is mirrored to the +rem shared folder (./shared on the host). +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" +exit /b %ERRORLEVEL% diff --git a/oem/install.ps1 b/oem/install.ps1 new file mode 100644 index 0000000..b2de7e3 --- /dev/null +++ b/oem/install.ps1 @@ -0,0 +1,492 @@ +<# +.SYNOPSIS + Turns a dockur/windows guest into one or more GitHub Actions runners. + +.DESCRIPTION + dockur/windows copies the /oem folder to C:\OEM and runs C:\OEM\install.bat + once, at the end of the unattended installation, as the auto-logon + administrator (its answer file disables UAC and the firewall). install.bat + hands over to this script, which installs the build toolchain and registers + the runners. + + Settings come from runner.env, a KEY=VALUE file with the layout of the + repo's .env. docker-compose.yml bind-mounts .env to /shared/runner.env, + which the guest sees as \\host.lan\Data\runner.env (drive Z:). Nothing + secret is placed in /oem because dockur bakes that folder into the install + ISO it keeps in /storage. + + Progress is written to C:\OEM\install.log and copied to the shared folder + together with an install.done or install.failed marker. + + The first run registers a "GitHub runner setup" scheduled task that runs + this script with -AtLogon at every logon. That run repeats the install when + a previous run did not finish (wrong PAT, download failure, ...) or + re-registers the runners when runner.env changed, so fixing .env on the + host and restarting the container is enough. It does nothing otherwise. + +.EXAMPLE + # Re-run from an RDP session after changing runner.env (RUNNERS, labels, + # version, ...). Skips the toolchain and re-registers the runners. + powershell -ExecutionPolicy Bypass -File C:\OEM\install.ps1 -RunnersOnly +#> +[CmdletBinding()] +param( + # Path to runner.env. Default: the shared folder, then C:\OEM\runner.env. + [string]$ConfigPath, + + # Skip Chocolatey, Visual Studio and Rtools; only (re)configure the runners. + [switch]$RunnersOnly, + + # Used by the "GitHub runner setup" scheduled task. Runs only when the + # toolchain install never finished or runner.env changed since the runners + # were last registered; exits quietly otherwise. + [switch]$AtLogon +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is much faster without the progress bar +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$Share = '\\host.lan\Data' +$LogFile = 'C:\OEM\install.log' # last real run +$LogonLogFile = 'C:\OEM\install-logon.log' # last logon check that found nothing to do +$ConfigMarker = 'C:\OEM\runner.env.sha256' # hash of the runner.env the runners were last registered from +$SetupTask = 'GitHub runner setup' +$Temp = Join-Path $env:TEMP 'runner-install' +$RebootRequired = $false + +function Write-Step([string]$Message) { + Write-Host ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) +} + +# Runs a native command so that its stdout and stderr end up in the transcript +# as well as on the console. Windows PowerShell does not record output a program +# writes straight to the console, and 2>&1 under ErrorActionPreference=Stop +# would abort the run at the first stderr line. Returns the exit code. +function Invoke-Native([string]$Exe, [string[]]$Arguments) { + $ErrorActionPreference = 'Continue' + & $Exe @Arguments 2>&1 | ForEach-Object { "$_" } | Out-Host + return $LASTEXITCODE +} + +# Appends whatever the transcript gained to the copy in the shared folder every +# 2 seconds, so the host sees the same log as the console and 'tail -f' keeps +# working: the copy only grows, it is never rewritten. +function Start-LogMirror { + $from = $LogFile + $to = "$Share\install.log" + Remove-Item -LiteralPath $to -Force -ErrorAction SilentlyContinue + return Start-Job -ScriptBlock { + $offset = 0 + while ($true) { + try { + $src = [IO.File]::Open($using:from, 'Open', 'Read', 'ReadWrite') + try { + if ($src.Length -gt $offset) { + $buffer = New-Object byte[] ($src.Length - $offset) + $src.Seek($offset, 'Begin') | Out-Null + $read = $src.Read($buffer, 0, $buffer.Length) + $dst = [IO.File]::Open($using:to, 'Append', 'Write', 'Read') + try { $dst.Write($buffer, 0, $read) } finally { $dst.Close() } + $offset += $read + } + } finally { $src.Close() } + } catch { Write-Verbose "$_" } + Start-Sleep -Seconds 2 + } + } +} + +function Update-ProcessPath { + $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') +} + +function Add-MachinePath([string[]]$Dirs) { + $current = [Environment]::GetEnvironmentVariable('Path', 'Machine').Split(';') | Where-Object { $_ } + $new = @($current) + @($Dirs | Where-Object { $current -notcontains $_ }) + [Environment]::SetEnvironmentVariable('Path', ($new -join ';'), 'Machine') + Update-ProcessPath +} + +# KEY=VALUE lines; blank lines and # comments ignored; surrounding quotes stripped. +function Read-EnvFile([string]$Path) { + $cfg = @{} + foreach ($raw in Get-Content -LiteralPath $Path) { + $line = $raw.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $idx = $line.IndexOf('=') + if ($idx -lt 1) { continue } + $key = $line.Substring(0, $idx).Trim() + $val = $line.Substring($idx + 1).Trim() + if ($val.Length -ge 2 -and (($val[0] -eq '"' -and $val[-1] -eq '"') -or ($val[0] -eq "'" -and $val[-1] -eq "'"))) { + $val = $val.Substring(1, $val.Length - 2) + } + $cfg[$key] = $val + } + return $cfg +} + +# Returns the path of runner.env, or $null with -Optional when there is none. +function Find-Config([string]$Override, [switch]$Optional) { + if ($Override) { return $Override } + $candidates = @("$Share\runner.env", 'Z:\runner.env', 'C:\OEM\runner.env') + # The samba share can take a moment to become reachable after logon. + $deadline = (Get-Date).AddMinutes(5) + do { + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + # Docker creates a missing bind-mount source as an empty directory, so a + # folder here means .env did not exist on the host when compose started. + if (Test-Path -LiteralPath $candidate -PathType Container) { + throw "$candidate is a directory, not a file. .env was missing on the host when 'docker compose up' ran: remove the .env directory, create .env from .env.example, then 'docker compose down' and 'docker compose up -d'." + } + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + if ($Optional) { return $null } + throw "runner.env not found (looked in $($candidates -join ', ')). Check the volumes in docker-compose.yml." +} + +function Get-ConfigHash([string]$Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} + +# Re-runs this script with -AtLogon at every logon of the auto-logon user, so a +# corrected or changed runner.env is applied by restarting the container. +function Register-SetupTask { + $user = "$env:USERDOMAIN\$env:USERNAME" + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`" -AtLogon" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user + $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings.ExecutionTimeLimit = 'PT0S' # a full install can take over an hour + Register-ScheduledTask -TaskName $SetupTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +} + +function Get-Setting($Config, [string]$Key, $Default = '') { + if ($Config.ContainsKey($Key) -and $Config[$Key] -ne '') { return $Config[$Key] } + return $Default +} + +function Test-Enabled($Config, [string]$Key, [bool]$Default = $true) { + $value = (Get-Setting $Config $Key ($Default.ToString())).ToLower() + return $value -in 'true', '1', 'yes', 'y' +} + +function Invoke-Download([string]$Uri, [string]$OutFile) { + Write-Step "Downloading $Uri" + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing +} + +# --- Toolchain --------------------------------------------------------------- + +function Initialize-System { + Write-Step 'Enabling long paths' + New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -PropertyType DWORD -Force | Out-Null + + # Grow C: when DISK_SIZE is larger than the partition dockur created + try { + $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax + $cur = (Get-Partition -DriveLetter C).Size + if (($max - $cur) -gt 1GB) { + Write-Step 'Growing C: to the full virtual disk' + Resize-Partition -DriveLetter C -Size $max + } + } catch { + Write-Step "Could not resize C: ($_)" + } +} + +function Test-VisualStudio { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path -LiteralPath $vswhere)) { return $false } + return [bool](& $vswhere -products * -version '[17.0,18.0)' -property installationPath) +} + +# Names of the toolchain parts that are not installed yet. Decides whether a +# logon run has anything to do and which installers Install-Toolchain runs. +function Get-MissingTools($Config) { + Update-ProcessPath + $checks = [ordered]@{ + 'PowerShell 7' = { Test-Path -LiteralPath "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + 'Chocolatey' = { [bool](Get-Command choco -ErrorAction SilentlyContinue) } + 'Git' = { [bool](Get-Command git -ErrorAction SilentlyContinue) } + 'jq' = { [bool](Get-Command jq -ErrorAction SilentlyContinue) } + '7-Zip' = { Test-Path -LiteralPath "$env:ProgramFiles\7-Zip\7z.exe" } + } + if (Test-Enabled $Config 'INSTALL_VISUAL_STUDIO') { $checks['Visual Studio'] = { Test-VisualStudio } } + if (Test-Enabled $Config 'INSTALL_RTOOLS') { + $checks['Rtools'] = { (Test-Path -LiteralPath 'C:\rtools40\usr\bin\pacman.exe') -and (Test-Path -LiteralPath 'C:\rtools40\mingw64\bin\mingw32-make.exe') } + } + return @($checks.Keys | Where-Object { -not (& $checks[$_]) }) +} + +function Install-Toolchain($Config, [string[]]$Missing) { + if ($Missing -contains 'PowerShell 7') { + Write-Step 'Installing PowerShell 7' + Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -UseMSI -Quiet" + } + + if ($Missing -contains 'Chocolatey') { + Write-Step 'Installing Chocolatey' + Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + Update-ProcessPath + } + + $packages = @{ 'Git' = 'git.install'; 'jq' = 'jq'; '7-Zip' = '7zip.install' } + $wanted = @($packages.Keys | Where-Object { $Missing -contains $_ } | ForEach-Object { $packages[$_] }) + if ($wanted) { + Write-Step "Installing $($wanted -join ', ') with Chocolatey" + $code = Invoke-Native 'choco' (@('install', '-y', '--no-progress') + $wanted) + if ($code -notin 0, 1641, 3010) { throw "choco install failed with exit code $code" } + Update-ProcessPath + Invoke-Native 'git' @('config', '--system', 'core.longpaths', 'true') | Out-Null + } + + if ($Missing -contains 'Visual Studio') { + Install-VisualStudio (Get-Setting $Config 'VS_EDITION' 'enterprise') (Get-Setting $Config 'VS_WORKLOADS' 'Microsoft.VisualStudio.Workload.NativeDesktop') + } + if ($Missing -contains 'Rtools') { Install-Rtools } +} + +function Install-VisualStudio([string]$Edition, [string]$Workloads) { + $exe = Join-Path $Temp "vs_$Edition.exe" + Invoke-Download "https://aka.ms/vs/17/release/vs_$Edition.exe" $exe + + $vsArgs = @('--quiet', '--wait', '--norestart', '--includeRecommended') + foreach ($workload in ($Workloads -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + $vsArgs += @('--add', $workload) + } + + Write-Step "Installing Visual Studio 2022 $Edition with $Workloads (this takes a while)" + $proc = Start-Process -FilePath $exe -ArgumentList $vsArgs -Wait -PassThru + switch ($proc.ExitCode) { + 0 { Write-Step 'Visual Studio installed' } + 3010 { Write-Step 'Visual Studio installed, reboot required'; $script:RebootRequired = $true } + default { throw "Visual Studio installer failed with exit code $($proc.ExitCode)" } + } +} + +function Install-Rtools { + $exe = Join-Path $Temp 'rtools40.exe' + Invoke-Download 'https://cran.r-project.org/bin/windows/Rtools/rtools40-x86_64.exe' $exe + + Write-Step 'Installing Rtools 4.0' + $proc = Start-Process -FilePath $exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "Rtools installer failed with exit code $($proc.ExitCode)" } + Add-MachinePath 'C:\rtools40\usr\bin', 'C:\rtools40\mingw64\bin' + + Write-Step 'Installing mingw-w64-x86_64-make' + $code = Invoke-Native 'C:\rtools40\usr\bin\pacman.exe' @('-Sy', '--noconfirm', 'mingw-w64-x86_64-make') + if ($code -ne 0) { throw "pacman failed with exit code $code" } +} + +# --- Runners ----------------------------------------------------------------- + +function Resolve-RunnerVersion([string]$Version) { + if ($Version) { return $Version.TrimStart('v') } + Write-Step 'GITHUB_RUNNER_VERSION not set, using the latest actions/runner release' + # The releases/latest page redirects to the tag. Unlike the REST API this has + # no rate limit, which an unauthenticated call shares with everyone behind + # the same public IP (60 per hour). + $request = [Net.WebRequest]::Create('https://github.com/actions/runner/releases/latest') + $request.Method = 'HEAD' + $request.AllowAutoRedirect = $false + $response = $request.GetResponse() + try { $location = $response.Headers['Location'] } finally { $response.Close() } + if ($location -notmatch '/tag/v?([\d.]+)$') { throw "Could not determine the latest actions/runner release from '$location'. Set GITHUB_RUNNER_VERSION in .env." } + return $Matches[1] +} + +# Interactive mode: run.cmd as a scheduled task in the auto-logon desktop session, +# restarted at every logon, so jobs can drive GUI tooling. +function Register-InteractiveRunner([int]$Index, [string]$Dir) { + $user = "$env:USERDOMAIN\$env:USERNAME" + $taskName = "GitHub runner $Index" + $action = New-ScheduledTaskAction -Execute "$Dir\run.cmd" -WorkingDirectory $Dir + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user + $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings.ExecutionTimeLimit = 'PT0S' # no time limit + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null + Start-ScheduledTask -TaskName $taskName +} + +# Takes runner $Index out of GitHub (its service or scheduled task with it) and +# deletes its folder. Safe to call when there is nothing to remove. +function Remove-Runner([int]$Index, [string[]]$Auth) { + $dir = "C:\runner-$Index" + $task = Get-ScheduledTask -TaskName "GitHub runner $Index" -ErrorAction SilentlyContinue + if ($task) { + $task | Stop-ScheduledTask -ErrorAction SilentlyContinue + $task | Unregister-ScheduledTask -Confirm:$false + } + # Interactive mode: the listener outlives its cmd window and holds files open + Get-Process Runner.Listener -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dir\*" } | Stop-Process -Force + if (Test-Path -LiteralPath "$dir\.runner") { + Write-Step 'Removing the previous registration' + $code = Invoke-Native "$dir\config.cmd" (@('remove', '--unattended') + $Auth) + if ($code -ne 0) { Write-Step "config.cmd remove exited with $code, continuing" } + } + if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } +} + +function Install-Runners($Config) { + $url = Get-Setting $Config 'RUNNER_URL' + $pat = Get-Setting $Config 'PAT' + $token = Get-Setting $Config 'TOKEN' + $count = [int](Get-Setting $Config 'RUNNERS' 1) + $prefix = Get-Setting $Config 'GITHUB_RUNNER_NAME' 'windows_x64_dockur' + $labels = Get-Setting $Config 'GITHUB_RUNNER_LABELS' 'windows,win_x64,windows_x64,windows_dockur_action' + $group = Get-Setting $Config 'GITHUB_RUNNER_GROUP' + $hostId = Get-Setting $Config 'RUNNER_HOST_ID' $env:COMPUTERNAME + $mode = (Get-Setting $Config 'RUNNER_MODE' 'service').ToLower() + $version = Resolve-RunnerVersion (Get-Setting $Config 'GITHUB_RUNNER_VERSION') + + if (-not $url) { throw 'RUNNER_URL must be set to https://github.com/ or https://github.com//' } + if (-not $pat -and -not $token) { throw 'Set PAT or TOKEN in runner.env so the runners can register' } + if ($mode -notin 'service', 'interactive') { throw "RUNNER_MODE must be 'service' or 'interactive', got '$mode'" } + $auth = if ($pat) { @('--pat', $pat) } else { @('--token', $token) } + + # Service account: the auto-logon user unless RUNNER_SERVICE_ACCOUNT says otherwise. + # config.cmd turns ".\user" into "\user" and grants it "Log on as a service". + $serviceAccount = Get-Setting $Config 'RUNNER_SERVICE_ACCOUNT' ".\$env:USERNAME" + $servicePassword = Get-Setting $Config 'WIN_PASSWORD' 'runner' + $builtinAccount = $serviceAccount -like 'NT AUTHORITY\*' -or $serviceAccount -eq 'LocalSystem' + + $zip = Join-Path $Temp "actions-runner-win-x64-$version.zip" + $pkgUrl = Get-Setting $Config 'GITHUB_RUNNER_URL' "https://github.com/actions/runner/releases/download/v$version/actions-runner-win-x64-$version.zip" + Invoke-Download $pkgUrl $zip + + # Runners from a previous run: the first $count are replaced in place, any + # above the new count (RUNNERS was lowered) are removed for good. + $existing = @(Get-ChildItem -LiteralPath 'C:\' -Directory -Filter 'runner-*' | ForEach-Object { [int]($_.Name -replace '^runner-', '') }) + foreach ($i in ($existing | Where-Object { $_ -gt $count })) { + Write-Step "Removing runner $i (RUNNERS is now $count)" + Remove-Runner $i $auth + } + + $names = @() + for ($i = 1; $i -le $count; $i++) { + $dir = "C:\runner-$i" + $name = "${prefix}_${hostId}_$i" + $names += $name + Write-Step "Configuring runner $i of ${count}: $name in $dir" + + # A previous registration (re-run) is removed first so --replace has nothing stale to fight. + Remove-Runner $i $auth + Expand-Archive -LiteralPath $zip -DestinationPath $dir -Force + + $cfgArgs = @('--unattended', '--replace', '--name', $name, '--url', $url, '--labels', $labels) + $auth + if ($group) { $cfgArgs += @('--runnergroup', $group) } + if ($mode -eq 'service') { + $cfgArgs += @('--runasservice', '--windowslogonaccount', $serviceAccount) + if (-not $builtinAccount) { $cfgArgs += @('--windowslogonpassword', $servicePassword) } + } + $code = Invoke-Native "$dir\config.cmd" $cfgArgs + if ($code -ne 0) { throw "config.cmd failed for $name with exit code $code" } + + if ($mode -eq 'interactive') { Register-InteractiveRunner $i $dir } + } + Remove-Item -LiteralPath $zip -Force + + return @{ Version = $version; Mode = $mode; Names = $names } +} + +# --- Main -------------------------------------------------------------------- + +$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script from an elevated PowerShell' +} + +# One transcript per run. A logon check writes to its own file so that a boot +# with nothing to do does not overwrite the log of the last real run. +Start-Transcript -Path $(if ($AtLogon) { $LogonLogFile } else { $LogFile }) | Out-Null +New-Item -ItemType Directory -Path $Temp -Force | Out-Null +$status = 'failed' +$mirror = $null +try { + # Registered before anything that can fail, so a bad runner.env still gets + # a retry at the next boot. Not re-registered from the task itself. + if (-not $AtLogon) { Register-SetupTask } + + $configFile = Find-Config $ConfigPath -Optional:$AtLogon + if (-not $configFile) { + Write-Step 'runner.env not found, nothing to do' + $status = 'skipped' + return + } + $config = Read-EnvFile $configFile + + # What is left to do: toolchain parts that are not on the machine, and the + # runners when runner.env differs from the one they were registered from. + $missing = if ($RunnersOnly) { @() } else { @(Get-MissingTools $config) } + $registerRunners = $true + if ($AtLogon) { + $applied = if (Test-Path -LiteralPath $ConfigMarker) { Get-Content -LiteralPath $ConfigMarker } else { '' } + $registerRunners = (Get-ConfigHash $configFile) -ne $applied + if (-not $missing -and -not $registerRunners) { + $status = 'skipped' + return + } + } + # A real run from here on: switch to the main log, clear the old markers and + # start mirroring the log to the host. + if ($AtLogon) { + Stop-Transcript | Out-Null + Start-Transcript -Path $LogFile | Out-Null + } + Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + $mirror = Start-LogMirror + Write-Step "Using settings from $configFile" + + # Idempotent and quick; also grows C: after a DISK_SIZE change on the host. + Initialize-System + if ($RunnersOnly) { Write-Step 'Toolchain: skipped (-RunnersOnly)' } + elseif ($missing) { Write-Step "Toolchain: installing $($missing -join ', ')"; Install-Toolchain $config $missing } + else { Write-Step 'Toolchain: all present' } + + if ($registerRunners) { + $result = Install-Runners $config + Set-Content -LiteralPath $ConfigMarker -Value (Get-ConfigHash $configFile) + Write-Step "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" + } else { + Write-Step 'Runners: runner.env unchanged, left as they are' + } + + # Only the fallback location is on the Windows disk; do not leave a PAT there. + if ($configFile -ieq 'C:\OEM\runner.env') { Remove-Item -LiteralPath $configFile -Force } + + $status = 'done' + if ($RebootRequired) { + Write-Step 'Rebooting in 60 seconds to finish the Visual Studio installation' + shutdown.exe /r /t 60 /c 'Finishing GitHub runner setup' + } +} catch { + Write-Step "FAILED: $_" + Write-Step $_.ScriptStackTrace +} finally { + Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null + if ($mirror) { + Start-Sleep -Seconds 3 # one more pass of the mirror picks up the transcript footer + $mirror | Stop-Job + $mirror | Remove-Job -Force + } + + # Report back to the host through the shared folder (./shared). A logon run + # that found nothing to do leaves the previous log and marker alone. + if ($status -ne 'skipped') { + try { + # Without a mirror (failed before the run started) the host has no log yet. + if (-not $mirror) { Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force } + Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + Set-Content -Path "$Share\install.$status" -Value (Get-Date -Format o) + } catch { + Write-Host "Could not write to $Share ($_)" + } + } +} + +if ($status -eq 'failed') { exit 1 }