diff --git a/.gitea/workflows/pr-security-check.yml b/.gitea/workflows/pr-security-check.yml new file mode 100644 index 0000000..4a33585 --- /dev/null +++ b/.gitea/workflows/pr-security-check.yml @@ -0,0 +1,54 @@ +name: PR -> develop + +on: + pull_request: + branches: + - main + - develop + +env: + GITLEAKS_VERSION: "8.30.1" + +jobs: + + # ── 1. Secret scan ──────────────────────────────────────────────────────────── + gitleaks: + name: Scan for secrets (Gitleaks) + runs-on: stage-gatehouse-api + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar xz gitleaks + mv gitleaks /usr/local/bin/gitleaks + + - name: Run secret scan + run: gitleaks detect --source . --exit-code 1 --redact --verbose --log-level debug + + # ── 2. CVE scan ─────────────────────────────────────────────────────────────── + trivy: + name: Scan for CVEs (Trivy) + runs-on: stage-gatehouse-api + + steps: + - uses: actions/checkout@v4 + + - name: Install Trivy + run: | + command -v trivy >/dev/null 2>&1 || \ + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + + - name: Run filesystem scan + run: | + trivy fs \ + --exit-code 1 \ + --severity HIGH,CRITICAL \ + --no-progress \ + . diff --git a/.gitea/workflows/push-develop.yml b/.gitea/workflows/push-develop.yml new file mode 100644 index 0000000..e342079 --- /dev/null +++ b/.gitea/workflows/push-develop.yml @@ -0,0 +1,82 @@ +name: Push -> develop + +on: + push: + branches: + - develop + - ci/deploy + +jobs: + + # ── 1. Build ────────────────────────────────────────────────────────────────── + build: + name: Build Docker images + runs-on: stage-gatehouse-api + outputs: + tag: ${{ steps.sha.outputs.tag }} + + steps: + - uses: actions/checkout@v4 + + - name: Set image tag + id: sha + run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build api image + run: | + docker build \ + -t "gatehouse-api:${{ steps.sha.outputs.tag }}" \ + -t "gatehouse-api:latest" \ + . + + - name: Build job image + run: | + docker build \ + -f Dockerfile.job \ + -t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \ + -t "gatehouse-api-job:latest" \ + . + + - name: Scan api image for vulnerabilities (Trivy) + run: | + command -v trivy >/dev/null 2>&1 || \ + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + + trivy image \ + --exit-code 0 \ + --severity HIGH,CRITICAL \ + --no-progress \ + "gatehouse-api:${{ steps.sha.outputs.tag }}" + + # ── 2. Deploy ───────────────────────────────────────────────────────────────── + deploy: + name: Rolling deploy + runs-on: stage-gatehouse-api + needs: build + env: + COMPOSE_DIR: /home/ubuntu/secuird/gatehouse-api + + steps: + - uses: actions/checkout@v4 + + - name: Deploy (rolling restart) + run: | + cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml" + bash deploy/deploy.sh "${{ needs.build.outputs.tag }}" + + # ── 3. Alert ────────────────────────────────────────────────────────────────── + alert: + name: Notify on result + runs-on: stage-gatehouse-api + needs: deploy + if: always() + + steps: + - name: Send notification + run: | + STATUS="${{ needs.deploy.result }}" + echo "TODO: send alert — deploy status: ${STATUS}" + # curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \ + # -H 'Content-Type: application/json' \ + # -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}" diff --git a/.gitea/workflows/push-main.yml b/.gitea/workflows/push-main.yml new file mode 100644 index 0000000..656ddbb --- /dev/null +++ b/.gitea/workflows/push-main.yml @@ -0,0 +1,81 @@ +name: Push -> main + +on: + push: + branches: + - main + +jobs: + + # ── 1. Build ────────────────────────────────────────────────────────────────── + build: + name: Build Docker images + runs-on: prod-gatehouse-api + outputs: + tag: ${{ steps.sha.outputs.tag }} + + steps: + - uses: actions/checkout@v4 + + - name: Set image tag + id: sha + run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build api image + run: | + docker build \ + -t "gatehouse-api:${{ steps.sha.outputs.tag }}" \ + -t "gatehouse-api:latest" \ + . + + - name: Build job image + run: | + docker build \ + -f Dockerfile.job \ + -t "gatehouse-api-job:${{ steps.sha.outputs.tag }}" \ + -t "gatehouse-api-job:latest" \ + . + + - name: Scan api image for vulnerabilities (Trivy) + run: | + command -v trivy >/dev/null 2>&1 || \ + curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + + trivy image \ + --exit-code 0 \ + --severity HIGH,CRITICAL \ + --no-progress \ + "gatehouse-api:${{ steps.sha.outputs.tag }}" + + # ── 2. Deploy ───────────────────────────────────────────────────────────────── + deploy: + name: Rolling deploy + runs-on: prod-gatehouse-api + needs: build + env: + COMPOSE_DIR: /home/ubuntu/secuird/gatehouse-api # TODO: confirm prod path + + steps: + - uses: actions/checkout@v4 + + - name: Deploy (rolling restart) + run: | + cp docker-compose.yml "${COMPOSE_DIR}/docker-compose.yml" + bash deploy/deploy.sh "${{ needs.build.outputs.tag }}" + + # ── 3. Alert ────────────────────────────────────────────────────────────────── + alert: + name: Notify on result + runs-on: prod-gatehouse-api + needs: deploy + if: always() + + steps: + - name: Send notification + run: | + STATUS="${{ needs.deploy.result }}" + echo "TODO: send alert — deploy status: ${STATUS}" + # curl -X POST "${{ secrets.ALERT_WEBHOOK }}" \ + # -H 'Content-Type: application/json' \ + # -d "{\"text\": \"[gatehouse-api] Deploy ${STATUS} — tag: ${{ needs.build.outputs.tag }}\"}" diff --git a/deploy/ansible/README.md b/deploy/ansible/README.md index 3ba9cbb..5f523fb 100644 --- a/deploy/ansible/README.md +++ b/deploy/ansible/README.md @@ -1,35 +1,41 @@ -# GitHub Actions self-hosted runners — Ansible +# Gitea Actions self-hosted runners — Ansible -Provisions self-hosted runners as **systemd services**. One host can run runners for +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, repo URL, and registration token. +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 runner version + sha256, paths, env_file +group_vars/all.yml pinned act_runner version + sha256, gitea_instance URL host_vars/.yml runner_env + per-project `runners` matrix tasks/install_project.yml reads token, expands count -> N instances -tasks/install_one_runner.yml download/verify/extract/register/service one runner +tasks/install_one_runner.yml download/register/service one runner install-runner.yml the playbook ``` ## Runner naming -- name: `---` e.g. `stage01-gatehouse-api-stage-1` -- dir: `/home/github-runner/actions-runner--` -- label: `project_spec.label` (e.g. `stage-secuird-runner`, `stage-gatehouse-ui`) -- service: `actions.runner.-..service` +- name: `---` e.g. `stage01-gatehouse-api-stage-1` +- dir: `/home/gitea-runner/act-runner--` +- labels: `project_spec.labels` (comma-separated, e.g. `self-hosted,linux,stage`) +- service: `gitea-runner--.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--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 > New self-hosted runner** -(the value after `--token`), then run the playbook within the hour. +Mint each from the repo's **Settings → Actions → Runners → Create new runner token**, +then run the playbook within the hour. ## Run @@ -37,7 +43,8 @@ Mint each from the repo's **Settings > Actions > Runners > New self-hosted runne cd deploy/ansible # 1. Edit inventory.ini (set ansible_host / ansible_user) and host_vars/*.yml. -# 2. Export fresh tokens (leading space keeps them out of shell history): +# 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 @@ -50,18 +57,28 @@ ansible-playbook install-runner.yml --limit stage # apply ```bash # on the host -systemctl list-units 'actions.runner.*' -ls /home/github-runner/ # actions-runner-gatehouse-api-1, -2, ... +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 label. Idempotent: re-running skips already-configured runners (`--replace` -re-registers safely; `creates:` guards downloads/extract/config). +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 `runner_version` + `runner_sha256` together in `group_vars/all.yml` - (sha256 from the GitHub release page). +- Bump `act_runner_version` + `act_runner_sha256` together in `group_vars/all.yml`. +- Labels in `host_vars/.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). -- `gatehouse-ui` repo URL + label in host_vars are placeholders — confirm before first run. + 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. diff --git a/deploy/ansible/group_vars/all.yml b/deploy/ansible/group_vars/all.yml index 6a1682c..fe1d758 100644 --- a/deploy/ansible/group_vars/all.yml +++ b/deploy/ansible/group_vars/all.yml @@ -1,14 +1,18 @@ --- # Shared constants for all runner hosts. -runner_user: github-runner -runner_home: /home/github-runner +runner_user: gitea-runner +runner_home: /home/gitea-runner -# Pinned runner release. Bump version + sha256 together. -# sha256 from the GitHub release page for actions-runner-linux-x64-.tar.gz -runner_version: "2.335.1" -runner_sha256: "4ef2f25285f0ae4477f1fe1e346db76d2f3ebf03824e2ddd1973a2819bf6c8cf" -runner_tarball: "actions-runner-linux-x64-{{ runner_version }}.tar.gz" -runner_download_url: "https://github.com/actions/runner/releases/download/v{{ runner_version }}/{{ runner_tarball }}" +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--linux-amd64 is on the release page (act_runner--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 }}" # 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. diff --git a/deploy/ansible/host_vars/prod01.yml b/deploy/ansible/host_vars/prod01.yml index b0b2d70..955cd65 100644 --- a/deploy/ansible/host_vars/prod01.yml +++ b/deploy/ansible/host_vars/prod01.yml @@ -3,13 +3,13 @@ runner_env: prod runners: - project: gatehouse-api - url: https://github.com/CoryHawkless/gatehouse-api - label: prod-secuird-runner # matches runs-on: in push-main.yml + 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://github.com/CoryHawkless/gatehouse-ui # TODO: confirm UI repo URL - label: prod-gatehouse-ui # TODO: confirm UI workflow runs-on label + 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 diff --git a/deploy/ansible/host_vars/stage01.yml b/deploy/ansible/host_vars/stage01.yml index 5f930aa..5549699 100644 --- a/deploy/ansible/host_vars/stage01.yml +++ b/deploy/ansible/host_vars/stage01.yml @@ -2,16 +2,18 @@ runner_env: stage # One entry per project. A host runs runners for every project listed. -# token_env = key read from .env on the control node (registration token, ~1h TTL). +# labels: comma-separated ":" pairs. Use :host for native execution, +# :docker:// 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://github.com/CoryHawkless/gatehouse-api - label: stage-secuird-runner # matches runs-on: in this repo's workflows + 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://github.com/CoryHawkless/gatehouse-ui # TODO: confirm UI repo URL - label: stage-gatehouse-ui # TODO: confirm UI workflow runs-on label + url: https://source.hawkless.id.au/coryHawkvelt/gatehouse-ui + labels: "self-hosted:host,linux:host,stage:host" token_env: RUNNER_TOKEN_GATEHOUSE_UI count: 1 diff --git a/deploy/ansible/install-runner.yml b/deploy/ansible/install-runner.yml index 9364280..f870abc 100644 --- a/deploy/ansible/install-runner.yml +++ b/deploy/ansible/install-runner.yml @@ -1,5 +1,5 @@ --- -- name: Install GitHub Actions self-hosted runners +- name: Install Gitea Actions self-hosted runners hosts: all become: true diff --git a/deploy/ansible/tasks/install_one_runner.yml b/deploy/ansible/tasks/install_one_runner.yml index 3b858c4..865db18 100644 --- a/deploy/ansible/tasks/install_one_runner.yml +++ b/deploy/ansible/tasks/install_one_runner.yml @@ -1,10 +1,11 @@ --- -# Installs + registers + services a single runner instance. +# 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 }}/actions-runner-{{ project_spec.project }}-{{ 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: @@ -14,54 +15,54 @@ group: "{{ runner_user }}" mode: "0755" -- name: Download runner tarball (sha256 verified) +- name: Download act_runner binary (sha256 verified) ansible.builtin.get_url: - url: "{{ runner_download_url }}" - dest: "{{ runner_dir }}/{{ runner_tarball }}" - checksum: "sha256:{{ runner_sha256 }}" + url: "{{ act_runner_download_url }}" + dest: "{{ runner_dir }}/gitea-runner" + checksum: "sha256:{{ act_runner_sha256 }}" owner: "{{ runner_user }}" group: "{{ runner_user }}" - mode: "0644" - -- name: Extract runner - ansible.builtin.unarchive: - src: "{{ runner_dir }}/{{ runner_tarball }}" - dest: "{{ runner_dir }}" - remote_src: true - owner: "{{ runner_user }}" - group: "{{ runner_user }}" - creates: "{{ runner_dir }}/config.sh" + mode: "0755" - name: "Register runner {{ runner_name }}" ansible.builtin.command: cmd: >- - ./config.sh --unattended - --url {{ project_spec.url }} + ./gitea-runner register + --no-interactive + --instance {{ gitea_instance }} --token {{ project_token }} --name {{ runner_name }} - --labels {{ project_spec.label }} - --work _work - --replace + --labels {{ project_spec.labels }} chdir: "{{ runner_dir }}" creates: "{{ runner_dir }}/.runner" become_user: "{{ runner_user }}" -- name: "Check if service installed for {{ runner_name }}" - ansible.builtin.find: - paths: "{{ runner_dir }}" - patterns: ".service" - hidden: true - register: runner_svc_marker +- 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 -- name: "Install systemd service for {{ runner_name }}" - ansible.builtin.command: - cmd: "./svc.sh install {{ runner_user }}" - chdir: "{{ runner_dir }}" - when: runner_svc_marker.matched == 0 + [Service] + Type=simple + User={{ runner_user }} + WorkingDirectory={{ runner_dir }} + ExecStart={{ runner_dir }}/gitea-runner daemon + Restart=always + RestartSec=5s + Environment=HOME={{ runner_home }} -- name: "Start + enable service for {{ runner_name }}" - ansible.builtin.command: - cmd: "./svc.sh start" - chdir: "{{ runner_dir }}" - register: svc_start - changed_when: "'active (running)' not in svc_start.stdout" + [Install] + WantedBy=multi-user.target + +- name: "Enable + start {{ svc_name }}" + ansible.builtin.systemd: + name: "{{ svc_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/deploy/ansible/tasks/install_project.yml b/deploy/ansible/tasks/install_project.yml index a588c78..6a8dbe9 100644 --- a/deploy/ansible/tasks/install_project.yml +++ b/deploy/ansible/tasks/install_project.yml @@ -4,13 +4,12 @@ ansible.builtin.set_fact: project_token: "{{ lookup('ansible.builtin.env', project_spec.token_env) }}" -- name: "Fail if token missing for {{ project_spec.project }}" - ansible.builtin.assert: - that: - - project_token | length > 0 - fail_msg: >- - Env var {{ project_spec.token_env }} is empty/unset. - Export a fresh registration token (Settings > Actions > Runners) before running. +- 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 @@ -18,3 +17,4 @@ loop_control: loop_var: idx label: "{{ project_spec.project }}-{{ idx }}" + when: project_token | length > 0