ci: add ansible and CICD deployment
PR -> develop / Scan for secrets (Gitleaks) (pull_request) Failing after 4s
PR -> develop / Scan for CVEs (Trivy) (pull_request) Successful in 2s

This commit is contained in:
sangnn
2026-06-23 07:16:42 +00:00
parent a6d74d9316
commit a3b230e65d
24 changed files with 1077 additions and 31 deletions
+84
View File
@@ -0,0 +1,84 @@
# Gitea Actions self-hosted runners — Ansible
Provisions `act_runner` instances as **systemd services**. One host can run runners for
multiple projects (e.g. `gatehouse-api` + `gatehouse-ui`); each project gets its own
runner name, install dir, label set, repo URL, and registration token.
## Layout
```
ansible.cfg inventory + ssh defaults
inventory.ini stage / prod host groups (set ansible_host)
group_vars/all.yml pinned act_runner version + sha256, gitea_instance URL
host_vars/<host>.yml runner_env + per-project `runners` matrix
tasks/install_project.yml reads token, expands count -> N instances
tasks/install_one_runner.yml download/register/service one runner
install-runner.yml the playbook
```
## Runner naming
- name: `<host>-<project>-<env>-<N>` e.g. `stage01-gatehouse-api-stage-1`
- dir: `/home/gitea-runner/act-runner-<project>-<N>`
- labels: `project_spec.labels` (comma-separated, e.g. `self-hosted,linux,stage`)
- service: `gitea-runner-<project>-<N>.service`
## Before first run — pin act_runner version
1. Check [act_runner releases](https://gitea.com/gitea/act_runner/releases) for the latest stable version.
2. Download `act_runner-<version>-linux-amd64.sha256` from the release page.
3. Update `act_runner_version` and `act_runner_sha256` in `group_vars/all.yml`.
## Tokens
Registration tokens are **per-repo and expire ~1h after minting**. Exported as env
vars on the control node, named by each project's `token_env` in `host_vars`.
Mint each from the repo's **Settings → Actions → Runners → Create new runner token**,
then run the playbook within the hour.
## Run
```bash
cd deploy/ansible
# 1. Edit inventory.ini (set ansible_host / ansible_user) and host_vars/*.yml.
# 2. Fill in act_runner_version + act_runner_sha256 in group_vars/all.yml.
# 3. Export fresh tokens (leading space keeps them out of shell history):
export RUNNER_TOKEN_GATEHOUSE_API=xxx
export RUNNER_TOKEN_GATEHOUSE_UI=yyy
ansible-playbook --syntax-check install-runner.yml
ansible-playbook install-runner.yml --check --diff --limit stage # dry run
ansible-playbook install-runner.yml --limit stage # apply
```
## Verify
```bash
# on the host
systemctl list-units 'gitea-runner-*'
ls /home/gitea-runner/ # act-runner-gatehouse-api-1, ...
```
Each repo's **Settings → Actions → Runners** should list the runners as **Idle** with
the expected labels. Idempotent: re-running skips already-registered runners
(`creates: .runner` guards re-registration; binary download is checksum-gated).
## Migrating from GitHub Actions runners
Stop and remove the old runners on any existing host before re-running:
```bash
sudo su - github-runner -c "cd ~/actions-runner-*/; ./svc.sh stop && ./svc.sh uninstall"
sudo userdel -r github-runner # optional — removes home dir too
```
## Notes
- Bump `act_runner_version` + `act_runner_sha256` together in `group_vars/all.yml`.
- Labels in `host_vars/<host>.yml` must match the `runs-on:` values in workflow files.
- `deploy/deploy.sh` needs the runner user to have passwordless sudo for nginx reload —
add that sudoers drop-in separately (not yet automated here). Update the username from
`github-runner` to `gitea-runner` in that drop-in.
- `prod01.ansible_host` in `inventory.ini` is still `CHANGE_ME` — fill in before running prod.
+10
View File
@@ -0,0 +1,10 @@
[defaults]
inventory = inventory.ini
host_key_checking = False
retry_files_enabled = False
stdout_callback = default
result_format = yaml
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
+40
View File
@@ -0,0 +1,40 @@
---
# Shared constants for all runner hosts.
runner_user: gitea-runner
runner_home: /home/gitea-runner
# Base dir for app deploy targets. Each project deploys to <app_base_dir>/<project>
# (e.g. /opt/gatehouse-api), owned by the runner so the deploy job can write the
# compose file there. Must match COMPOSE_DIR in the project's workflow files.
app_base_dir: /opt
gitea_instance: "https://source.hawkless.id.au"
# Pinned act_runner release. Bump version + sha256 together.
# Check latest: https://gitea.com/gitea/act_runner/releases
# sha256 for act_runner-<version>-linux-amd64 is on the release page (act_runner-<version>-linux-amd64.sha256).
act_runner_version: "1.0.8"
act_runner_sha256: "027d726127bb67e191d57052fdb66e74ec7f76966f790a18727147fa2b8005e5"
act_runner_binary: "gitea-runner-{{ act_runner_version }}-linux-amd64"
act_runner_download_url: "https://gitea.com/gitea/runner/releases/download/v{{ act_runner_version }}/{{ act_runner_binary }}"
# Node.js major version installed on the host executor. JS actions
# (actions/checkout@v4, etc.) are run with `node`; without it act_runner
# fails with "Cannot find: node in PATH". Bump this to change versions.
node_major_version: "26"
# Security scanners pre-installed on the host so workflow steps use the local
# binary instead of writing to /usr/local/bin as the runner user ("Permission
# denied"). gitleaks_version must match GITLEAKS_VERSION in pr-security-check.yml.
trivy_version: "0.71.2"
gitleaks_version: "8.30.1"
# Docker CLI plugins. The engine ships without them, so `docker compose` (used by
# deploy.sh) and `docker buildx` are missing — deploy.sh then fails with exit 125.
# Installed as pinned binaries into the host cli-plugins dir; bump to upgrade.
docker_compose_version: "5.1.4"
docker_buildx_version: "0.35.0"
# Registration tokens come from env vars named by each project's `token_env`
# (e.g. RUNNER_TOKEN_GATEHOUSE_API). Export them on the control node before running.
# Mint from: Gitea repo → Settings → Actions → Runners → Create new runner token.
+15
View File
@@ -0,0 +1,15 @@
---
runner_env: prod
runners:
- project: gatehouse-api
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-api
labels: "self-hosted:host,linux:host,prod:host,prod-gatehouse-api:host"
token_env: RUNNER_TOKEN_GATEHOUSE_API
count: 1
- project: gatehouse-ui
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-ui
labels: "self-hosted:host,linux:host,prod:host"
token_env: RUNNER_TOKEN_GATEHOUSE_UI
count: 1
+19
View File
@@ -0,0 +1,19 @@
---
runner_env: stage
# One entry per project. A host runs runners for every project listed.
# labels: comma-separated "<name>:<executor>" pairs. Use :host for native execution,
# :docker://<image> for Docker. Must match runs-on: values in workflow files.
# token_env: env var name on the control node holding a fresh registration token (~1h TTL).
runners:
- project: gatehouse-api
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-api
labels: "self-hosted:host,linux:host,stage:host,stage-gatehouse-api:host"
token_env: RUNNER_TOKEN_GATEHOUSE_API
count: 1
- project: gatehouse-ui
url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-ui
labels: "self-hosted:host,linux:host,stage:host,stage-gatehouse-ui:host"
token_env: RUNNER_TOKEN_GATEHOUSE_UI
count: 1
+160
View File
@@ -0,0 +1,160 @@
---
- name: Install Gitea Actions self-hosted runners
hosts: all
become: true
pre_tasks:
- name: Assert host defines a runners matrix
ansible.builtin.assert:
that:
- runners is defined
- runners | length > 0
- runner_env is defined
fail_msg: "Host {{ inventory_hostname }} is missing host_vars (runners / runner_env)."
tasks:
- name: Ensure runner service user exists
ansible.builtin.user:
name: "{{ runner_user }}"
shell: /bin/bash
create_home: true
home: "{{ runner_home }}"
# JS actions (actions/checkout@v4, etc.) execute with `node` on the host
# executor. Without it act_runner fails: "Cannot find: node in PATH".
# git is needed by checkout for its fetch step.
- name: Ensure git is present
ansible.builtin.apt:
name: git
state: present
update_cache: true
- name: Install Node.js {{ node_major_version }}.x (NodeSource)
block:
# Key is ASCII-armored, so store it as .asc — apt reads .gpg as binary
# and .asc as armored; a mismatch fails repo signature verification.
- name: Add NodeSource apt key
ansible.builtin.get_url:
url: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
dest: /usr/share/keyrings/nodesource.asc
mode: "0644"
- name: Add NodeSource apt repo
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/nodesource.asc] https://deb.nodesource.com/node_{{ node_major_version }}.x nodistro main"
filename: nodesource
- name: Install nodejs
ansible.builtin.apt:
name: nodejs
state: present
update_cache: true
# Security scanners used by the CI workflows. Pre-installing them (as root)
# means the workflow steps find them on PATH and skip their runtime install,
# which would otherwise fail writing to /usr/local/bin as the runner user.
- name: Check installed Trivy version
ansible.builtin.command: trivy --version
register: trivy_check
changed_when: false
failed_when: false
- name: Install Trivy {{ trivy_version }}
ansible.builtin.shell: |
set -o pipefail
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin v{{ trivy_version }}
args:
executable: /bin/bash
when: trivy_version not in (trivy_check.stdout | default(''))
- name: Check installed Gitleaks version
ansible.builtin.command: gitleaks version
register: gitleaks_check
changed_when: false
failed_when: false
- name: Install Gitleaks {{ gitleaks_version }}
ansible.builtin.unarchive:
src: "https://github.com/gitleaks/gitleaks/releases/download/v{{ gitleaks_version }}/gitleaks_{{ gitleaks_version }}_linux_x64.tar.gz"
dest: /usr/local/bin
remote_src: true
include:
- gitleaks
mode: "0755"
when: gitleaks_version not in (gitleaks_check.stdout | default(''))
# The Docker engine ships without CLI plugins, so `docker compose` (used by
# deploy.sh) and `docker buildx` are absent. Install them host-wide; this
# path is searched by both docker.io and docker-ce CLIs.
- name: Ensure Docker CLI plugins dir exists
ansible.builtin.file:
path: /usr/libexec/docker/cli-plugins
state: directory
mode: "0755"
- name: Check installed Docker Compose plugin version
ansible.builtin.command: docker compose version --short
register: compose_check
changed_when: false
failed_when: false
- name: Install Docker Compose v2 plugin {{ docker_compose_version }}
ansible.builtin.get_url:
url: "https://github.com/docker/compose/releases/download/v{{ docker_compose_version }}/docker-compose-linux-x86_64"
dest: /usr/libexec/docker/cli-plugins/docker-compose
mode: "0755"
force: true
when: docker_compose_version not in (compose_check.stdout | default(''))
- name: Check installed Docker Buildx plugin version
ansible.builtin.command: docker buildx version
register: buildx_check
changed_when: false
failed_when: false
- name: Install Docker Buildx plugin {{ docker_buildx_version }}
ansible.builtin.get_url:
url: "https://github.com/docker/buildx/releases/download/v{{ docker_buildx_version }}/buildx-v{{ docker_buildx_version }}.linux-amd64"
dest: /usr/libexec/docker/cli-plugins/docker-buildx
mode: "0755"
force: true
when: docker_buildx_version not in (buildx_check.stdout | default(''))
# Deploy target for each project's compose stack. Owned by the runner so the
# deploy job can `cp docker-compose.yml` here; the host-managed .env lives
# here too. Basename matches the compose project name, preserving volumes.
- name: Ensure app deploy dir exists for each project
ansible.builtin.file:
path: "{{ app_base_dir }}/{{ item.project }}"
state: directory
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
loop: "{{ runners }}"
loop_control:
label: "{{ item.project }}"
- name: Install runners for each project
ansible.builtin.include_tasks: tasks/install_project.yml
loop: "{{ runners }}"
loop_control:
loop_var: project_spec
label: "{{ project_spec.project }}"
# The build job runs `docker build` on the host, talking to the daemon via
# /var/run/docker.sock. Without docker group membership the runner user gets
# "permission denied ... unix:///var/run/docker.sock".
- name: Add runner user to the docker group
ansible.builtin.user:
name: "{{ runner_user }}"
groups: docker
append: true
register: runner_docker_group
# Group membership is only read at process start, so already-running runner
# services must be restarted to gain socket access.
- name: Restart runner services to apply docker group membership
ansible.builtin.shell: "systemctl restart 'gitea-runner-*.service'"
when: runner_docker_group is changed
changed_when: true
+9
View File
@@ -0,0 +1,9 @@
# Self-hosted GitHub Actions runner hosts.
# Per-project runner matrix lives in host_vars/<host>.yml.
# Set ansible_host (and ansible_user if not root) to real values.
[stage]
stage01 ansible_host=172.25.25.209 ansible_user=ubuntu
[prod]
prod01 ansible_host=CHANGE_ME ansible_user=ubuntu
@@ -0,0 +1,68 @@
---
# Installs + registers + services a single act_runner instance.
# Inputs: project_spec (dict), idx (int), project_token (str).
- name: Set per-runner facts
ansible.builtin.set_fact:
runner_name: "{{ inventory_hostname }}-{{ project_spec.project }}-{{ runner_env }}-{{ idx }}"
runner_dir: "{{ runner_home }}/act-runner-{{ project_spec.project }}-{{ idx }}"
svc_name: "gitea-runner-{{ project_spec.project }}-{{ idx }}"
- name: "Create runner dir {{ runner_dir }}"
ansible.builtin.file:
path: "{{ runner_dir }}"
state: directory
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
- name: Download act_runner binary (sha256 verified)
ansible.builtin.get_url:
url: "{{ act_runner_download_url }}"
dest: "{{ runner_dir }}/gitea-runner"
checksum: "sha256:{{ act_runner_sha256 }}"
owner: "{{ runner_user }}"
group: "{{ runner_user }}"
mode: "0755"
- name: "Register runner {{ runner_name }}"
ansible.builtin.command:
cmd: >-
./gitea-runner register
--no-interactive
--instance {{ gitea_instance }}
--token {{ project_token }}
--name {{ runner_name }}
--labels {{ project_spec.labels }}
chdir: "{{ runner_dir }}"
creates: "{{ runner_dir }}/.runner"
become_user: "{{ runner_user }}"
- name: "Write systemd unit for {{ svc_name }}"
ansible.builtin.copy:
dest: "/etc/systemd/system/{{ svc_name }}.service"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Gitea Actions Runner ({{ runner_name }})
After=network.target
[Service]
Type=simple
User={{ runner_user }}
WorkingDirectory={{ runner_dir }}
ExecStart={{ runner_dir }}/gitea-runner daemon
Restart=always
RestartSec=5s
Environment=HOME={{ runner_home }}
[Install]
WantedBy=multi-user.target
- name: "Enable + start {{ svc_name }}"
ansible.builtin.systemd:
name: "{{ svc_name }}"
enabled: true
state: started
daemon_reload: true
+20
View File
@@ -0,0 +1,20 @@
---
# Expands one project entry into `count` runner instances.
- name: "Read registration token for {{ project_spec.project }} from env var"
ansible.builtin.set_fact:
project_token: "{{ lookup('ansible.builtin.env', project_spec.token_env) }}"
- name: "Warn and skip {{ project_spec.project }} — token missing"
ansible.builtin.debug:
msg: >-
Skipping {{ project_spec.project }}: env var {{ project_spec.token_env }} is empty/unset.
Export a fresh registration token (Gitea repo → Settings → Actions → Runners → Create new runner token).
when: project_token | length == 0
- name: "Install {{ project_spec.count }} runner(s) for {{ project_spec.project }}"
ansible.builtin.include_tasks: install_one_runner.yml
loop: "{{ range(1, project_spec.count | int + 1) | list }}"
loop_control:
loop_var: idx
label: "{{ project_spec.project }}-{{ idx }}"
when: project_token | length > 0