diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5acc1f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,118 @@ +# ------------------------------- 必填配置 ------------------------------- +# +# 复制本文件为 .env 后,至少填写 ORG 和 GH_PAT。 + +# GitHub 组织名或用户名。 +# 所有需要访问 GitHub API 的命令都需要它。 +ORG= + +# GitHub classic Personal Access Token。 +# 所需权限: +# - 组织级 runner:admin:org +# - 仓库级 runner:目标仓库管理员权限 +# 真实 token 只应放在 .env 中,不要提交到仓库。 +GH_PAT= + +# ------------------------------- 作用域配置 ------------------------------- + +# 可选的仓库名。 +# 留空时管理组织级 runner:https://github.com/${ORG} +# 填写后管理仓库级 runner:https://github.com/${ORG}/${REPO} +# REPO= + +# ------------------------------- Runner 默认配置 ------------------------------- + +# 注册 runner 时使用的标签,逗号分隔。 +# 默认值:intel +RUNNER_LABELS=intel,board,kvm + +# runner 的设备映射,逗号分隔。 +# 每一项可以写成 "/宿主机设备" 或 "/宿主机设备:/容器内设备"。 +# 默认值:/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm +# RUNNER_DEVICES=/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm + +# runner 容器额外加入的 Linux 组,逗号分隔。 +# /dev/kvm 的数字 GID 会由脚本单独检测并加入。 +# 默认值:dialout +# RUNNER_GROUP_ADD=dialout + +# runner 的额外挂载,分号分隔。 +# 示例:RUNNER_VOLUMES=/opt/cache:/opt/cache;/data/sdk:/data/sdk:ro +# RUNNER_VOLUMES= + +# 传入 runner 容器的额外环境变量,分号分隔。 +# 示例:RUNNER_ENV=SDK_ROOT=/opt/sdk;BOARD_PORT=/dev/ttyUSB0 +# RUNNER_ENV= + +# runner 容器启动时执行的命令。 +# 默认值:/home/runner/run.sh +# RUNNER_COMMAND=/home/runner/run.sh + +# 按单个 runner 覆盖配置:在任意 RUNNER_* 变量后追加 runner 编号。 +# 示例: +# RUNNER_LABELS_2=board,phytiumpi,arm64 +# RUNNER_DEVICES_2=/dev/kvm,/dev/ttyUSB0:/dev/ttyUSB0 +# RUNNER_ENV_2=BOARD_NAME=phytiumpi;SERIAL_PORT=/dev/ttyUSB0 + +# ------------------------------- 注册配置 ------------------------------- + +# GitHub Actions runner group 名称。 +# 默认值:Default +# RUNNER_GROUP=Default + +# 传给 config.sh --work 的 runner 工作目录。 +# 留空时使用 runner 镜像默认工作目录。 +# RUNNER_WORKDIR= + +# 是否禁用 GitHub runner 自动更新。 +# 值为 "1" 或 "true" 时会在注册时追加 config.sh --disableupdate。 +# 默认值:false +# DISABLE_AUTO_UPDATE=false + +# 可选的短期 runner 注册 token。 +# 通常不需要设置;runner.sh 会使用 GH_PAT 自动申请并缓存。 +# REG_TOKEN= + +# 注册 token 缓存有效期,单位秒。 +# 默认值:300 +# REG_TOKEN_CACHE_TTL=300 + +# ------------------------------- 镜像配置 ------------------------------- + +# 生成 compose 文件时使用的基础 runner 镜像。 +# 默认值:ghcr.io/actions/actions-runner:latest +# RUNNER_IMAGE=ghcr.io/actions/actions-runner:latest + +# runner.sh 根据 Dockerfile 自动构建的本地自定义镜像 tag。 +# 默认值:qc-actions-runner:v0.0.1 +# RUNNER_CUSTOM_IMAGE=qc-actions-runner:v0.0.1 + +# ------------------------------- 命名和生成文件 ------------------------------- + +# runner 容器名和 GitHub runner 名称前缀。 +# 留空时根据 hostname、ORG 和可选 REPO 自动生成。 +# 如果手动设置且结尾不是 "-",runner.sh 会自动追加 "-"。 +# RUNNER_NAME_PREFIX= + +# 生成的 docker compose 文件路径。 +# 留空时根据 ORG 和可选 REPO 自动生成,避免多组织/多仓库冲突。 +# COMPOSE_FILE= + +# Dockerfile hash 缓存文件路径。 +# 留空时根据 ORG 和可选 REPO 自动生成。 +# DOCKERFILE_HASH_FILE= + +# 注册 token 缓存文件路径。 +# 留空时根据 ORG 和可选 REPO 自动生成。 +# REG_TOKEN_CACHE_FILE= + +# ------------------------------- 主机访问和网络 ------------------------------- + +# /dev/kvm 访问所需的数字组 ID。 +# 留空时从宿主机 /dev/kvm 自动检测;检测失败时回退到 993。 +# RUNNER_KVM_GID= + +# 代理变量。设置后会写入生成的 runner compose 环境变量。 +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= diff --git a/.gitignore b/.gitignore index 821518c..bc6be4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.env* +.env .reg_token* .dockerfile* docker-compose* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b77c051..b68311a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,13 @@ FROM ghcr.io/actions/actions-runner:latest # Switch to root to install packages USER root +ARG QEMU_VERSION=10.2.1 + ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=Etc/UTC \ + PATH=/opt/cargo/bin:/opt/qemu-${QEMU_VERSION}/bin:/opt/x86_64-linux-musl-cross/bin:/opt/aarch64-linux-musl-cross/bin:/opt/riscv64-linux-musl-cross/bin:/opt/loongarch64-linux-musl-cross/bin:${PATH} + # Install common build tools and dependencies # - build-essential: gcc, g++, make, libc dev headers # - binfmt-support: helpers for binfmt_misc (host typically manages handlers) @@ -56,26 +61,74 @@ RUN apt-get update \ python3-tomli \ python3-sphinx \ ninja-build \ - libslirp0 \ + libslirp-dev \ + cmake \ + clang \ + libclang-19-dev \ + # Additional tools from tgoskits-container + e2fsprogs \ + meson \ + qemu-user-static \ + xz-utils \ + curl \ + libavcodec-dev \ + libavdevice-dev \ + libavfilter-dev \ + libavformat-dev \ + libavutil-dev \ + libswresample-dev \ + libswscale-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install GitHub CLI for workflows that call `gh`. +RUN mkdir -p -m 755 /etc/apt/keyrings \ + && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list >/dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && gh --version \ && rm -rf /var/lib/apt/lists/* -# Build and install QEMU 10.1.2 from source +# Build and install QEMU from source RUN mkdir -p /tmp/qemu-build \ && cd /tmp/qemu-build \ - && wget https://download.qemu.org/qemu-10.1.2.tar.xz \ - && tar -xf qemu-10.1.2.tar.xz \ - && cd qemu-10.1.2 \ + && wget "https://download.qemu.org/qemu-${QEMU_VERSION}.tar.xz" \ + && tar -xf "qemu-${QEMU_VERSION}.tar.xz" \ + && cd "qemu-${QEMU_VERSION}" \ && ./configure \ + --prefix="/opt/qemu-${QEMU_VERSION}" \ + --target-list=loongarch64-softmmu,loongarch64-linux-user,riscv64-softmmu,riscv64-linux-user,aarch64-softmmu,aarch64-linux-user,x86_64-softmmu,x86_64-linux-user \ --enable-kvm \ --disable-docs \ --enable-virtfs \ --enable-vhost-net \ --enable-slirp \ + --disable-gtk \ + --disable-sdl \ + --disable-vte \ + --disable-werror \ && make -j$(nproc) \ && make install \ && cd / \ && rm -rf /tmp/qemu-build +RUN set -eux; \ + for arch in aarch64 riscv64 x86_64 loongarch64; do \ + file="${arch}-linux-musl-cross.tgz"; \ + if ! wget -q "https://github.com/arceos-org/setup-musl/releases/download/prebuilt/${file}" -O "/tmp/${file}"; then \ + if [ "${arch}" = "loongarch64" ]; then \ + wget -q "https://github.com/LoongsonLab/oscomp-toolchains-for-oskernel/releases/download/loongarch64-linux-musl-cross-gcc-13.2.0/${file}" -O "/tmp/${file}"; \ + else \ + wget -q "https://musl.cc/${file}" -O "/tmp/${file}"; \ + fi; \ + fi; \ + tar -xzf "/tmp/${file}" -C /opt; \ + rm -f "/tmp/${file}"; \ + done + # 串口访问只能是 root 和 dialout 组,这里直把 runner 用户加入 dialout 组 RUN usermod -aG dialout runner RUN usermod -aG kvm runner @@ -107,11 +160,11 @@ RUN set -eux; \ rustc --version; # Install additional Rust toolchains, targets and components -RUN rustup toolchain install nightly-2025-12-12 nightly-2026-02-25 && \ - rustup target add aarch64-unknown-none-softfloat riscv64gc-unknown-none-elf x86_64-unknown-none loongarch64-unknown-none-softfloat --toolchain nightly-2025-12-12 && \ +RUN rustup toolchain install nightly-2026-04-01 nightly-2026-02-25 && \ + rustup target add aarch64-unknown-none-softfloat riscv64gc-unknown-none-elf x86_64-unknown-none loongarch64-unknown-none-softfloat --toolchain nightly-2026-04-01 && \ rustup target add aarch64-unknown-none-softfloat riscv64gc-unknown-none-elf x86_64-unknown-none loongarch64-unknown-none-softfloat --toolchain nightly-2026-02-25 && \ rustup target add aarch64-unknown-none-softfloat riscv64gc-unknown-none-elf x86_64-unknown-none loongarch64-unknown-none-softfloat --toolchain nightly && \ - rustup component add clippy llvm-tools rust-src rustfmt --toolchain nightly-2025-12-12 && \ + rustup component add clippy llvm-tools rust-src rustfmt --toolchain nightly-2026-04-01 && \ rustup component add clippy llvm-tools rust-src rustfmt --toolchain nightly-2026-02-25 && \ rustup component add clippy llvm-tools rust-src rustfmt --toolchain nightly diff --git a/README.md b/README.md index 4b7434e..986dd53 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ This repository provides scripts and tools for creating, managing, and registeri - Batch management of multiple runner containers using Docker Compose - Support for organization-level and repository-level runners (controlled by `REPO` variable) -- Per-instance custom labels via `BOARD_RUNNERS` +- Per-runner configuration for labels, devices, groups, volumes, environment variables, and commands - Automatic custom image rebuild when `Dockerfile` changes - Cached registration tokens to reduce GitHub API requests -- Full lifecycle commands: `init`, `register`, `start`, `stop`, `restart`, `logs`, `list`, `rm`, `purge` +- Full lifecycle commands: `init`, `add`, `compose`, `register`, `start`, `stop`, `restart`, `log`, `list`, `rm`, `purge`, `image` ## Usage @@ -42,15 +42,16 @@ chmod +x runner.sh | Command | Description | |---------|-------------| | `./runner.sh init [-n N]` | Generate and start N runners | +| `./runner.sh add [-n N]` | Append N new runners after the existing runner indexes | +| `./runner.sh compose` | Regenerate compose file with existing runners | | `./runner.sh register [runner- ...]` | Register specified instances; without arguments, registers all unconfigured instances | | `./runner.sh start/stop/restart [runner- ...]` | Start/stop/restart containers | -| `./runner.sh logs runner-` | View instance logs | +| `./runner.sh log runner-` | Follow instance logs | | `./runner.sh ps` | Show container status | | `./runner.sh list` | Show local container status and GitHub registration status | | `./runner.sh rm [runner- ...] [-y]` | Unregister and remove containers; `-y` skips confirmation | | `./runner.sh purge [-y]` | Remove containers and generated files (`docker-compose.yml`, caches, etc.) | - -> **Note**: The `init` command creates two hardware-based runners (phytiumpi and roc-rk3568-pc) by default. This behavior is not controlled by the `-n` parameter. +| `./runner.sh image` | Rebuild the custom runner image | ## Configuration @@ -58,15 +59,26 @@ chmod +x runner.sh The default prefix automatically includes `ORG` (and `REPO` if set), formatted as `--runner-N` or `---runner-N` to avoid naming conflicts when multiple orgs/repos run on the same host. Override with `RUNNER_NAME_PREFIX`. -### BOARD_RUNNERS Format +### Runner Configuration -``` -name:label1[,label2];name2:label1 -``` +All runners use the same naming format, `${RUNNER_NAME_PREFIX}runner-N`. The `-n` option controls the total number of runners. Use the default `RUNNER_*` variables for all runners, and append an index such as `_1` or `_2` to override one runner. -Example: `phytiumpi:arm64,phytiumpi;roc-rk3568-pc:arm64,roc-rk3568-pc` +| Variable | Default | Description | +|----------|---------|-------------| +| `RUNNER_LABELS` | `intel` | Labels used when registering runners | +| `RUNNER_DEVICES` | `/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm` | Comma-separated device mappings; entries without `:` are mapped to the same path in the container | +| `RUNNER_GROUP_ADD` | `dialout` | Comma-separated extra groups added to runner containers | +| `RUNNER_VOLUMES` | empty | Semicolon-separated extra volume mounts | +| `RUNNER_ENV` | empty | Semicolon-separated `KEY=VALUE` environment variables | +| `RUNNER_COMMAND` | `/home/runner/run.sh` | Command executed by runners | -Board instances will only use labels defined in `BOARD_RUNNERS` and will not append global `RUNNER_LABELS`. +Example per-runner overrides: + +```bash +RUNNER_LABELS_2=board,phytiumpi,arm64 +RUNNER_DEVICES_2=/dev/kvm,/dev/ttyUSB0:/dev/ttyUSB0 +RUNNER_ENV_2='BOARD_NAME=phytiumpi;SERIAL=/dev/ttyUSB0' +``` ### Other Settings diff --git a/README_CN.md b/README_CN.md index ab87092..38b2094 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,10 +15,10 @@ - 使用 Docker Compose 批量管理多个 Runner 容器 - 支持组织级与仓库级 Runner(通过 `REPO` 变量切换) -- 支持针对特定实例的自定义标签(`BOARD_RUNNERS`) +- 支持按 Runner 配置标签、设备、用户组、卷、环境变量和启动命令 - 检测 `Dockerfile` 变更并自动重建自定义镜像 - 缓存注册令牌以减少 GitHub API 请求 -- 提供完整生命周期命令:`init`、`register`、`start`、`stop`、`restart`、`logs`、`list`、`rm`、`purge` +- 提供完整生命周期命令:`init`、`add`、`compose`、`register`、`start`、`stop`、`restart`、`log`、`list`、`rm`、`purge`、`image` ## 使用 @@ -42,15 +42,16 @@ chmod +x runner.sh | 命令 | 说明 | |------|------| | `./runner.sh init [-n N]` | 生成并启动 N 个 Runner | +| `./runner.sh add [-n N]` | 在现有 Runner 编号之后继续追加 N 个 Runner | +| `./runner.sh compose` | 基于已有 Runner 重新生成 compose 文件 | | `./runner.sh register [runner- ...]` | 注册指定实例;不带参数则注册所有未配置实例 | | `./runner.sh start/stop/restart [runner- ...]` | 启动/停止/重启容器 | -| `./runner.sh logs runner-` | 查看实例日志 | +| `./runner.sh log runner-` | 跟随查看实例日志 | | `./runner.sh ps` | 显示容器状态 | | `./runner.sh list` | 显示本地容器状态及 GitHub 注册状态 | | `./runner.sh rm [runner- ...] [-y]` | 取消注册并删除容器;`-y` 跳过确认 | | `./runner.sh purge [-y]` | 删除容器并移除生成文件(`docker-compose.yml`、缓存等) | - -> **注意**:`init` 命令默认会创建两个基于硬件的 Runner(phytiumpi 和 roc-rk3568-pc),此行为不受 `-n` 参数控制。 +| `./runner.sh image` | 重新构建自定义 Runner 镜像 | ## 配置说明 @@ -58,15 +59,26 @@ chmod +x runner.sh 默认前缀自动包含 `ORG`(及 `REPO`),格式为 `--runner-N` 或 `---runner-N`,避免多组织/多仓库容器重名。可通过 `RUNNER_NAME_PREFIX` 覆盖。 -### BOARD_RUNNERS 格式 +### Runner 配置 -``` -name:label1[,label2];name2:label1 -``` +所有 Runner 都使用同一种命名格式:`${RUNNER_NAME_PREFIX}runner-N`。`-n` 控制 Runner 总数。默认 `RUNNER_*` 变量作用于所有 Runner,也可以追加 `_1`、`_2` 这类序号为单个 Runner 覆盖配置。 -示例:`phytiumpi:arm64,phytiumpi;roc-rk3568-pc:arm64,roc-rk3568-pc` +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `RUNNER_LABELS` | `intel` | 注册 Runner 时使用的标签 | +| `RUNNER_DEVICES` | `/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm` | 逗号分隔的设备映射;没有 `:` 的条目会映射到容器内相同路径 | +| `RUNNER_GROUP_ADD` | `dialout` | 逗号分隔的额外用户组 | +| `RUNNER_VOLUMES` | 空 | 分号分隔的额外卷挂载 | +| `RUNNER_ENV` | 空 | 分号分隔的 `KEY=VALUE` 环境变量 | +| `RUNNER_COMMAND` | `/home/runner/run.sh` | Runner 执行的启动命令 | -开发板实例将仅使用 `BOARD_RUNNERS` 中定义的标签,不会追加全局 `RUNNER_LABELS`。 +按 Runner 覆盖配置示例: + +```bash +RUNNER_LABELS_2=board,phytiumpi,arm64 +RUNNER_DEVICES_2=/dev/kvm,/dev/ttyUSB0:/dev/ttyUSB0 +RUNNER_ENV_2='BOARD_NAME=phytiumpi;SERIAL=/dev/ttyUSB0' +``` ### 其他配置 diff --git a/runner.sh b/runner.sh index 8f2166a..79df657 100755 --- a/runner.sh +++ b/runner.sh @@ -6,79 +6,83 @@ export COMPOSE_IGNORE_ORPHANS=1 ENV_FILE="${ENV_FILE:-.env}" +shell_load_env_file() { + local file="$1" line + [[ -f "$file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue + export "$line" + done < "$file" +} + +shell_default_runner_prefix() { + if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then + printf '%s-%s-%s-' "$(hostname)" "$ORG" "$REPO" + elif [[ -n "${ORG:-}" ]]; then + printf '%s-%s-' "$(hostname)" "$ORG" + else + printf '%s-' "$(hostname)" + fi +} + +shell_scoped_file() { + local base="$1" ext="${2:-}" suffix="" + if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then + suffix=".${ORG}.${REPO}" + elif [[ -n "${ORG:-}" ]]; then + suffix=".${ORG}" + fi + printf '%s%s%s\n' "$base" "$suffix" "$ext" +} + +shell_refresh_derived_config() { + if [[ "${RUNNER_NAME_PREFIX_AUTO:-0}" -eq 1 ]]; then + RUNNER_NAME_PREFIX="$(shell_default_runner_prefix)" + else + [[ "$RUNNER_NAME_PREFIX" == *- ]] || RUNNER_NAME_PREFIX="${RUNNER_NAME_PREFIX}-" + fi + + [[ "${COMPOSE_FILE_AUTO:-0}" -eq 1 ]] && COMPOSE_FILE="$(shell_scoped_file "docker-compose" ".yml")" + [[ "${DOCKERFILE_HASH_FILE_AUTO:-0}" -eq 1 ]] && DOCKERFILE_HASH_FILE="$(shell_scoped_file ".dockerfile" ".sha256")" + [[ "${REG_TOKEN_CACHE_FILE_AUTO:-0}" -eq 1 ]] && REG_TOKEN_CACHE_FILE="$(shell_scoped_file ".reg_token.cache")" +} + # ------------------------------- load .env file ------------------------------- -if [[ -f "$ENV_FILE" ]]; then - # shellcheck disable=SC2046 - export $(grep -v '^[[:space:]]*#' "$ENV_FILE" | grep -v '^[[:space:]]*$' | sed 's/^/export /') -fi +shell_load_env_file "$ENV_FILE" -# Organization, REG_TOKEN, etc. +# Organization, PAT, etc. ORG="${ORG:-}" GH_PAT="${GH_PAT:-}" REPO="${REPO:-}" # Runner container related parameters +RUNNER_NAME_PREFIX_AUTO=0 +COMPOSE_FILE_AUTO=0 +DOCKERFILE_HASH_FILE_AUTO=0 +REG_TOKEN_CACHE_FILE_AUTO=0 +[[ -z "${RUNNER_NAME_PREFIX:-}" ]] && RUNNER_NAME_PREFIX_AUTO=1 +[[ -z "${COMPOSE_FILE:-}" ]] && COMPOSE_FILE_AUTO=1 +[[ -z "${DOCKERFILE_HASH_FILE:-}" ]] && DOCKERFILE_HASH_FILE_AUTO=1 +[[ -z "${REG_TOKEN_CACHE_FILE:-}" ]] && REG_TOKEN_CACHE_FILE_AUTO=1 + RUNNER_IMAGE="${RUNNER_IMAGE:-ghcr.io/actions/actions-runner:latest}" RUNNER_CUSTOM_IMAGE="${RUNNER_CUSTOM_IMAGE:-qc-actions-runner:v0.0.1}" -if [[ -z "${RUNNER_NAME_PREFIX:-}" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - RUNNER_NAME_PREFIX="$(hostname)-${ORG}-${REPO}-" - elif [[ -n "${ORG:-}" ]]; then - RUNNER_NAME_PREFIX="$(hostname)-${ORG}-" - else - RUNNER_NAME_PREFIX="$(hostname)-" - fi -else - [[ "$RUNNER_NAME_PREFIX" == *- ]] || RUNNER_NAME_PREFIX="${RUNNER_NAME_PREFIX}-" -fi +RUNNER_NAME_PREFIX="${RUNNER_NAME_PREFIX:-}" RUNNER_GROUP="${RUNNER_GROUP:-Default}" RUNNER_WORKDIR="${RUNNER_WORKDIR:-}" RUNNER_LABELS="${RUNNER_LABELS:-intel}" -RUNNER_BOARD_COUNT="${RUNNER_BOARD_COUNT:-${RUNNER_BOARD:-2}}" -RUNNER_BOARD="${RUNNER_BOARD_COUNT}" -BOARD_RUNNER_LABELS="${BOARD_RUNNER_LABELS:-board}" -BOARD_RUNNER_DEVICES="${BOARD_RUNNER_DEVICES:-/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm}" -BOARD_RUNNER_GROUP_ADD="${BOARD_RUNNER_GROUP_ADD:-dialout}" -BOARD_RUNNER_VOLUMES="${BOARD_RUNNER_VOLUMES:-}" -BOARD_RUNNER_ENV="${BOARD_RUNNER_ENV:-}" -BOARD_RUNNER_COMMAND="${BOARD_RUNNER_COMMAND:-/home/runner/run.sh}" +RUNNER_DEVICES="${RUNNER_DEVICES:-/dev/loop-control,/dev/loop0,/dev/loop1,/dev/loop2,/dev/loop3,/dev/kvm}" +RUNNER_GROUP_ADD="${RUNNER_GROUP_ADD:-dialout}" +RUNNER_VOLUMES="${RUNNER_VOLUMES:-}" +RUNNER_ENV="${RUNNER_ENV:-}" +RUNNER_COMMAND="${RUNNER_COMMAND:-/home/runner/run.sh}" DISABLE_AUTO_UPDATE="${DISABLE_AUTO_UPDATE:-false}" COMPOSE_FILE="${COMPOSE_FILE:-}" DOCKERFILE_HASH_FILE="${DOCKERFILE_HASH_FILE:-}" REG_TOKEN_CACHE_FILE="${REG_TOKEN_CACHE_FILE:-}" REG_TOKEN_CACHE_TTL="${REG_TOKEN_CACHE_TTL:-300}" # seconds, default 5 minutes -# Compose 文件名:未显式设置时自动拼入 ORG/REPO,避免同一主机多组织时文件冲突 -# 组织级默认:docker-compose..yml 仓库级默认:docker-compose...yml -if [[ -z "$COMPOSE_FILE" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - COMPOSE_FILE="docker-compose.${ORG}.${REPO}.yml" - elif [[ -n "${ORG:-}" ]]; then - COMPOSE_FILE="docker-compose.${ORG}.yml" - else - COMPOSE_FILE="docker-compose.yml" - fi -fi -# Dockerfile hash 文件名:同样根据 ORG/REPO 区分,避免多组织时 hash 冲突 -if [[ -z "$DOCKERFILE_HASH_FILE" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - DOCKERFILE_HASH_FILE=".dockerfile.${ORG}.${REPO}.sha256" - elif [[ -n "${ORG:-}" ]]; then - DOCKERFILE_HASH_FILE=".dockerfile.${ORG}.sha256" - else - DOCKERFILE_HASH_FILE=".dockerfile.sha256" - fi -fi -# REG_TOKEN_CACHE_FILE 文件名:未显式设置时自动拼入 ORG/REPO,避免同一主机多组织时文件冲突 -# 组织级默认:.reg_token.cache. 仓库级默认:.reg_token.cache.. -if [[ -z "$REG_TOKEN_CACHE_FILE" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - REG_TOKEN_CACHE_FILE=".reg_token.cache.${ORG}.${REPO}" - elif [[ -n "${ORG:-}" ]]; then - REG_TOKEN_CACHE_FILE=".reg_token.cache.${ORG}" - else - REG_TOKEN_CACHE_FILE=".reg_token.cache" - fi -fi +shell_refresh_derived_config # ------------------------------- Helpers ------------------------------- shell_usage() { @@ -88,7 +92,8 @@ shell_usage() { echo "1. Creation commands:" printf " %-${COLW}s %s\n" "./runner.sh init -n N" "Generate docker-compose.yml then create runners and start" - printf " %-${COLW}s %s\n" "./runner.sh compose" "Regenerate docker-compose.yml with existing generic and board-specific runners" + printf " %-${COLW}s %s\n" "./runner.sh add [-n N]" "Append N new runners after existing runner indexes" + printf " %-${COLW}s %s\n" "./runner.sh compose" "Regenerate docker-compose.yml with existing runners" echo echo "2. Instance operation commands:" @@ -124,13 +129,13 @@ shell_usage() { printf " %-${KEYW}s %s\n" "RUNNER_NAME_PREFIX" "Runner name prefix" printf " %-${KEYW}s %s\n" "RUNNER_IMAGE" "Image used for compose generation (default ghcr.io/actions/actions-runner:latest)" printf " %-${KEYW}s %s\n" "RUNNER_CUSTOM_IMAGE" "Image tag used for auto-build (can override)" - printf " %-${KEYW}s %s\n" "RUNNER_BOARD_COUNT" "Number of generic board runners to create" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_LABELS" "Default labels for board runners" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_DEVICES" "Comma-separated device list for board runners" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_GROUP_ADD" "Comma-separated extra groups for board runners" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_VOLUMES" "Semicolon-separated extra volume mounts for board runners" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_ENV" "Semicolon-separated KEY=VALUE envs for board runners" - printf " %-${KEYW}s %s\n" "BOARD_RUNNER_COMMAND" "Command executed by board runners" + printf " %-${KEYW}s %s\n" "RUNNER_LABELS" "Default labels for runners" + printf " %-${KEYW}s %s\n" "RUNNER_DEVICES" "Comma-separated device list for runners" + printf " %-${KEYW}s %s\n" "RUNNER_GROUP_ADD" "Comma-separated extra groups for runners" + printf " %-${KEYW}s %s\n" "RUNNER_VOLUMES" "Semicolon-separated extra volume mounts for runners" + printf " %-${KEYW}s %s\n" "RUNNER_ENV" "Semicolon-separated KEY=VALUE envs for runners" + printf " %-${KEYW}s %s\n" "RUNNER_COMMAND" "Command executed by runners" + printf " %-${KEYW}s %s\n" "RUNNER_*_" "Per-runner overrides, e.g. RUNNER_LABELS_1 or RUNNER_DEVICES_2" echo echo "Example workflow runs-on: runs-on: [self-hosted, linux, docker]" @@ -163,6 +168,16 @@ shell_trim() { printf '%s' "$value" } +shell_env_upsert() { + local file="$1" key="$2" value="$3" tmp + [[ -n "$key" && -n "$value" ]] || return 0 + + tmp="$(mktemp "${file}.tmp.XXXXXX")" + grep -v -E "^[[:space:]]*${key}=" "$file" > "$tmp" || true + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + shell_get_indexed_env() { local key="$1" index="$2" default_value="${3:-}" local indexed_key="${key}_${index}" @@ -173,6 +188,54 @@ shell_get_indexed_env() { fi } +shell_parse_count_arg() { + local default_count="$1"; shift + local count="$default_count" + + if [[ "${1:-}" == "-n" || "${1:-}" == "--count" ]]; then + shift + count="${1:-}" + [[ -n "$count" ]] || shell_die "Count is required after -n|--count!" + shift || true + fi + [[ "$count" =~ ^[0-9]+$ ]] || shell_die "Count must be numeric!" + + PARSED_COUNT="$count" + PARSED_REMAINING_ARGS=("$@") +} + +shell_runner_index_from_name() { + local name="$1" prefix="${RUNNER_NAME_PREFIX}runner-" + [[ "$name" == "${prefix}"* ]] || return 1 + + local index="${name#"$prefix"}" + [[ "$index" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$index" +} + +shell_get_max_existing_runner_index() { + local name index max_index=0 + local existing_names=() + mapfile -t existing_names < <(docker_list_existing_containers | sed '/^$/d') || true + + if [[ -f "$COMPOSE_FILE" ]]; then + while IFS= read -r name; do + [[ -n "$name" ]] || continue + existing_names+=("$name") + done < <(sed -n -E 's/^ ([^[:space:]:]+):[[:space:]]*$/\1/p' "$COMPOSE_FILE" || true) + fi + + for name in "${existing_names[@]}"; do + [[ -n "$name" ]] || continue + index="$(shell_runner_index_from_name "$name" || true)" + if [[ -n "$index" && "$index" -gt "$max_index" ]]; then + max_index="$index" + fi + done + + printf '%s\n' "$max_index" +} + shell_append_csv_yaml_list() { local file="$1" indent="$2" csv="$3" local item trimmed @@ -291,82 +354,19 @@ shell_get_org_and_pat() { export ORG GH_PAT REPO - # Recalculate RUNNER_NAME_PREFIX if it was auto-generated (not explicitly set by user) - # Same logic as COMPOSE_FILE etc.: check if empty or equals default value (hostname only) - local default_prefix default_org_prefix default_repo_prefix - default_prefix="$(hostname)-" - default_org_prefix="$(hostname)-${ORG}-" - default_repo_prefix="$(hostname)-${ORG}-${REPO}-" - if [[ -z "${RUNNER_NAME_PREFIX:-}" ]] || \ - [[ "$RUNNER_NAME_PREFIX" == "$default_prefix" ]] || \ - [[ -n "${ORG:-}" && "$RUNNER_NAME_PREFIX" == "$default_org_prefix" ]] || \ - [[ -n "${ORG:-}" && -n "${REPO:-}" && "$RUNNER_NAME_PREFIX" == "$default_repo_prefix" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - RUNNER_NAME_PREFIX="$(hostname)-${ORG}-${REPO}-" - elif [[ -n "${ORG:-}" ]]; then - RUNNER_NAME_PREFIX="$(hostname)-${ORG}-" - else - RUNNER_NAME_PREFIX="$default_prefix" - fi - export RUNNER_NAME_PREFIX - fi - - # Recalculate file paths based on newly obtained ORG/REPO - if [[ -z "${COMPOSE_FILE:-}" ]] || [[ "$COMPOSE_FILE" == "docker-compose.yml" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - COMPOSE_FILE="docker-compose.${ORG}.${REPO}.yml" - elif [[ -n "${ORG:-}" ]]; then - COMPOSE_FILE="docker-compose.${ORG}.yml" - else - COMPOSE_FILE="docker-compose.yml" - fi - export COMPOSE_FILE - fi - if [[ -z "${DOCKERFILE_HASH_FILE:-}" ]] || [[ "$DOCKERFILE_HASH_FILE" == ".dockerfile.sha256" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - DOCKERFILE_HASH_FILE=".dockerfile.${ORG}.${REPO}.sha256" - elif [[ -n "${ORG:-}" ]]; then - DOCKERFILE_HASH_FILE=".dockerfile.${ORG}.sha256" - else - DOCKERFILE_HASH_FILE=".dockerfile.sha256" - fi - export DOCKERFILE_HASH_FILE - fi - if [[ -z "${REG_TOKEN_CACHE_FILE:-}" ]] || [[ "$REG_TOKEN_CACHE_FILE" == ".reg_token.cache" ]]; then - if [[ -n "${ORG:-}" && -n "${REPO:-}" ]]; then - REG_TOKEN_CACHE_FILE=".reg_token.cache.${ORG}.${REPO}" - elif [[ -n "${ORG:-}" ]]; then - REG_TOKEN_CACHE_FILE=".reg_token.cache.${ORG}" - else - REG_TOKEN_CACHE_FILE=".reg_token.cache" - fi - export REG_TOKEN_CACHE_FILE - fi + # Recalculate auto-derived names after interactive ORG/REPO input. + shell_refresh_derived_config + export RUNNER_NAME_PREFIX COMPOSE_FILE DOCKERFILE_HASH_FILE REG_TOKEN_CACHE_FILE # Persist to .env (ENV_FILE) if values were entered interactively if [[ $wrote_env -eq 1 ]]; then - local env_file="$ENV_FILE" tmp + local env_file="$ENV_FILE" touch "$env_file" chmod 600 "$env_file" 2>/dev/null || true - if [[ -n "${ORG:-}" ]]; then - tmp="$(mktemp "${env_file}.tmp.XXXXXX")" - grep -v -E '^[[:space:]]*ORG=' "$env_file" > "$tmp" || true - printf 'ORG=%s\n' "$ORG" >> "$tmp" - mv "$tmp" "$env_file" - fi - if [[ -n "${GH_PAT:-}" ]]; then - tmp="$(mktemp "${env_file}.tmp.XXXXXX")" - grep -v -E '^[[:space:]]*GH_PAT=' "$env_file" > "$tmp" || true - printf 'GH_PAT=%s\n' "$GH_PAT" >> "$tmp" - mv "$tmp" "$env_file" - fi - if [[ -n "${REPO:-}" ]]; then - tmp="$(mktemp "${env_file}.tmp.XXXXXX")" - grep -v -E '^[[:space:]]*REPO=' "$env_file" > "$tmp" || true - printf 'REPO=%s\n' "$REPO" >> "$tmp" - mv "$tmp" "$env_file" - fi + shell_env_upsert "$env_file" "ORG" "${ORG:-}" + shell_env_upsert "$env_file" "GH_PAT" "${GH_PAT:-}" + shell_env_upsert "$env_file" "REPO" "${REPO:-}" fi } @@ -388,11 +388,15 @@ shell_prepare_runner_image() { [[ -f "$hash_file" ]] && old_hash=$(cat "$hash_file" 2>/dev/null || true) if [[ "$new_hash" != "$old_hash" ]]; then shell_info "Detected Dockerfile change, building ${RUNNER_CUSTOM_IMAGE} image" >&2 - docker build -t "${RUNNER_CUSTOM_IMAGE}" . 1>&2 - echo "$new_hash" > "$hash_file" - shell_info "Build complete. Will use ${RUNNER_CUSTOM_IMAGE} as image" >&2 - echo "${RUNNER_CUSTOM_IMAGE}" - return 0 + if docker build -t "${RUNNER_CUSTOM_IMAGE}" . 1>&2; then + echo "$new_hash" > "$hash_file" + shell_info "Build complete. Will use ${RUNNER_CUSTOM_IMAGE} as image" >&2 + echo "${RUNNER_CUSTOM_IMAGE}" + return 0 + else + shell_warn "Docker build failed; hash file not updated. Fix the build error and retry." >&2 + # fall through to check for existing image or use base + fi fi fi @@ -421,7 +425,11 @@ shell_delete_all_execute() { if command -v jq >/dev/null 2>&1; then resp=$(github_api GET "/actions/runners?per_page=100" || echo "{}") - org_count=$(echo "$resp" | jq -r --arg p "$prefix" '[.runners[] | select(.name|startswith($p))] | length' 2>/dev/null || echo 0) + if github_response_has_runner_list "$resp"; then + org_count=$(echo "$resp" | jq -r --arg p "$prefix" '[.runners[] | select(.name|startswith($p))] | length' 2>/dev/null || echo 0) + else + github_print_api_error "$resp" + fi else shell_warn "jq is not installed; cannot count organization runners. Will only remove local containers/volumes and attempt best-effort unregister." fi @@ -479,45 +487,6 @@ shell_get_reg_token() { printf '%s\n' "$REG_TOKEN" } -# Update a field in docker-compose.yml under environment (mapping or list styles) -# Usage: shell_update_compose_file KEY VALUE -shell_update_compose_file() { - local key="$1" token="$2" - [[ -n "$key" && -n "$token" ]] || return 0 - local file="$COMPOSE_FILE" - [[ -f "$file" ]] || { shell_warn "${file} not found; skip updating ${key}." >&2; return 0; } - - local tmpfile updated - tmpfile=$(mktemp "${file}.tmp.XXXXXX") || return 1 - updated=0 - - while IFS= read -r line; do - # Mapping style: KEY: "value" (preserve indentation and spacing) - if [[ "$line" =~ ^[[:space:]]*${key}[[:space:]]*: ]]; then - # Extract leading whitespace and preserve it - local indent="${line%%[^ ]*}" - printf '%s%s: "%s"\n' "$indent" "$key" "$token" >> "$tmpfile" - updated=1 - # List style: - KEY="value" (preserve indentation and dash) - elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*${key}= ]]; then - # Extract leading spaces and dash - local prefix="${line%${key}*}" - printf '%s%s="%s"\n' "$prefix" "$key" "$token" >> "$tmpfile" - updated=1 - else - printf '%s\n' "$line" >> "$tmpfile" - fi - done < "$file" - - if [[ $updated -eq 1 ]]; then - mv "$tmpfile" "$file" - shell_info "Updated ${key} in ${file}." >&2 - else - rm -f "$tmpfile" - shell_warn "${key} key not found in ${file}; ensure your compose defines it under environment." >&2 - fi -} - # Helper: Extract an environment variable value for a specific service from docker-compose.yml # Usage: shell_get_compose_file SERVICE_NAME ENV_KEY shell_get_compose_file() { @@ -549,7 +518,10 @@ shell_get_compose_file() { # If we're in environment section, look for the key if [[ $in_env -eq 1 ]]; then # Stop if we encounter a key at lower indentation level (end of environment) - if [[ "$line" =~ ^[a-zA-Z_] ]] || [[ "$line" =~ ^[[:space:]]{0,2}[a-zA-Z_] ]]; then + # {0,4} matches top-level and service-level keys (0-4 spaces), but not environment + # entries which are indented at 6+ spaces in the generated compose file + if [[ "$line" =~ ^[a-zA-Z_] ]] || [[ "$line" =~ ^[[:space:]]{0,4}[a-zA-Z_] ]]; then + in_env=0 break fi @@ -572,8 +544,7 @@ shell_get_compose_file() { } shell_generate_compose_file() { - local general_count=$1 - local board_count="${RUNNER_BOARD_COUNT:-0}" + local runner_count=$1 local extra_proxy_env=() local kvm_gid="${RUNNER_KVM_GID:-}" @@ -588,126 +559,73 @@ shell_generate_compose_file() { kvm_gid="993" shell_warn "/dev/kvm gid not detected; falling back to legacy gid ${kvm_gid}. Set RUNNER_KVM_GID to override." fi - [[ -n "${HTTP_PROXY:-}" ]] && extra_proxy_env+=(" HTTP_PROXY: \"${HTTP_PROXY}\"") - [[ -n "${HTTPS_PROXY:-}" ]] && extra_proxy_env+=(" HTTPS_PROXY: \"${HTTPS_PROXY}\"") - [[ -n "${NO_PROXY:-}" ]] && extra_proxy_env+=(" NO_PROXY: \"${NO_PROXY}\"") + [[ -n "${HTTP_PROXY:-}" ]] && extra_proxy_env+=(" HTTP_PROXY: \"${HTTP_PROXY}\"") + [[ -n "${HTTPS_PROXY:-}" ]] && extra_proxy_env+=(" HTTPS_PROXY: \"${HTTPS_PROXY}\"") + [[ -n "${NO_PROXY:-}" ]] && extra_proxy_env+=(" NO_PROXY: \"${NO_PROXY}\"") # 使用 printf 输出文件头 printf '%s\n' \ "# 自动生成的 Docker Compose 配置" \ "# 机器名: $(hostname)" \ - "# 普通 runner 数量: $general_count" \ - "# 板子 runner 数量: ${board_count}" \ + "# runner 数量: $runner_count" \ "" \ "# 基础配置" \ "x-${RUNNER_NAME_PREFIX}runner-base: &runner_base" \ " image: \"${RUNNER_IMAGE}\"" \ " restart: unless-stopped" \ - " environment: &runner_env" \ - " RUNNER_ORG_URL: \"https://github.com/${ORG}${REPO:+/}${REPO}\"" \ - " RUNNER_TOKEN: \"${REG_TOKEN}\"" \ - " RUNNER_GROUP: \"${RUNNER_GROUP}\"" \ - " RUNNER_REMOVE_ON_STOP: \"false\"" \ - " DISABLE_AUTO_UPDATE: \"${DISABLE_AUTO_UPDATE}\"" \ - " RUNNER_WORKDIR: \"${RUNNER_WORKDIR}\"" \ - "${extra_proxy_env[@]}" \ " network_mode: host" \ " privileged: true" \ "" \ "services:" > "${COMPOSE_FILE}" - # 生成普通 runners - echo " # 普通 runners" >> ${COMPOSE_FILE} - for i in $(seq 1 $general_count); do + # 生成 runners。差异通过 RUNNER_*_ 环境变量按实例覆盖。 + local i runner_labels runner_devices runner_groups runner_env runner_volumes runner_command + for i in $(seq 1 "$runner_count"); do + runner_labels="$(shell_get_indexed_env "RUNNER_LABELS" "$i" "${RUNNER_LABELS}")" + runner_devices="$(shell_get_indexed_env "RUNNER_DEVICES" "$i" "${RUNNER_DEVICES}")" + runner_groups="$(shell_get_indexed_env "RUNNER_GROUP_ADD" "$i" "${RUNNER_GROUP_ADD}")" + runner_env="$(shell_get_indexed_env "RUNNER_ENV" "$i" "${RUNNER_ENV}")" + runner_volumes="$(shell_get_indexed_env "RUNNER_VOLUMES" "$i" "${RUNNER_VOLUMES}")" + runner_command="$(shell_get_indexed_env "RUNNER_COMMAND" "$i" "${RUNNER_COMMAND}")" + printf '%s\n' \ " ${RUNNER_NAME_PREFIX}runner-${i}:" \ " <<: *runner_base" \ " container_name: \"${RUNNER_NAME_PREFIX}runner-${i}\"" \ - " command: [\"/home/runner/run.sh\"]" \ - " devices:" \ - " - /dev/loop-control:/dev/loop-control" \ - " - /dev/loop0:/dev/loop0" \ - " - /dev/loop1:/dev/loop1" \ - " - /dev/loop2:/dev/loop2" \ - " - /dev/loop3:/dev/loop3" \ - " - /dev/kvm:/dev/kvm" \ + " command:" \ + " - /bin/bash" \ + " - -lc" \ + " - |" \ + " exec ${runner_command}" \ + " devices:" >> "${COMPOSE_FILE}" + shell_append_device_yaml_list "${COMPOSE_FILE}" " " "${runner_devices}" + printf '%s\n' \ " group_add:" \ - " - ${kvm_gid}" \ + " - ${kvm_gid}" >> "${COMPOSE_FILE}" + shell_append_csv_yaml_list "${COMPOSE_FILE}" " " "${runner_groups}" + printf '%s\n' \ " environment:" \ - " <<: *runner_env" \ - " RUNNER_NAME: \"${RUNNER_NAME_PREFIX}runner-${i}\"" \ - " RUNNER_LABELS: \"${RUNNER_LABELS}\"" \ + " RUNNER_LABELS: \"${runner_labels}\"" \ + "${extra_proxy_env[@]}" >> "${COMPOSE_FILE}" + shell_append_semicolon_env_map "${COMPOSE_FILE}" " " "${runner_env}" + printf '%s\n' \ " volumes:" \ " - ${RUNNER_NAME_PREFIX}runner-${i}-data:/home/runner" \ - " - ${RUNNER_NAME_PREFIX}runner-${i}-udev-rules:/etc/udev/rules.d" \ - "" >> "${COMPOSE_FILE}" + " - ${RUNNER_NAME_PREFIX}runner-${i}-udev-rules:/etc/udev/rules.d" >> "${COMPOSE_FILE}" + shell_append_semicolon_yaml_list "${COMPOSE_FILE}" " " "${runner_volumes}" + printf '\n' >> "${COMPOSE_FILE}" done - # 只有当 RUNNER_BOARD_COUNT 大于 0 时才生成板子 runners - if [[ "${board_count}" -gt 0 ]]; then - echo " # 板子专用 runners" >> "${COMPOSE_FILE}" - local i board_labels board_devices board_groups board_env board_volumes board_command - for i in $(seq 1 "$board_count"); do - board_labels="$(shell_get_indexed_env "BOARD_RUNNER_LABELS" "$i" "${BOARD_RUNNER_LABELS}")" - board_devices="$(shell_get_indexed_env "BOARD_RUNNER_DEVICES" "$i" "${BOARD_RUNNER_DEVICES}")" - board_groups="$(shell_get_indexed_env "BOARD_RUNNER_GROUP_ADD" "$i" "${BOARD_RUNNER_GROUP_ADD}")" - board_env="$(shell_get_indexed_env "BOARD_RUNNER_ENV" "$i" "${BOARD_RUNNER_ENV}")" - board_volumes="$(shell_get_indexed_env "BOARD_RUNNER_VOLUMES" "$i" "${BOARD_RUNNER_VOLUMES}")" - board_command="$(shell_get_indexed_env "BOARD_RUNNER_COMMAND" "$i" "${BOARD_RUNNER_COMMAND}")" - - printf '%s\n' \ - " ${RUNNER_NAME_PREFIX}runner-board-${i}:" \ - " <<: *runner_base" \ - " container_name: \"${RUNNER_NAME_PREFIX}runner-board-${i}\"" \ - " command:" \ - " - /bin/bash" \ - " - -lc" \ - " - |" \ - " exec ${board_command}" \ - " devices:" >> "${COMPOSE_FILE}" - shell_append_device_yaml_list "${COMPOSE_FILE}" " " "${board_devices}" - printf '%s\n' \ - " group_add:" \ - " - ${kvm_gid}" >> "${COMPOSE_FILE}" - shell_append_csv_yaml_list "${COMPOSE_FILE}" " " "${board_groups}" - printf '%s\n' \ - " environment:" \ - " <<: *runner_env" \ - " RUNNER_NAME: \"${RUNNER_NAME_PREFIX}runner-board-${i}\"" \ - " RUNNER_LABELS: \"${board_labels}\"" \ - " RUNNER_BOARD_INDEX: \"${i}\"" >> "${COMPOSE_FILE}" - shell_append_semicolon_env_map "${COMPOSE_FILE}" " " "${board_env}" - printf '%s\n' \ - " volumes:" \ - " - ${RUNNER_NAME_PREFIX}runner-board-${i}-data:/home/runner" \ - " - ${RUNNER_NAME_PREFIX}runner-board-${i}-udev-rules:/etc/udev/rules.d" >> "${COMPOSE_FILE}" - shell_append_semicolon_yaml_list "${COMPOSE_FILE}" " " "${board_volumes}" - printf '\n' >> "${COMPOSE_FILE}" - done - fi - # 生成 volumes echo "volumes:" >> ${COMPOSE_FILE} - for i in $(seq 1 $general_count); do + for i in $(seq 1 "$runner_count"); do printf '%s\n' \ " ${RUNNER_NAME_PREFIX}runner-${i}-data:" \ " name: ${RUNNER_NAME_PREFIX}runner-${i}-data" \ " ${RUNNER_NAME_PREFIX}runner-${i}-udev-rules:" \ " name: ${RUNNER_NAME_PREFIX}runner-${i}-udev-rules" >> "${COMPOSE_FILE}" done - - # 只有当 RUNNER_BOARD_COUNT 大于 0 时才生成板子相关的 volumes - if [[ "${board_count}" -gt 0 ]]; then - local i - for i in $(seq 1 "$board_count"); do - printf '%s\n' \ - " ${RUNNER_NAME_PREFIX}runner-board-${i}-data:" \ - " name: ${RUNNER_NAME_PREFIX}runner-board-${i}-data" \ - " ${RUNNER_NAME_PREFIX}runner-board-${i}-udev-rules:" \ - " name: ${RUNNER_NAME_PREFIX}runner-board-${i}-udev-rules" >> "${COMPOSE_FILE}" - done - fi } # ------------------------------- GitHub API helpers ------------------------------- @@ -746,10 +664,38 @@ github_fetch_reg_token() { echo "${token}" } +github_response_has_runner_list() { + local resp="$1" + if command -v jq >/dev/null 2>&1; then + echo "$resp" | jq -e '.runners | type == "array"' >/dev/null 2>&1 + else + echo "$resp" | grep -q '"runners"[[:space:]]*:' + fi +} + +github_print_api_error() { + local resp="$1" message documentation_url + if command -v jq >/dev/null 2>&1; then + message="$(echo "$resp" | jq -r '.message // empty' 2>/dev/null || true)" + documentation_url="$(echo "$resp" | jq -r '.documentation_url // empty' 2>/dev/null || true)" + else + message="$(echo "$resp" | sed -n 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + documentation_url="$(echo "$resp" | sed -n 's/.*"documentation_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + fi + + if [[ -n "$message" ]]; then + shell_warn "GitHub API did not return a runner list: ${message}" + else + shell_warn "GitHub API did not return a runner list. Check ORG/REPO, GH_PAT, and token permissions." + fi + [[ -n "$documentation_url" ]] && shell_warn "GitHub API documentation: ${documentation_url}" +} + github_get_runner_id_by_name() { local name="$1" local resp resp=$(github_api GET "/actions/runners?per_page=100") || return 1 + github_response_has_runner_list "$resp" || { github_print_api_error "$resp"; return 1; } if command -v jq >/dev/null 2>&1; then echo "$resp" | jq -r --arg n "$name" '.runners[] | select(.name==$n) | .id' | head -1 else @@ -766,6 +712,7 @@ github_delete_all_runners_with_prefix() { local prefix="${RUNNER_NAME_PREFIX}runner-" local resp resp=$(github_api GET "/actions/runners?per_page=100" || echo "{}") + github_response_has_runner_list "$resp" || { github_print_api_error "$resp"; return 1; } if command -v jq >/dev/null 2>&1; then while IFS=$'\t' read -r id name; do [[ -n "$id" && "$id" != "null" ]] || continue @@ -870,8 +817,15 @@ docker_runner_register() { is_configured=true fi else - if docker exec "$cname" bash -c 'test -f /home/runner/.runner && test -f /home/runner/.credentials' >/dev/null 2>&1; then - is_configured=true + # docker exec only works on running containers; use docker run with the volume + # so that stopped containers are also correctly detected as already configured + local _cimg + _cimg=$(docker inspect "$cname" --format='{{.Config.Image}}' 2>/dev/null || true) + if [[ -n "$_cimg" ]]; then + if docker run --rm -v "${cname}-data:/home/runner" "$_cimg" \ + bash -c 'test -f /home/runner/.runner && test -f /home/runner/.credentials' >/dev/null 2>&1; then + is_configured=true + fi fi fi @@ -900,7 +854,7 @@ docker_runner_register() { "--unattended" "--replace" ) [[ -n "${RUNNER_WORKDIR}" ]] && cfg_opts+=("--work" "${RUNNER_WORKDIR}") - [[ "${DISABLE_AUTO_UPDATE}" == "1" ]] && cfg_opts+=("--disableupdate") + [[ "${DISABLE_AUTO_UPDATE}" == "1" || "${DISABLE_AUTO_UPDATE}" == "true" ]] && cfg_opts+=("--disableupdate") shell_info "Registering ${cname} on GitHub with ${cfg_opts[@]}" # Pass arguments directly to avoid shell quoting issues @@ -931,11 +885,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "--------------------------------- Runners --------------------------------------------" resp=$(github_api GET "/actions/runners?per_page=100") || shell_die "Failed to fetch runner list." if command -v jq >/dev/null 2>&1; then - echo "$resp" | jq -r '.runners[] | [.name, .status, (if .busy then "busy" else "idle" end), ( [.labels[].name] | join(","))] | @tsv' \ - | grep -E "^${RUNNER_NAME_PREFIX}runner-" \ - | awk -F'\t' 'BEGIN{printf("%-40s %-8s %-6s %s\n","NAME","STATUS","BUSY","LABELS")}{printf("%-40s %-8s %-6s %s\n",$1,$2,$3,$4)}' + if github_response_has_runner_list "$resp"; then + echo "$resp" | jq -r '.runners[] | [.name, .status, (if .busy then "busy" else "idle" end), ( [.labels[].name] | join(","))] | @tsv' \ + | grep -E "^${RUNNER_NAME_PREFIX}runner-" \ + | awk -F'\t' 'BEGIN{printf("%-40s %-8s %-6s %s\n","NAME","STATUS","BUSY","LABELS")}{printf("%-40s %-8s %-6s %s\n",$1,$2,$3,$4)}' + else + github_print_api_error "$resp" + exit 1 + fi else - echo "$resp" + if github_response_has_runner_list "$resp"; then + echo "$resp" + else + github_print_api_error "$resp" + exit 1 + fi fi echo shell_info "Due to GitHub limitations, runner list is limited to 100 entries!" @@ -944,23 +908,15 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # ./runner.sh init -n|--count N init) - count=0 - if [[ "${1:-}" == "-n" || "${1:-}" == "--count" ]]; then - shift - count="${1:-0}" - shift || true - fi - [[ "$count" =~ ^[0-9]+$ ]] || shell_die "Count must be numeric!" + shell_parse_count_arg 0 "$@" + count="$PARSED_COUNT" + set -- "${PARSED_REMAINING_ARGS[@]}" REG_TOKEN="$(shell_get_reg_token)" RUNNER_IMAGE="$(shell_prepare_runner_image)"; - if [[ "${RUNNER_BOARD_COUNT}" -gt 0 ]]; then - shell_info "Generating $COMPOSE_FILE with $count generic runners and ${RUNNER_BOARD_COUNT} board runners." - else - shell_info "Generating $COMPOSE_FILE with $count generic runners." - fi + shell_info "Generating $COMPOSE_FILE with $count runners." shell_generate_compose_file "$count" @@ -968,36 +924,49 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then docker_runner_register ;; + + # ./runner.sh add -n|--count N + add) + shell_parse_count_arg 1 "$@" + add_count="$PARSED_COUNT" + set -- "${PARSED_REMAINING_ARGS[@]}" + [[ "$add_count" -gt 0 ]] || shell_die "Add count must be greater than 0!" + + current_max="$(shell_get_max_existing_runner_index)" + new_total=$((current_max + add_count)) + new_names=() + for i in $(seq $((current_max + 1)) "$new_total"); do + new_names+=("${RUNNER_NAME_PREFIX}runner-${i}") + done + + REG_TOKEN="$(shell_get_reg_token)" + + RUNNER_IMAGE="$(shell_prepare_runner_image)"; + + shell_info "Adding ${add_count} runner(s): ${new_names[*]}" + shell_info "Regenerating $COMPOSE_FILE with ${new_total} total runner slots." + + shell_generate_compose_file "$new_total" + + $DC -f "$COMPOSE_FILE" up -d "$@" "${new_names[@]}"; + + docker_runner_register "${new_names[@]}" + ;; # ./runner.sh compose compose) cont_count=0 cont_list="$(docker_list_existing_containers)" if [[ -n "$cont_list" ]]; then cont_count=$(echo "$cont_list" | wc -l | tr -d ' '); fi - - # 计算通用 runner 的数量 - if [[ "${RUNNER_BOARD_COUNT}" -gt 0 ]]; then - # 如果启用了板子 runner,则减去 RUNNER_BOARD_COUNT 个板子 runner - generic_count=$(( cont_count - RUNNER_BOARD_COUNT )) - [[ "$generic_count" -ge 0 ]] || generic_count=0 - else - # 如果没有启用板子 runner,则所有容器都是通用 runner - generic_count=$cont_count - fi - if [[ "${RUNNER_BOARD_COUNT}" -gt 0 ]]; then - shell_info "Regenerating $COMPOSE_FILE with ${generic_count} existing runners and ${RUNNER_BOARD_COUNT} board runners." - else - shell_info "Regenerating $COMPOSE_FILE with ${generic_count} existing runners." - fi + shell_info "Regenerating $COMPOSE_FILE with ${cont_count} existing runners." RUNNER_IMAGE="$(shell_prepare_runner_image)"; REG_TOKEN="$(shell_get_reg_token)" - shell_generate_compose_file "$generic_count" + shell_generate_compose_file "$cont_count" ;; # ./runner.sh register [${RUNNER_NAME_PREFIX}runner- ...] register) REG_TOKEN="$(shell_get_reg_token)" - shell_update_compose_file "RUNNER_TOKEN" "$REG_TOKEN" if [[ $# -ge 1 ]]; then # Pass incoming parameters (container names or numbers) directly to docker_runner_register @@ -1200,15 +1169,15 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then shell_info "Successfully built ${RUNNER_CUSTOM_IMAGE} image" # Update hash file - local new_hash="" + image_new_hash="" if command -v sha256sum >/dev/null 2>&1; then - new_hash=$(sha256sum Dockerfile | awk '{print $1}') + image_new_hash=$(sha256sum Dockerfile | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then - new_hash=$(shasum -a 256 Dockerfile | awk '{print $1}') + image_new_hash=$(shasum -a 256 Dockerfile | awk '{print $1}') fi - if [[ -n "$new_hash" ]]; then - echo "$new_hash" > "$DOCKERFILE_HASH_FILE" + if [[ -n "$image_new_hash" ]]; then + echo "$image_new_hash" > "$DOCKERFILE_HASH_FILE" shell_info "Updated Dockerfile hash" fi else