Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56dcda0d5e | ||
|
|
e44f0c403b | ||
|
|
fb1498bf7a | ||
|
|
fa69213d15 | ||
|
|
f92e50f35b | ||
|
|
a792b47b41 | ||
|
|
68ec7efde0 | ||
|
|
f314ffb036 | ||
|
|
b303a83a77 | ||
|
|
66d0b1e608 | ||
|
|
48a589eb79 | ||
|
|
fef300dd5b | ||
|
|
49a0b6f167 | ||
|
|
e5fdaadbd2 | ||
|
|
ab382dc256 | ||
|
|
2c66de4df2 | ||
|
|
9de33d4252 | ||
|
|
ff8375c6e1 | ||
| 3e22214bbd | |||
| b4e90db372 | |||
| c6b9e0c2d1 | |||
| aefab79307 | |||
| a6c08576a9 | |||
|
|
90d11b8692 | ||
|
|
c4b57fbcb2 | ||
|
|
e6dbe2a1ca | ||
|
|
774f316c8b | ||
|
|
823e9489d6 | ||
|
|
dc38bf1895 | ||
|
|
96b866a3a8 | ||
|
|
3b11bac2ad | ||
|
|
47caafd037 | ||
|
|
50e0509007 | ||
|
|
8920c4a170 | ||
|
|
bbf9d7e90f | ||
|
|
46f471a900 | ||
|
|
aa28f8d99c | ||
|
|
6a7e18b124 | ||
|
|
53329c46ff | ||
|
|
6b1aea9c04 | ||
|
|
edec9a8f91 | ||
|
|
6a9a447f86 | ||
|
|
5302c25feb | ||
|
|
a616ed1a10 | ||
|
|
f0b5aff3bb | ||
|
|
44b4736703 | ||
|
|
b1ae30dda8 | ||
|
|
0d687268c7 | ||
|
|
425a570261 | ||
|
|
4c8179ee12 | ||
|
|
5ae13f0bd7 | ||
|
|
3510152e36 | ||
|
|
8dfb805c62 | ||
|
|
a7080f5457 | ||
|
|
8b72d1c7ae | ||
|
|
8bc0275e74 | ||
|
|
0348aaac59 | ||
|
|
9712481bed | ||
|
|
b5f901b2d9 | ||
|
|
0e2a3e00f5 | ||
|
|
b282356e9e | ||
|
|
b075e3a1d5 | ||
|
|
e27189ea32 | ||
|
|
59e478464e | ||
|
|
e1c7b20898 | ||
|
|
2f78411c3d | ||
|
|
d1d3cad4b0 | ||
|
|
1735b26e66 | ||
|
|
65ed62d2f5 | ||
|
|
ec03f19650 | ||
|
|
8567324a19 | ||
|
|
be2df361ef | ||
|
|
a5085dde0c | ||
|
|
cef86d1140 | ||
|
|
94c45acf6b | ||
|
|
1760899d27 | ||
|
|
a7eca813ea | ||
|
|
23ec12b8cf | ||
|
|
a1fc2b3ca7 | ||
|
|
5977042b86 | ||
|
|
6bc19cbc33 | ||
|
|
75006a59cc | ||
|
|
4da97f53de | ||
|
|
45270656df | ||
|
|
e14f42c40a | ||
|
|
e6630e2e36 | ||
|
|
f1f9142a3c | ||
|
|
f17cad1bbe | ||
|
|
daf52d0e62 | ||
|
|
8c8a8ce401 | ||
|
|
08c681be0c | ||
|
|
91bfe4c186 | ||
|
|
825c6f97b7 | ||
|
|
2f3e5c7125 | ||
|
|
4d9de6ca8c | ||
|
|
feb39666cc | ||
|
|
61cd71c9f8 | ||
|
|
0adfc1c7cc | ||
|
|
e3c68668fa | ||
|
|
f1b27d5274 | ||
|
|
655a39fd61 | ||
|
|
cca7d54117 | ||
|
|
934471813a | ||
|
|
1e940f028b | ||
|
|
2020ce79bf | ||
|
|
00e9b3d62b | ||
|
|
130b9f1877 | ||
|
|
4c35288175 | ||
|
|
990db1bfc0 | ||
|
|
d07fbfc8c3 | ||
|
|
10dc6fb60d | ||
|
|
ed35b09b8f | ||
|
|
03f0829d09 | ||
|
|
7fc1b91ba6 | ||
|
|
82c3c2df1a | ||
|
|
9fc823e4b1 | ||
|
|
12999b61dd | ||
|
|
49a2fcc138 | ||
|
|
8f88e4f15a | ||
|
|
a1bb3b56fd | ||
|
|
1a7ec5f339 | ||
|
|
5d01cb8904 | ||
|
|
dcf84d8a53 | ||
|
|
73adae040d | ||
|
|
db662b3690 | ||
|
|
cf92a979e2 | ||
|
|
87058716fb | ||
|
|
c701ba4787 | ||
|
|
57ff1df6e0 | ||
|
|
3dcfd6ea3d | ||
|
|
c6006ee699 | ||
|
|
f2629f2ea3 | ||
|
|
cf48ed88ba | ||
|
|
ccc27329dc | ||
|
|
b0bd503b11 | ||
|
|
8c14933e70 | ||
|
|
34d15f21c2 | ||
|
|
32d29f0813 | ||
|
|
2e2c0400c8 | ||
|
|
054c8d912f | ||
|
|
9e4a5f7363 | ||
|
|
ec38401097 | ||
|
|
45bfe0a9b2 | ||
|
|
316534996a | ||
|
|
67b1363d25 | ||
|
|
946c41cf4f | ||
|
|
341d49a24d | ||
|
|
b21d476aca | ||
|
|
a29307a9d9 | ||
|
|
4bfbfec477 | ||
|
|
fed01c9807 | ||
|
|
a83f29d5a9 | ||
|
|
69c55ee003 | ||
|
|
01ef57c667 | ||
|
|
a384adbbc6 | ||
|
|
e3271d8469 | ||
|
|
84386c1b16 | ||
|
|
fd7c8580af | ||
|
|
35596a182b | ||
|
|
c9d3f67264 | ||
|
|
94031fc198 | ||
|
|
d5e4baed54 | ||
|
|
d4caa7e065 | ||
|
|
de4160b023 | ||
|
|
609c0a0773 | ||
|
|
0c029f7e79 | ||
|
|
eef3c32eb2 | ||
|
|
c40b651873 | ||
|
|
b498341857 | ||
|
|
0d727eb262 | ||
|
|
7c71c94366 | ||
|
|
49d2cb0cb5 | ||
|
|
85626b6bbd | ||
|
|
35400f76fa | ||
|
|
0cf31b2d22 | ||
|
|
c8cc7b2448 | ||
|
|
3be962cdb3 | ||
|
|
a5edbc9ac4 | ||
|
|
66bab3d805 | ||
|
|
293926f5d5 | ||
|
|
43c5ba923f | ||
|
|
acc5afc428 | ||
|
|
27a1a90d25 | ||
|
|
83ec0ba909 | ||
|
|
ed86e2f15a | ||
|
|
d4bebccc12 | ||
|
|
c75b67e892 | ||
|
|
bc6031eff7 | ||
|
|
c69c353d93 |
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{go}]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,30 +0,0 @@
|
||||
name: goreleaser
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.20.1'
|
||||
- name: goreleaser
|
||||
uses: https://github.com/goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: latest
|
||||
args: release --nightly
|
||||
env:
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
@@ -1,41 +1,79 @@
|
||||
name: goreleaser
|
||||
name: Release
|
||||
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
runs-on: linux-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v3
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '>=1.20.1'
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: https://github.com/crazy-max/ghaction-import-gpg@v5
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.PASSPHRASE }}
|
||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||
- name: goreleaser
|
||||
uses: https://github.com/goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: latest
|
||||
args: release
|
||||
go-version-file: 'go.mod'
|
||||
cache: false
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
GORELEASER_FORCE_TOKEN: 'gitea'
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
GOPRIVATE: git.marketally.com
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
# Strip the v prefix from tag
|
||||
VERSION="${VERSION#v}"
|
||||
EXT=""
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
EXT=".exe"
|
||||
fi
|
||||
echo "Building version: ${VERSION}"
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -a -ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=${VERSION}" \
|
||||
-o act_runner-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: act_runner-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: act_runner-*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: linux-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -name 'act_runner-*' -exec mv {} release/ \;
|
||||
cd release && sha256sum * > checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,43 +1,33 @@
|
||||
name: checks
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
name: CI
|
||||
|
||||
env:
|
||||
GOPROXY: https://goproxy.io,direct
|
||||
GOPATH: /go_path
|
||||
GOCACHE: /go_cache
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: check and test
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cache go path
|
||||
id: cache-go-path
|
||||
uses: https://github.com/actions/cache@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
path: /go_path
|
||||
key: go_path-${{ github.repository }}-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
go_path-${{ github.repository }}-
|
||||
go_path-
|
||||
- name: cache go cache
|
||||
id: cache-go-cache
|
||||
uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: /go_cache
|
||||
key: go_cache-${{ github.repository }}-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
go_cache-${{ github.repository }}-
|
||||
go_cache-
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.20
|
||||
- uses: actions/checkout@v3
|
||||
- name: vet checks
|
||||
go-version-file: 'go.mod'
|
||||
cache: false
|
||||
|
||||
- name: Vet
|
||||
run: make vet
|
||||
- name: build
|
||||
env:
|
||||
GOPRIVATE: git.marketally.com
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
- name: test
|
||||
run: make test
|
||||
env:
|
||||
GOPRIVATE: git.marketally.com
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
env:
|
||||
GOPRIVATE: git.marketally.com
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,10 +1,15 @@
|
||||
act_runner
|
||||
/act_runner
|
||||
.env
|
||||
.runner
|
||||
coverage.txt
|
||||
/gitea-vet
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains
|
||||
.idea
|
||||
# MS VSCode
|
||||
.vscode
|
||||
__debug_bin
|
||||
# gorelease binary folder
|
||||
dist
|
||||
act_runner-*
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
linters:
|
||||
enable:
|
||||
- gosimple
|
||||
- deadcode
|
||||
- typecheck
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- structcheck
|
||||
- varcheck
|
||||
- dupl
|
||||
#- gocyclo # The cyclomatic complexety of a lot of functions is too high, we should refactor those another time.
|
||||
- gofmt
|
||||
@@ -112,7 +109,6 @@ issues:
|
||||
- gocritic
|
||||
- linters:
|
||||
- unused
|
||||
- deadcode
|
||||
text: "swagger"
|
||||
- path: contrib/pr/checkout.go
|
||||
linters:
|
||||
@@ -154,9 +150,6 @@ issues:
|
||||
- path: cmd/dump.go
|
||||
linters:
|
||||
- dupl
|
||||
- path: services/webhook/webhook.go
|
||||
linters:
|
||||
- structcheck
|
||||
- text: "commentFormatting: put a space between `//` and comment text"
|
||||
linters:
|
||||
- gocritic
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
@@ -14,6 +16,9 @@ builds:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
- loong64
|
||||
- s390x
|
||||
- riscv64
|
||||
goarm:
|
||||
- "5"
|
||||
- "6"
|
||||
@@ -71,9 +76,8 @@ builds:
|
||||
no_unique_dist_dir: true
|
||||
hooks:
|
||||
post:
|
||||
- cmd: tar -cJf {{ .Path }}.xz {{ .Path }}
|
||||
env:
|
||||
- XZ_OPT=-9
|
||||
- cmd: xz -k -9 {{ .Path }}
|
||||
dir: ./dist/
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||
|
||||
@@ -82,7 +86,7 @@ blobs:
|
||||
provider: s3
|
||||
bucket: "{{ .Env.S3_BUCKET }}"
|
||||
region: "{{ .Env.S3_REGION }}"
|
||||
folder: "act_runner/{{.Version}}"
|
||||
directory: "act_runner/{{.Version}}"
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
@@ -98,14 +102,19 @@ checksum:
|
||||
- glob: ./**.xz
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Branch }}-devel"
|
||||
version_template: "{{ .Branch }}-devel"
|
||||
|
||||
nightly:
|
||||
name_template: "{{ .Branch }}"
|
||||
version_template: "nightly"
|
||||
|
||||
gitea_urls:
|
||||
api: https://gitea.com/api/v1
|
||||
download: https://gitea.com
|
||||
|
||||
release:
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.xz.sha256
|
||||
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,17 +1,64 @@
|
||||
FROM golang:alpine as builder
|
||||
RUN apk add --update-cache make git
|
||||
### BUILDER STAGE
|
||||
#
|
||||
#
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||
RUN apk add --no-cache make git
|
||||
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY=${GOPROXY:-}
|
||||
|
||||
COPY . /opt/src/act_runner
|
||||
WORKDIR /opt/src/act_runner
|
||||
|
||||
RUN make clean && make build
|
||||
|
||||
FROM alpine as runner
|
||||
RUN apk add --update-cache \
|
||||
git bash \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:28-dind AS dind
|
||||
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY run.sh /opt/act/run.sh
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
COPY scripts/s6 /etc/s6
|
||||
|
||||
ENTRYPOINT ["/opt/act/run.sh"]
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:28-dind-rootless AS dind-rootless
|
||||
|
||||
USER root
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
COPY scripts/s6 /etc/s6
|
||||
|
||||
VOLUME /data
|
||||
|
||||
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
||||
|
||||
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||
|
||||
USER rootless
|
||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
|
||||
### BASIC VARIANT
|
||||
#
|
||||
#
|
||||
FROM alpine AS basic
|
||||
RUN apk add --no-cache tini bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/sbin/tini","--","run.sh"]
|
||||
|
||||
121
HOWTOSTART.md
Normal file
121
HOWTOSTART.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# How to Start a GitCaddy Runner
|
||||
|
||||
This guide explains how to set up and start a GitCaddy Actions runner (act_runner) to execute your CI/CD workflows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Linux, macOS, or Windows machine
|
||||
- Network access to your GitCaddy/Gitea instance
|
||||
- (Optional) Docker installed for container-based workflows
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Download the Runner
|
||||
|
||||
Download the latest release from the [releases page](https://git.marketally.com/gitcaddy/act_runner/releases) or build from source:
|
||||
|
||||
```bash
|
||||
git clone https://git.marketally.com/gitcaddy/act_runner.git
|
||||
cd act_runner
|
||||
make build
|
||||
```
|
||||
|
||||
### 2. Register the Runner
|
||||
|
||||
Get a registration token from your GitCaddy instance:
|
||||
- **Global runners**: Admin Area → Actions → Runners → Create Runner
|
||||
- **Organization runners**: Organization Settings → Actions → Runners
|
||||
- **Repository runners**: Repository Settings → Actions → Runners
|
||||
|
||||
Then register:
|
||||
|
||||
```bash
|
||||
./act_runner register --no-interactive \
|
||||
--instance https://your-gitea-instance.com \
|
||||
--token YOUR_REGISTRATION_TOKEN \
|
||||
--name my-runner \
|
||||
--labels linux,ubuntu-latest
|
||||
```
|
||||
|
||||
### 3. Start the Runner
|
||||
|
||||
```bash
|
||||
./act_runner daemon
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Runner Labels
|
||||
|
||||
Labels determine which jobs the runner can execute. Configure labels during registration or edit them in the admin UI.
|
||||
|
||||
Common labels:
|
||||
- `linux`, `linux-latest` - Linux runners
|
||||
- `windows`, `windows-latest` - Windows runners
|
||||
- `macos`, `macos-latest` - macOS runners
|
||||
- `ubuntu`, `ubuntu-latest` - Ubuntu-specific
|
||||
- `self-hosted` - Self-hosted runners
|
||||
|
||||
### Running as a Service
|
||||
|
||||
#### Linux (systemd)
|
||||
|
||||
```bash
|
||||
sudo cat > /etc/systemd/system/act_runner.service << 'SERVICE'
|
||||
[Unit]
|
||||
Description=GitCaddy Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=runner
|
||||
WorkingDirectory=/opt/act_runner
|
||||
ExecStart=/opt/act_runner/act_runner daemon
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
sudo systemctl enable act_runner
|
||||
sudo systemctl start act_runner
|
||||
```
|
||||
|
||||
### Docker Support
|
||||
|
||||
For workflows that use container actions, ensure Docker is installed and the runner user has access:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
## Capabilities Detection
|
||||
|
||||
The runner automatically detects and reports:
|
||||
- Operating system and architecture
|
||||
- Available shells (bash, sh, powershell)
|
||||
- Installed tools (node, python, go, etc.)
|
||||
- Docker availability
|
||||
- Disk space and network bandwidth
|
||||
|
||||
These capabilities help admins understand what each runner can handle.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner not connecting
|
||||
|
||||
1. Check network connectivity to your GitCaddy instance
|
||||
2. Verify the registration token is valid
|
||||
3. Check firewall rules allow outbound HTTPS
|
||||
|
||||
### Jobs not running
|
||||
|
||||
1. Verify runner labels match the job's `runs-on` requirement
|
||||
2. Check runner is online in the admin panel
|
||||
3. Review runner logs: `journalctl -u act_runner -f`
|
||||
|
||||
## More Information
|
||||
|
||||
- [act_runner Repository](https://git.marketally.com/gitcaddy/act_runner)
|
||||
- [GitCaddy Documentation](https://git.marketally.com/gitcaddy/gitea)
|
||||
28
Makefile
28
Makefile
@@ -1,13 +1,12 @@
|
||||
DIST := dist
|
||||
EXECUTABLE := act_runner
|
||||
GOFMT ?= gofumpt -l
|
||||
DIST := dist
|
||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
||||
XGO_VERSION := go-1.18.x
|
||||
XGO_VERSION := go-1.24.x
|
||||
GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
@@ -19,6 +18,9 @@ GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "genera
|
||||
DOCKER_IMAGE ?= gitea/act_runner
|
||||
DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
||||
|
||||
ifneq ($(shell uname), Darwin)
|
||||
EXTLDFLAGS = -extldflags "-static" $(null)
|
||||
@@ -65,8 +67,11 @@ else
|
||||
endif
|
||||
endif
|
||||
|
||||
GO_PACKAGES_TO_VET ?= $(filter-out gitea.com/gitea/act_runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
|
||||
|
||||
|
||||
TAGS ?=
|
||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=$(RELASE_VERSION)"
|
||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||
|
||||
all: build
|
||||
|
||||
@@ -98,14 +103,22 @@ fmt-check:
|
||||
exit 1; \
|
||||
fi;
|
||||
|
||||
test: fmt-check
|
||||
.PHONY: deps-tools
|
||||
deps-tools: ## install tool dependencies
|
||||
$(GO) install $(GOVULNCHECK_PACKAGE)
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: deps-tools
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./...
|
||||
|
||||
test: fmt-check security-check
|
||||
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
@echo "Running go vet..."
|
||||
@$(GO) build code.gitea.io/gitea-vet
|
||||
@$(GO) vet -vettool=gitea-vet ./...
|
||||
@$(GO) vet -vettool=gitea-vet $(GO_PACKAGES_TO_VET)
|
||||
|
||||
install: $(GOFILES)
|
||||
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
|
||||
@@ -162,7 +175,10 @@ release-compress: | $(DIST_DIRS)
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
docker build --disable-content-trust=false -t $(DOCKER_REF) .
|
||||
if ! docker buildx version >/dev/null 2>&1; then \
|
||||
ARG_DISABLE_CONTENT_TRUST=--disable-content-trust=false; \
|
||||
fi; \
|
||||
docker build $${ARG_DISABLE_CONTENT_TRUST} -t $(DOCKER_REF) .
|
||||
|
||||
clean:
|
||||
$(GO) clean -x -i ./...
|
||||
|
||||
228
README.md
228
README.md
@@ -1,95 +1,195 @@
|
||||
# act runner
|
||||
# GitCaddy Act Runner
|
||||
|
||||
Act runner is a runner for Gitea based on [Gitea fork](https://gitea.com/gitea/act) of [act](https://github.com/nektos/act).
|
||||
A Gitea Actions runner with enhanced capability detection and reporting for AI-friendly workflow generation.
|
||||
|
||||
> **This is a GitCaddy fork** of [gitea.com/gitea/act_runner](https://gitea.com/gitea/act_runner) with runner capability discovery features.
|
||||
|
||||
## Overview
|
||||
|
||||
Act Runner executes Gitea Actions workflows using [act](https://github.com/nektos/act). This fork adds automatic capability detection, enabling Gitea to expose runner capabilities via API for AI tools to query before generating workflows.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Capability Detection**: Automatically detects OS, architecture, Docker support, available shells, and installed tools
|
||||
- **Capability Reporting**: Reports capabilities to Gitea server during runner declaration
|
||||
- **Full Compatibility**: Drop-in replacement for standard act_runner
|
||||
- **Multi-Platform**: Supports Linux, macOS, and Windows
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
### Download Pre-built Binary
|
||||
|
||||
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||
|
||||
### Download pre-built binary
|
||||
|
||||
Visit https://dl.gitea.com/act_runner/ and download the right version for your platform.
|
||||
|
||||
### Build from source
|
||||
Download from [Releases](https://git.marketally.com/gitcaddy/act_runner/releases):
|
||||
|
||||
```bash
|
||||
# Linux (amd64)
|
||||
curl -L -o act_runner https://git.marketally.com/gitcaddy/act_runner/releases/download/v0.3.1-gitcaddy/act_runner-linux-amd64
|
||||
chmod +x act_runner
|
||||
|
||||
# macOS (Apple Silicon)
|
||||
curl -L -o act_runner https://git.marketally.com/gitcaddy/act_runner/releases/download/v0.3.1-gitcaddy/act_runner-darwin-arm64
|
||||
chmod +x act_runner
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://git.marketally.com/gitcaddy/act_runner.git
|
||||
cd act_runner
|
||||
make build
|
||||
```
|
||||
|
||||
### Build a docker image
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable Actions in Gitea
|
||||
|
||||
Add to your Gitea `app.ini`:
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
### 2. Register the Runner
|
||||
|
||||
```bash
|
||||
make docker
|
||||
./act_runner register \
|
||||
--instance https://your-gitea-instance.com \
|
||||
--token YOUR_RUNNER_TOKEN \
|
||||
--name my-runner \
|
||||
--labels ubuntu-latest,docker
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Register
|
||||
|
||||
```bash
|
||||
./act_runner register
|
||||
```
|
||||
|
||||
And you will be asked to input:
|
||||
|
||||
1. Gitea instance URL, like `http://192.168.8.8:3000/`. You should use your gitea instance ROOT_URL as the instance argument
|
||||
and you should not use `localhost` or `127.0.0.1` as instance IP;
|
||||
2. Runner token, you can get it from `http://192.168.8.8:3000/admin/runners`;
|
||||
3. Runner name, you can just leave it blank;
|
||||
4. Runner labels, you can just leave it blank.
|
||||
|
||||
The process looks like:
|
||||
|
||||
```text
|
||||
INFO Registering runner, arch=amd64, os=darwin, version=0.1.5.
|
||||
WARN Runner in user-mode.
|
||||
INFO Enter the Gitea instance URL (for example, https://gitea.com/):
|
||||
http://192.168.8.8:3000/
|
||||
INFO Enter the runner token:
|
||||
fe884e8027dc292970d4e0303fe82b14xxxxxxxx
|
||||
INFO Enter the runner name (if set empty, use hostname: Test.local):
|
||||
|
||||
INFO Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-20.04:docker://node:16-bullseye,ubuntu-18.04:docker://node:16-buster,linux_arm:host):
|
||||
|
||||
INFO Registering runner, name=Test.local, instance=http://192.168.8.8:3000/, labels=[ubuntu-latest:docker://node:16-bullseye ubuntu-22.04:docker://node:16-bullseye ubuntu-20.04:docker://node:16-bullseye ubuntu-18.04:docker://node:16-buster].
|
||||
DEBU Successfully pinged the Gitea instance server
|
||||
INFO Runner registered successfully.
|
||||
```
|
||||
|
||||
You can also register with command line arguments.
|
||||
|
||||
```bash
|
||||
./act_runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
||||
```
|
||||
|
||||
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
|
||||
|
||||
### Run
|
||||
### 3. Start the Runner
|
||||
|
||||
```bash
|
||||
./act_runner daemon
|
||||
```
|
||||
|
||||
### Configuration
|
||||
On startup, the runner will:
|
||||
1. Detect system capabilities (OS, arch, Docker, shells, tools)
|
||||
2. Report capabilities to Gitea via the Declare API
|
||||
3. Begin polling for jobs
|
||||
|
||||
You can also configure the runner with a configuration file.
|
||||
The configuration file is a YAML file, you can generate a sample configuration file with `./act_runner generate-config`.
|
||||
## Capability Detection
|
||||
|
||||
The runner automatically detects:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| **OS/Arch** | linux/amd64, darwin/arm64, windows/amd64 |
|
||||
| **Container Runtime** | Docker, Podman |
|
||||
| **Shells** | bash, sh, zsh, powershell, cmd |
|
||||
| **Tools** | Node.js, Go, Python, Java, .NET, Rust |
|
||||
| **Features** | Cache support, Docker Compose |
|
||||
|
||||
### Example Capabilities JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"docker": true,
|
||||
"docker_compose": true,
|
||||
"container_runtime": "docker",
|
||||
"shell": ["bash", "sh"],
|
||||
"tools": {
|
||||
"node": ["18.19.0"],
|
||||
"go": ["1.21.5"],
|
||||
"python": ["3.11.6"]
|
||||
},
|
||||
"features": {
|
||||
"cache": true,
|
||||
"docker_services": true
|
||||
},
|
||||
"limitations": []
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a config file or use command-line flags:
|
||||
|
||||
```bash
|
||||
./act_runner generate-config > config.yaml
|
||||
./act_runner -c config.yaml daemon
|
||||
```
|
||||
|
||||
You can specify the configuration file path with `-c`/`--config` argument.
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
labels:
|
||||
- ubuntu-latest:docker://node:18-bullseye
|
||||
- ubuntu-22.04:docker://ubuntu:22.04
|
||||
|
||||
container:
|
||||
docker_host: ""
|
||||
force_pull: false
|
||||
privileged: false
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
dir: ~/.cache/actcache
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
```bash
|
||||
./act_runner -c config.yaml register # register with config file
|
||||
./act_runner -c config.yaml deamon # run with config file
|
||||
docker run -d \
|
||||
--name act_runner \
|
||||
-e GITEA_INSTANCE_URL=https://your-gitea.com \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ./data:/data \
|
||||
gitcaddy/act_runner:latest
|
||||
```
|
||||
|
||||
### Run a docker container
|
||||
## GitCaddy Integration
|
||||
|
||||
This runner is designed to work with the [GitCaddy Gitea fork](https://git.marketally.com/gitcaddy/gitea), which provides:
|
||||
|
||||
- **Runner Capabilities API** (`/api/v2/repos/{owner}/{repo}/actions/runners/capabilities`)
|
||||
- **Workflow Validation API** for pre-flight checks
|
||||
- **Action Compatibility Database** for GitHub Actions mapping
|
||||
|
||||
### How It Works
|
||||
|
||||
```sh
|
||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/act_runner:nightly
|
||||
```
|
||||
|
||||
act_runner Gitea AI Tool
|
||||
| | |
|
||||
| Declare + Capabilities | |
|
||||
|---------------------------->| |
|
||||
| | |
|
||||
| | GET /api/v2/.../caps |
|
||||
| |<------------------------|
|
||||
| | |
|
||||
| | Runner capabilities |
|
||||
| |------------------------>|
|
||||
| | |
|
||||
| | Generates workflow |
|
||||
| | with correct config |
|
||||
|
||||
```
|
||||
|
||||
## Related Projects
|
||||
|
||||
| Project | Description |
|
||||
|---------|-------------|
|
||||
| [gitcaddy/gitea](https://git.marketally.com/gitcaddy/gitea) | Gitea with AI-friendly enhancements |
|
||||
| [gitcaddy/actions-proto-go](https://git.marketally.com/gitcaddy/actions-proto-go) | Protocol definitions with capability support |
|
||||
|
||||
## Upstream
|
||||
|
||||
This project is a fork of [gitea.com/gitea/act_runner](https://gitea.com/gitea/act_runner). We contribute enhancements back to upstream where appropriate.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
BIN
act_runner_test
Executable file
BIN
act_runner_test
Executable file
Binary file not shown.
12
examples/README.md
Normal file
12
examples/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Usage Examples for `act_runner`
|
||||
|
||||
Welcome to our collection of usage and deployment examples specifically designed for Gitea setups. Whether you're a beginner or an experienced user, you'll find practical resources here that you can directly apply to enhance your Gitea experience. We encourage you to contribute your own insights and knowledge to make this collection even more comprehensive and valuable.
|
||||
|
||||
| Section | Description |
|
||||
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [`docker`](docker) | This section provides you with scripts and instructions tailored for running containers on a workstation or server where Docker is installed. It simplifies the process of setting up and managing your Gitea deployment using Docker. |
|
||||
| [`docker-compose`](docker-compose) | In this section, you'll discover examples demonstrating how to utilize docker-compose to efficiently handle your Gitea deployments. It offers a straightforward approach to managing multiple containerized components of your Gitea setup. |
|
||||
| [`kubernetes`](kubernetes) | If you're utilizing Kubernetes clusters for your infrastructure, this section is specifically designed for you. It presents examples and guidelines for configuring Gitea deployments within Kubernetes clusters, enabling you to leverage the scalability and flexibility of Kubernetes. |
|
||||
| [`vm`](vm) | This section is dedicated to examples that assist you in setting up Gitea on virtual or physical servers. Whether you're working with virtual machines or physical hardware, you'll find helpful resources to guide you through the deployment process. |
|
||||
|
||||
We hope these resources provide you with valuable insights and solutions for your Gitea setup. Feel free to explore, contribute, and adapt these examples to suit your specific requirements.
|
||||
61
examples/docker-compose/README.md
Normal file
61
examples/docker-compose/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
### Running `act_runner` using `docker-compose`
|
||||
|
||||
```yml
|
||||
...
|
||||
gitea:
|
||||
image: gitea/gitea
|
||||
...
|
||||
healthcheck:
|
||||
# checks availability of Gitea's front-end with curl
|
||||
test: ["CMD", "curl", "-f", "<instance_url>"]
|
||||
interval: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
environment:
|
||||
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
|
||||
# The Gitea version must be v1.23 or higher.
|
||||
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||
|
||||
runner:
|
||||
image: gitea/act_runner
|
||||
restart: always
|
||||
depends_on:
|
||||
gitea:
|
||||
# required so runner can attach to gitea, see "healthcheck"
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
volumes:
|
||||
- ./data/act_runner:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=<instance url>
|
||||
# When using Docker Secrets, it's also possible to use
|
||||
# GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||
# The env var takes precedence.
|
||||
# Needed only for the first start.
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
||||
```
|
||||
|
||||
### Running `act_runner` using Docker-in-Docker (DIND)
|
||||
|
||||
```yml
|
||||
...
|
||||
runner:
|
||||
image: gitea/act_runner:latest-dind-rootless
|
||||
restart: always
|
||||
privileged: true
|
||||
depends_on:
|
||||
- gitea
|
||||
volumes:
|
||||
- ./data/act_runner:/data
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=<instance url>
|
||||
- DOCKER_HOST=unix:///var/run/user/1000/docker.sock
|
||||
# When using Docker Secrets, it's also possible to use
|
||||
# GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||
# The env var takes precedence.
|
||||
# Needed only for the first start.
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
||||
```
|
||||
8
examples/docker/README.md
Normal file
8
examples/docker/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
### Run `act_runner` in a Docker Container
|
||||
|
||||
```sh
|
||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/act_runner:nightly
|
||||
```
|
||||
|
||||
The `/data` directory inside the docker container contains the runner API keys after registration.
|
||||
It must be persisted, otherwise the runner would try to register again, using the same, now defunct registration token.
|
||||
11
examples/kubernetes/README.md
Normal file
11
examples/kubernetes/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## Kubernetes Docker in Docker Deployment with `act_runner`
|
||||
|
||||
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.
|
||||
|
||||
Files in this directory:
|
||||
|
||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
||||
How to create a Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||
|
||||
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
||||
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||
81
examples/kubernetes/dind-docker.yaml
Normal file
81
examples/kubernetes/dind-docker.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: act-runner-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: runner-secret
|
||||
type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: act-runner
|
||||
name: act-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: act-runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: act-runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: docker-certs
|
||||
emptyDir: {}
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: act-runner-vol
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/act_runner:nightly
|
||||
command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- run.sh"]
|
||||
env:
|
||||
- name: DOCKER_HOST
|
||||
value: tcp://localhost:2376
|
||||
- name: DOCKER_CERT_PATH
|
||||
value: /certs/client
|
||||
- name: DOCKER_TLS_VERIFY
|
||||
value: "1"
|
||||
- name: GITEA_INSTANCE_URL
|
||||
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: runner-secret
|
||||
key: token
|
||||
volumeMounts:
|
||||
- name: docker-certs
|
||||
mountPath: /certs
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
- name: daemon
|
||||
image: docker:23.0.6-dind
|
||||
env:
|
||||
- name: DOCKER_TLS_CERTDIR
|
||||
value: /certs
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: docker-certs
|
||||
mountPath: /certs
|
||||
73
examples/kubernetes/rootless-docker.yaml
Normal file
73
examples/kubernetes/rootless-docker.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: act-runner-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: standard
|
||||
---
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: runner-secret
|
||||
type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: act-runner
|
||||
name: act-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: act-runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: act-runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: act-runner-vol
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/act_runner:nightly-dind-rootless
|
||||
imagePullPolicy: Always
|
||||
# command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- /opt/act/run.sh"]
|
||||
env:
|
||||
- name: DOCKER_HOST
|
||||
value: tcp://localhost:2376
|
||||
- name: DOCKER_CERT_PATH
|
||||
value: /certs/client
|
||||
- name: DOCKER_TLS_VERIFY
|
||||
value: "1"
|
||||
- name: GITEA_INSTANCE_URL
|
||||
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: runner-secret
|
||||
key: token
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
|
||||
6
examples/vm/README.md
Normal file
6
examples/vm/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## `act_runner` on Virtual or Physical Servers
|
||||
|
||||
Files in this directory:
|
||||
|
||||
- [`rootless-docker.md`](rootless-docker.md)
|
||||
How to set up a rootless docker implementation of the runner.
|
||||
95
examples/vm/rootless-docker.md
Normal file
95
examples/vm/rootless-docker.md
Normal file
@@ -0,0 +1,95 @@
|
||||
## Using Rootless Docker with`act_runner`
|
||||
|
||||
Here is a simple example of how to set up `act_runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
|
||||
|
||||
Note: This procedure needs a real login shell -- using `sudo su` or other method of accessing the account will fail some of the steps below.
|
||||
|
||||
As `root`:
|
||||
|
||||
- Create a user to run both `docker` and `act_runner`. In this example, we use a non-privileged account called `rootless`.
|
||||
|
||||
```bash
|
||||
useradd -m rootless
|
||||
passwd rootless
|
||||
apt-get install -y uidmap # Not mentioned but needed for docker rootless.
|
||||
```
|
||||
|
||||
- Install [`docker-ce`](https://docs.docker.com/engine/install/)
|
||||
- (Recommended) Disable the system-wide Docker daemon
|
||||
|
||||
``systemctl disable --now docker.service docker.socket``
|
||||
|
||||
As the `rootless` user:
|
||||
|
||||
- Follow the instructions for [enabling rootless mode](https://docs.docker.com/engine/security/rootless/)
|
||||
- Add the following line to the `/home/rootless/.bashrc`:
|
||||
|
||||
```bash
|
||||
for f in ./.bashrc.d/*.bash; do echo "Processing $f file..."; . "$f"; done
|
||||
```
|
||||
|
||||
- Create the .bashrc.d directory `mkdir ~/.bashrc.d`
|
||||
- Add the following lines to the `/home/rootless/.bashrc.d/rootless-docker.bash`:
|
||||
|
||||
```bash
|
||||
export XDG_RUNTIME_DIR=/home/rootless/.docker/run
|
||||
export PATH=/home/rootless/bin:$PATH
|
||||
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
|
||||
```
|
||||
|
||||
- Reboot. Ensure that the Docker process is working.
|
||||
- Create a directory for saving `act_runner` data between restarts
|
||||
|
||||
`mkdir /home/rootless/act_runner`
|
||||
|
||||
- Register the runner from the data directory
|
||||
|
||||
```bash
|
||||
cd /home/rootless/act_runner
|
||||
act_runner register
|
||||
```
|
||||
|
||||
- Generate a `act_runner` configuration file in the data directory. Edit the file to adjust for the system.
|
||||
|
||||
```bash
|
||||
act_runner generate-config >/home/rootless/act_runner/config
|
||||
```
|
||||
|
||||
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/act_runner.service` with the following contents:
|
||||
|
||||
```bash
|
||||
Description=Gitea Actions runner
|
||||
Documentation=https://gitea.com/gitea/act_runner
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/home/rootless/bin:/sbin:/usr/sbin:/home/rootless/bin:/home/rootless/bin:/home/rootless/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
|
||||
Environment=DOCKER_HOST=unix:///run/user/1001/docker.sock
|
||||
ExecStart=/usr/bin/act_runner daemon -c /home/rootless/act_runner/config
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
WorkingDirectory=/home/rootless/act_runner
|
||||
TimeoutSec=0
|
||||
RestartSec=2
|
||||
Restart=always
|
||||
StartLimitBurst=3
|
||||
StartLimitInterval=60s
|
||||
LimitNOFILE=infinity
|
||||
LimitNPROC=infinity
|
||||
LimitCORE=infinity
|
||||
TasksMax=infinity
|
||||
Delegate=yes
|
||||
Type=notify
|
||||
NotifyAccess=all
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
- Reboot
|
||||
|
||||
After the system restarts, check that the`act_runner` is working and that the runner is connected to Gitea.
|
||||
|
||||
````bash
|
||||
systemctl --user status act_runner
|
||||
journalctl --user -xeu act_runner
|
||||
165
go.mod
165
go.mod
@@ -1,111 +1,114 @@
|
||||
module gitea.com/gitea/act_runner
|
||||
|
||||
go 1.20
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
code.gitea.io/actions-proto-go v0.2.0
|
||||
code.gitea.io/gitea-vet v0.2.3-0.20230113022436-2b1561217fa5
|
||||
github.com/avast/retry-go/v4 v4.3.1
|
||||
github.com/bufbuild/connect-go v1.3.1
|
||||
github.com/docker/docker v23.0.1+incompatible
|
||||
github.com/go-chi/chi/v5 v5.0.8
|
||||
github.com/go-chi/render v1.0.2
|
||||
code.gitea.io/actions-proto-go v0.5.2
|
||||
code.gitea.io/gitea-vet v0.2.3
|
||||
connectrpc.com/connect v1.16.2
|
||||
github.com/avast/retry-go/v4 v4.6.0
|
||||
github.com/docker/docker v25.0.13+incompatible
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-isatty v0.0.17
|
||||
github.com/nektos/act v0.0.0
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/stretchr/testify v1.8.1
|
||||
golang.org/x/term v0.6.0
|
||||
golang.org/x/time v0.1.0
|
||||
google.golang.org/protobuf v1.28.1
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/nektos/act v0.0.0 // will be replaced
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.36.0
|
||||
golang.org/x/time v0.12.0
|
||||
google.golang.org/protobuf v1.35.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gotest.tools/v3 v3.4.0
|
||||
modernc.org/sqlite v1.14.2
|
||||
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978
|
||||
xorm.io/xorm v1.3.2
|
||||
gotest.tools/v3 v3.5.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.37.0
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
|
||||
cyphar.com/go-pathrs v0.2.1 // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.5.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20220404123522-616f957b79ad // indirect
|
||||
github.com/acomagu/bufpipe v1.0.3 // indirect
|
||||
github.com/ajg/form v1.5.1 // indirect
|
||||
github.com/containerd/containerd v1.6.18 // indirect
|
||||
github.com/creack/pty v1.1.18 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/containerd/containerd v1.7.29 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/creack/pty v1.1.21 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/docker/cli v23.0.1+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.1+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.7.0 // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/cli v25.0.3+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/emirpasic/gods v1.12.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.0 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
||||
github.com/go-git/go-git/v5 v5.4.2 // indirect
|
||||
github.com/goccy/go-json v0.8.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fatih/color v1.17.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.12.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/imdario/mergo v0.3.13 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.15.12 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/moby/buildkit v0.11.4 // indirect
|
||||
github.com/moby/patternmatcher v0.5.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moby/buildkit v0.12.5 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/onsi/ginkgo v1.12.1 // indirect
|
||||
github.com/onsi/gomega v1.10.3 // indirect
|
||||
github.com/moby/sys/user v0.3.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0-rc2 // indirect
|
||||
github.com/opencontainers/runc v1.1.3 // indirect
|
||||
github.com/opencontainers/selinux v1.11.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/selinux v1.13.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/rhysd/actionlint v1.6.23 // indirect
|
||||
github.com/rivo/uniseg v0.4.3 // indirect
|
||||
github.com/robfig/cron v1.2.0 // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/rhysd/actionlint v1.7.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.1 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.2.0 // indirect
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/tools v0.1.5 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
go.etcd.io/bbolt v1.3.10 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/tools v0.23.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
lukechampine.com/uint128 v1.1.1 // indirect
|
||||
modernc.org/cc/v3 v3.35.18 // indirect
|
||||
modernc.org/ccgo/v3 v3.12.82 // indirect
|
||||
modernc.org/libc v1.11.87 // indirect
|
||||
modernc.org/mathutil v1.4.1 // indirect
|
||||
modernc.org/memory v1.0.5 // indirect
|
||||
modernc.org/opt v0.1.1 // indirect
|
||||
modernc.org/strutil v1.1.1 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/nektos/act => gitea.com/gitea/act v0.243.2-0.20230323041428-929ea6df751b
|
||||
replace github.com/nektos/act => gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742
|
||||
|
||||
replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.16.2
|
||||
|
||||
// Remove after
|
||||
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0
|
||||
|
||||
// Use GitCaddy fork with capability support
|
||||
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.7
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package artifactcache provides a cache handler for the runner.
|
||||
//
|
||||
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||
//
|
||||
// TODO: Authorization
|
||||
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
||||
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
|
||||
package artifactcache
|
||||
@@ -1,416 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/render"
|
||||
log "github.com/sirupsen/logrus"
|
||||
_ "modernc.org/sqlite"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const (
|
||||
urlBase = "/_apis/artifactcache"
|
||||
)
|
||||
|
||||
var logger = log.StandardLogger().WithField("module", "cache_request")
|
||||
|
||||
type Handler struct {
|
||||
engine engine
|
||||
storage *Storage
|
||||
router *chi.Mux
|
||||
listener net.Listener
|
||||
|
||||
gc atomic.Bool
|
||||
gcAt time.Time
|
||||
|
||||
outboundIP string
|
||||
}
|
||||
|
||||
func StartHandler(dir, outboundIP string, port uint16) (*Handler, error) {
|
||||
h := &Handler{}
|
||||
|
||||
if dir == "" {
|
||||
if home, err := os.UserHomeDir(); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e, err := xorm.NewEngine("sqlite", filepath.Join(dir, "sqlite.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.Sync(&Cache{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.engine = engine{e: e}
|
||||
|
||||
storage, err := NewStorage(filepath.Join(dir, "cache"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.storage = storage
|
||||
|
||||
if outboundIP != "" {
|
||||
h.outboundIP = outboundIP
|
||||
} else if ip, err := getOutboundIP(); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
h.outboundIP = ip.String()
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: logger}))
|
||||
router.Use(func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler.ServeHTTP(w, r)
|
||||
go h.gcCache()
|
||||
})
|
||||
})
|
||||
router.Use(middleware.Logger)
|
||||
router.Route(urlBase, func(r chi.Router) {
|
||||
r.Get("/cache", h.find)
|
||||
r.Route("/caches", func(r chi.Router) {
|
||||
r.Post("/", h.reserve)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Patch("/", h.upload)
|
||||
r.Post("/", h.commit)
|
||||
})
|
||||
})
|
||||
r.Get("/artifacts/{id}", h.get)
|
||||
r.Post("/clean", h.clean)
|
||||
})
|
||||
|
||||
h.router = router
|
||||
|
||||
h.gcCache()
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
if err := http.Serve(listener, h.router); err != nil {
|
||||
logger.Errorf("http serve: %v", err)
|
||||
}
|
||||
}()
|
||||
h.listener = listener
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
return fmt.Sprintf("http://%s:%d",
|
||||
h.outboundIP,
|
||||
h.listener.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/cache
|
||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
|
||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||
version := r.URL.Query().Get("version")
|
||||
|
||||
cache, err := h.findCache(r.Context(), keys, version)
|
||||
if err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
responseJson(w, r, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.storage.Exist(cache.ID); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
_ = h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Delete(cache)
|
||||
return err
|
||||
})
|
||||
responseJson(w, r, 204)
|
||||
return
|
||||
}
|
||||
responseJson(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
|
||||
"cacheKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) {
|
||||
cache := &Cache{}
|
||||
if err := render.Bind(r, cache); err != nil {
|
||||
responseJson(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.engine.ExecBool(func(sess *xorm.Session) (bool, error) {
|
||||
return sess.Where(builder.Eq{"key": cache.Key, "version": cache.Version}).Get(&Cache{})
|
||||
}); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
} else if ok {
|
||||
responseJson(w, r, 400, fmt.Errorf("already exist"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Insert(cache)
|
||||
return err
|
||||
}); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
responseJson(w, r, 200, map[string]any{
|
||||
"cacheId": cache.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// PATCH /_apis/artifactcache/caches/:id
|
||||
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
responseJson(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache := &Cache{
|
||||
ID: id,
|
||||
}
|
||||
|
||||
if ok, err := h.engine.ExecBool(func(sess *xorm.Session) (bool, error) {
|
||||
return sess.Get(cache)
|
||||
}); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
responseJson(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
responseJson(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
}
|
||||
start, _, err := parseContentRange(r.Header.Get("Content-Range"))
|
||||
if err != nil {
|
||||
responseJson(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
}
|
||||
h.useCache(r.Context(), id)
|
||||
responseJson(w, r, 200)
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches/:id
|
||||
func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
responseJson(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache := &Cache{
|
||||
ID: id,
|
||||
}
|
||||
if ok, err := h.engine.ExecBool(func(sess *xorm.Session) (bool, error) {
|
||||
return sess.Get(cache)
|
||||
}); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
responseJson(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
responseJson(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.storage.Commit(cache.ID, cache.Size); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
|
||||
cache.Complete = true
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.ID(cache.ID).Cols("complete").Update(cache)
|
||||
return err
|
||||
}); err != nil {
|
||||
responseJson(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseJson(w, r, 200)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
responseJson(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
h.useCache(r.Context(), id)
|
||||
h.storage.Serve(w, r, id)
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/clean
|
||||
func (h *Handler) clean(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: don't support force deleting cache entries
|
||||
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
|
||||
responseJson(w, r, 200)
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
func (h *Handler) findCache(ctx context.Context, keys []string, version string) (*Cache, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
key := keys[0] // the first key is for exact match.
|
||||
|
||||
cache := &Cache{}
|
||||
if ok, err := h.engine.ExecBool(func(sess *xorm.Session) (bool, error) {
|
||||
return sess.Where(builder.Eq{"key": key, "version": version, "complete": true}).Get(cache)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
for _, prefix := range keys[1:] {
|
||||
if ok, err := h.engine.ExecBool(func(sess *xorm.Session) (bool, error) {
|
||||
return sess.Where(builder.And(
|
||||
builder.Like{"key", prefix + "%"},
|
||||
builder.Eq{"version": version, "complete": true},
|
||||
)).OrderBy("id DESC").Get(cache)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return cache, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (h *Handler) useCache(ctx context.Context, id int64) {
|
||||
// keep quiet
|
||||
_ = h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Context(ctx).Cols("used_at").Update(&Cache{
|
||||
ID: id,
|
||||
UsedAt: time.Now().Unix(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) gcCache() {
|
||||
if h.gc.Load() {
|
||||
return
|
||||
}
|
||||
if !h.gc.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer h.gc.Store(false)
|
||||
|
||||
if time.Since(h.gcAt) < time.Hour {
|
||||
logger.Infof("skip gc: %v", h.gcAt.String())
|
||||
return
|
||||
}
|
||||
h.gcAt = time.Now()
|
||||
logger.Infof("gc: %v", h.gcAt.String())
|
||||
|
||||
const (
|
||||
keepUsed = 30 * 24 * time.Hour
|
||||
keepUnused = 7 * 24 * time.Hour
|
||||
keepTemp = 5 * time.Minute
|
||||
)
|
||||
|
||||
var caches []*Cache
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
return sess.Where(builder.And(builder.Lt{"used_at": time.Now().Add(-keepTemp).Unix()}, builder.Eq{"complete": false})).
|
||||
Find(&caches)
|
||||
}); err != nil {
|
||||
logger.Warnf("find caches: %v", err)
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
h.storage.Remove(cache.ID)
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Delete(cache)
|
||||
return err
|
||||
}); err != nil {
|
||||
logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
caches = caches[:0]
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
return sess.Where(builder.Lt{"used_at": time.Now().Add(-keepUnused).Unix()}).
|
||||
Find(&caches)
|
||||
}); err != nil {
|
||||
logger.Warnf("find caches: %v", err)
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
h.storage.Remove(cache.ID)
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Delete(cache)
|
||||
return err
|
||||
}); err != nil {
|
||||
logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
caches = caches[:0]
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
return sess.Where(builder.Lt{"created_at": time.Now().Add(-keepUsed).Unix()}).
|
||||
Find(&caches)
|
||||
}); err != nil {
|
||||
logger.Warnf("find caches: %v", err)
|
||||
} else {
|
||||
for _, cache := range caches {
|
||||
h.storage.Remove(cache.ID)
|
||||
if err := h.engine.Exec(func(sess *xorm.Session) error {
|
||||
_, err := sess.Delete(cache)
|
||||
return err
|
||||
}); err != nil {
|
||||
logger.Warnf("delete cache: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("deleted cache: %+v", cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
ID int64 `xorm:"id pk autoincr" json:"-"`
|
||||
Key string `xorm:"TEXT index unique(key_version)" json:"key"`
|
||||
Version string `xorm:"TEXT unique(key_version)" json:"version"`
|
||||
Size int64 `json:"cacheSize"`
|
||||
Complete bool `xorm:"index(complete_used_at)" json:"-"`
|
||||
UsedAt int64 `xorm:"index(complete_used_at) updated" json:"-"`
|
||||
CreatedAt int64 `xorm:"index created" json:"-"`
|
||||
}
|
||||
|
||||
// Bind implements render.Binder
|
||||
func (c *Cache) Bind(_ *http.Request) error {
|
||||
if c.Key == "" {
|
||||
return fmt.Errorf("missing key")
|
||||
}
|
||||
if c.Version == "" {
|
||||
return fmt.Errorf("missing version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func NewStorage(rootDir string) (*Storage, error) {
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Storage{
|
||||
rootDir: rootDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Exist(id int64) (bool, error) {
|
||||
name := s.filename(id)
|
||||
if _, err := os.Stat(name); os.IsNotExist(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id int64, offset int64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id int64, size int64) error {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
}()
|
||||
|
||||
name := s.filename(id)
|
||||
tempNames, err := s.tempNames(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var written int64
|
||||
for _, v := range tempNames {
|
||||
f, err := os.Open(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := io.Copy(file, f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
if written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
name := s.filename(id)
|
||||
http.ServeFile(w, r, name)
|
||||
}
|
||||
|
||||
func (s *Storage) Remove(id int64) {
|
||||
_ = os.Remove(s.filename(id))
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
}
|
||||
|
||||
func (s *Storage) filename(id int64) string {
|
||||
return filepath.Join(s.rootDir, fmt.Sprintf("%02x", id%0xff), fmt.Sprint(id))
|
||||
}
|
||||
|
||||
func (s *Storage) tempDir(id int64) string {
|
||||
return filepath.Join(s.rootDir, "tmp", fmt.Sprint(id))
|
||||
}
|
||||
|
||||
func (s *Storage) tempName(id, offset int64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id int64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func responseJson(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
||||
render.Status(r, code)
|
||||
if len(v) == 0 || v[0] == nil {
|
||||
render.JSON(w, r, struct{}{})
|
||||
} else if err, ok := v[0].(error); ok {
|
||||
logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
|
||||
render.JSON(w, r, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
render.JSON(w, r, v[0])
|
||||
}
|
||||
}
|
||||
|
||||
func parseContentRange(s string) (int64, int64, error) {
|
||||
// support the format like "bytes 11-22/*" only
|
||||
s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/")
|
||||
s1, s2, _ := strings.Cut(s, "-")
|
||||
|
||||
start, err := strconv.ParseInt(s1, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
|
||||
}
|
||||
stop, err := strconv.ParseInt(s2, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
|
||||
}
|
||||
return start, stop, nil
|
||||
}
|
||||
|
||||
func getOutboundIP() (net.IP, error) {
|
||||
// FIXME: It makes more sense to use the gateway IP address of container network
|
||||
if conn, err := net.Dial("udp", "8.8.8.8:80"); err == nil {
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP, nil
|
||||
}
|
||||
if ifaces, err := net.Interfaces(); err == nil {
|
||||
for _, i := range ifaces {
|
||||
if addrs, err := i.Addrs(); err == nil {
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip.IsGlobalUnicast() {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no outbound IP address found")
|
||||
}
|
||||
|
||||
// engine is a wrapper of *xorm.Engine, with a lock.
|
||||
// To avoid racing of sqlite, we don't care performance here.
|
||||
type engine struct {
|
||||
e *xorm.Engine
|
||||
m sync.Mutex
|
||||
}
|
||||
|
||||
func (e *engine) Exec(f func(*xorm.Session) error) error {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
|
||||
sess := e.e.NewSession()
|
||||
defer sess.Close()
|
||||
|
||||
return f(sess)
|
||||
}
|
||||
|
||||
func (e *engine) ExecBool(f func(*xorm.Session) (bool, error)) (bool, error) {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
|
||||
sess := e.e.NewSession()
|
||||
defer sess.Close()
|
||||
|
||||
return f(sess)
|
||||
}
|
||||
69
internal/app/cmd/cache-server.go
Normal file
69
internal/app/cmd/cache-server.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
|
||||
"github.com/nektos/act/pkg/artifactcache"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cacheServerArgs struct {
|
||||
Dir string
|
||||
Host string
|
||||
Port uint16
|
||||
}
|
||||
|
||||
func runCacheServer(ctx context.Context, configFile *string, cacheArgs *cacheServerArgs) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.LoadDefault(*configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
initLogging(cfg)
|
||||
|
||||
var (
|
||||
dir = cfg.Cache.Dir
|
||||
host = cfg.Cache.Host
|
||||
port = cfg.Cache.Port
|
||||
)
|
||||
|
||||
// cacheArgs has higher priority
|
||||
if cacheArgs.Dir != "" {
|
||||
dir = cacheArgs.Dir
|
||||
}
|
||||
if cacheArgs.Host != "" {
|
||||
host = cacheArgs.Host
|
||||
}
|
||||
if cacheArgs.Port != 0 {
|
||||
port = cacheArgs.Port
|
||||
}
|
||||
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
host,
|
||||
port,
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt)
|
||||
<-c
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,18 @@ func Execute(ctx context.Context) {
|
||||
registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token")
|
||||
registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name")
|
||||
registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated")
|
||||
registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
|
||||
rootCmd.AddCommand(registerCmd)
|
||||
|
||||
// ./act_runner daemon
|
||||
var daemArgs daemonArgs
|
||||
daemonCmd := &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run as a runner daemon",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runDaemon(ctx, &configFile),
|
||||
Args: cobra.MaximumNArgs(0),
|
||||
RunE: runDaemon(ctx, &daemArgs, &configFile),
|
||||
}
|
||||
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
|
||||
// ./act_runner exec
|
||||
@@ -63,6 +66,19 @@ func Execute(ctx context.Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// ./act_runner cache-server
|
||||
var cacheArgs cacheServerArgs
|
||||
cacheCmd := &cobra.Command{
|
||||
Use: "cache-server",
|
||||
Short: "Start a cache server for the cache action",
|
||||
Args: cobra.MaximumNArgs(0),
|
||||
RunE: runCacheServer(ctx, &configFile, &cacheArgs),
|
||||
}
|
||||
cacheCmd.Flags().StringVarP(&cacheArgs.Dir, "dir", "d", "", "Cache directory")
|
||||
cacheCmd.Flags().StringVarP(&cacheArgs.Host, "host", "s", "", "Host of the cache server")
|
||||
cacheCmd.Flags().Uint16VarP(&cacheArgs.Port, "port", "p", 0, "Port of the cache server")
|
||||
rootCmd.AddCommand(cacheCmd)
|
||||
|
||||
// hide completion command
|
||||
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
||||
|
||||
|
||||
@@ -5,9 +5,18 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -21,16 +30,29 @@ import (
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
)
|
||||
|
||||
func runDaemon(ctx context.Context, configFile *string) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
log.Infoln("Starting runner daemon")
|
||||
const (
|
||||
// DiskSpaceWarningThreshold is the percentage at which to warn about low disk space
|
||||
DiskSpaceWarningThreshold = 85.0
|
||||
// DiskSpaceCriticalThreshold is the percentage at which to log critical warnings
|
||||
DiskSpaceCriticalThreshold = 95.0
|
||||
// CapabilitiesUpdateInterval is how often to update capabilities (including disk space)
|
||||
CapabilitiesUpdateInterval = 5 * time.Minute
|
||||
// BandwidthTestInterval is how often to run bandwidth tests (hourly)
|
||||
BandwidthTestInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Global bandwidth manager - accessible for triggering manual tests
|
||||
var bandwidthManager *envcheck.BandwidthManager
|
||||
|
||||
func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.LoadDefault(*configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
initLogging(cfg)
|
||||
log.Infoln("Starting runner daemon")
|
||||
|
||||
reg, err := config.LoadRegistration(cfg.Runner.File)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -40,8 +62,13 @@ func runDaemon(ctx context.Context, configFile *string) func(cmd *cobra.Command,
|
||||
return fmt.Errorf("failed to load registration file: %w", err)
|
||||
}
|
||||
|
||||
lbls := reg.Labels
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
lbls = cfg.Runner.Labels
|
||||
}
|
||||
|
||||
ls := labels.Labels{}
|
||||
for _, l := range reg.Labels {
|
||||
for _, l := range lbls {
|
||||
label, err := labels.Parse(l)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("ignored invalid label %q", l)
|
||||
@@ -53,10 +80,64 @@ func runDaemon(ctx context.Context, configFile *string) func(cmd *cobra.Command,
|
||||
log.Warn("no labels configured, runner may not be able to pick up jobs")
|
||||
}
|
||||
|
||||
if ls.RequireDocker() {
|
||||
if err := envcheck.CheckIfDockerRunning(ctx); err != nil {
|
||||
if ls.RequireDocker() || cfg.Container.RequireDocker {
|
||||
// Wait for dockerd be ready
|
||||
if timeout := cfg.Container.DockerTimeout; timeout > 0 {
|
||||
tctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
keepRunning := true
|
||||
for keepRunning {
|
||||
dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get socket path: %s", err.Error())
|
||||
} else if err = envcheck.CheckIfDockerRunning(tctx, dockerSocketPath); errors.Is(err, context.Canceled) {
|
||||
log.Infof("Docker wait timeout of %s expired", timeout.String())
|
||||
break
|
||||
} else if err != nil {
|
||||
log.Errorf("Docker connection failed: %s", err.Error())
|
||||
} else {
|
||||
log.Infof("Docker is ready")
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
case <-tctx.Done():
|
||||
log.Infof("Docker wait timeout of %s expired", timeout.String())
|
||||
keepRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// Require dockerd be ready
|
||||
dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envcheck.CheckIfDockerRunning(ctx, dockerSocketPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
|
||||
os.Setenv("DOCKER_HOST", dockerSocketPath)
|
||||
// empty cfg.Container.DockerHost means act_runner need to find an available docker host automatically
|
||||
// and assign the path to cfg.Container.DockerHost
|
||||
if cfg.Container.DockerHost == "" {
|
||||
cfg.Container.DockerHost = dockerSocketPath
|
||||
}
|
||||
// check the scheme, if the scheme is not npipe or unix
|
||||
// set cfg.Container.DockerHost to "-" because it can't be mounted to the job container
|
||||
if protoIndex := strings.Index(cfg.Container.DockerHost, "://"); protoIndex != -1 {
|
||||
scheme := cfg.Container.DockerHost[:protoIndex]
|
||||
if !strings.EqualFold(scheme, "npipe") && !strings.EqualFold(scheme, "unix") {
|
||||
cfg.Container.DockerHost = "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Equal(reg.Labels, ls.ToStrings()) {
|
||||
reg.Labels = ls.ToStrings()
|
||||
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
|
||||
return fmt.Errorf("failed to save runner config: %w", err)
|
||||
}
|
||||
log.Infof("labels updated to: %v", reg.Labels)
|
||||
}
|
||||
|
||||
cli := client.New(
|
||||
@@ -68,31 +149,232 @@ func runDaemon(ctx context.Context, configFile *string) func(cmd *cobra.Command,
|
||||
)
|
||||
|
||||
runner := run.NewRunner(cfg, reg, cli)
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
|
||||
poller.Poll(ctx)
|
||||
// Detect runner capabilities for AI-friendly workflow generation
|
||||
dockerHost := cfg.Container.DockerHost
|
||||
if dockerHost == "" {
|
||||
if dh, err := getDockerSocketPath(""); err == nil {
|
||||
dockerHost = dh
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize bandwidth manager with the Gitea server address
|
||||
bandwidthManager = envcheck.NewBandwidthManager(reg.Address, BandwidthTestInterval)
|
||||
bandwidthManager.Start(ctx)
|
||||
log.Infof("bandwidth manager started, testing against: %s", reg.Address)
|
||||
|
||||
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, cfg.Container.WorkdirParent)
|
||||
// Include initial bandwidth result if available
|
||||
capabilities.Bandwidth = bandwidthManager.GetLastResult()
|
||||
capabilitiesJson := capabilities.ToJSON()
|
||||
log.Infof("detected capabilities: %s", capabilitiesJson)
|
||||
|
||||
// Check disk space and warn if low
|
||||
checkDiskSpaceWarnings(capabilities)
|
||||
|
||||
// declare the labels of the runner before fetching tasks
|
||||
resp, err := runner.Declare(ctx, ls.Names(), capabilitiesJson)
|
||||
if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented {
|
||||
log.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later")
|
||||
return err
|
||||
} else if err != nil {
|
||||
log.WithError(err).Error("fail to invoke Declare")
|
||||
return err
|
||||
} else {
|
||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
||||
}
|
||||
|
||||
// Start periodic capabilities update goroutine
|
||||
go periodicCapabilitiesUpdate(ctx, runner, ls.Names(), dockerHost, cfg.Container.WorkdirParent)
|
||||
// Start periodic stale job cache cleanup (every hour, remove caches older than 2 hours)
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runner.CleanStaleJobCaches(2 * time.Hour)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
poller.SetBandwidthManager(bandwidthManager)
|
||||
|
||||
if daemArgs.Once || reg.Ephemeral {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
poller.PollOnce()
|
||||
}()
|
||||
|
||||
// shutdown when we complete a job or cancel is requested
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
} else {
|
||||
go poller.Poll()
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.Runner.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = poller.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// initLogging setup the global logrus logger.
|
||||
func initLogging(cfg *config.Config) {
|
||||
isTerm := isatty.IsTerminal(os.Stdout.Fd())
|
||||
log.SetFormatter(&log.TextFormatter{
|
||||
DisableColors: !isTerm,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
// checkDiskSpaceWarnings logs warnings if disk space is low
|
||||
func checkDiskSpaceWarnings(capabilities *envcheck.RunnerCapabilities) {
|
||||
if capabilities.Disk == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if l := cfg.Log.Level; l != "" {
|
||||
level, err := log.ParseLevel(l)
|
||||
if err != nil {
|
||||
log.WithError(err).
|
||||
Errorf("invalid log level: %q", l)
|
||||
}
|
||||
if log.GetLevel() != level {
|
||||
log.Infof("log level changed to %v", level)
|
||||
log.SetLevel(level)
|
||||
usedPercent := capabilities.Disk.UsedPercent
|
||||
freeGB := float64(capabilities.Disk.Free) / (1024 * 1024 * 1024)
|
||||
|
||||
if usedPercent >= DiskSpaceCriticalThreshold {
|
||||
log.Errorf("CRITICAL: Disk space critically low! %.1f%% used, only %.2f GB free. Runner may fail to execute jobs!", usedPercent, freeGB)
|
||||
} else if usedPercent >= DiskSpaceWarningThreshold {
|
||||
log.Warnf("WARNING: Disk space running low. %.1f%% used, %.2f GB free. Consider cleaning up disk space.", usedPercent, freeGB)
|
||||
}
|
||||
}
|
||||
|
||||
// periodicCapabilitiesUpdate periodically updates capabilities including disk space and bandwidth
|
||||
func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNames []string, dockerHost string, workingDir string) {
|
||||
ticker := time.NewTicker(CapabilitiesUpdateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug("stopping periodic capabilities update")
|
||||
if bandwidthManager != nil {
|
||||
bandwidthManager.Stop()
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Detect updated capabilities (disk space changes over time)
|
||||
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, workingDir)
|
||||
|
||||
// Include latest bandwidth result
|
||||
if bandwidthManager != nil {
|
||||
capabilities.Bandwidth = bandwidthManager.GetLastResult()
|
||||
}
|
||||
|
||||
capabilitiesJson := capabilities.ToJSON()
|
||||
|
||||
// Check for disk space warnings
|
||||
checkDiskSpaceWarnings(capabilities)
|
||||
|
||||
// Send updated capabilities to server
|
||||
_, err := runner.Declare(ctx, labelNames, capabilitiesJson)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("failed to update capabilities")
|
||||
} else {
|
||||
bandwidthInfo := ""
|
||||
if capabilities.Bandwidth != nil {
|
||||
bandwidthInfo = fmt.Sprintf(", bandwidth: %.1f Mbps", capabilities.Bandwidth.DownloadMbps)
|
||||
}
|
||||
log.Debugf("capabilities updated: disk %.1f%% used, %.2f GB free%s",
|
||||
capabilities.Disk.UsedPercent,
|
||||
float64(capabilities.Disk.Free)/(1024*1024*1024),
|
||||
bandwidthInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type daemonArgs struct {
|
||||
Once bool
|
||||
}
|
||||
|
||||
// initLogging setup the global logrus logger.
|
||||
func initLogging(cfg *config.Config) {
|
||||
callPrettyfier := func(f *runtime.Frame) (string, string) {
|
||||
// get function name
|
||||
s := strings.Split(f.Function, ".")
|
||||
funcname := "[" + s[len(s)-1] + "]"
|
||||
// get file name and line number
|
||||
_, filename := path.Split(f.File)
|
||||
filename = "[" + filename + ":" + strconv.Itoa(f.Line) + "]"
|
||||
return funcname, filename
|
||||
}
|
||||
|
||||
isTerm := isatty.IsTerminal(os.Stdout.Fd())
|
||||
format := &log.TextFormatter{
|
||||
DisableColors: !isTerm,
|
||||
FullTimestamp: true,
|
||||
CallerPrettyfier: callPrettyfier,
|
||||
}
|
||||
log.SetFormatter(format)
|
||||
|
||||
l := cfg.Log.Level
|
||||
if l == "" {
|
||||
log.Infof("Log level not set, sticking to info")
|
||||
return
|
||||
}
|
||||
|
||||
level, err := log.ParseLevel(l)
|
||||
if err != nil {
|
||||
log.WithError(err).
|
||||
Errorf("invalid log level: %q", l)
|
||||
}
|
||||
|
||||
// debug level
|
||||
switch level {
|
||||
case log.DebugLevel, log.TraceLevel:
|
||||
log.SetReportCaller(true) // Only in debug or trace because it takes a performance toll
|
||||
log.Infof("Log level %s requested, setting up report caller for further debugging", level)
|
||||
}
|
||||
|
||||
if log.GetLevel() != level {
|
||||
log.Infof("log level set to %v", level)
|
||||
log.SetLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
var commonSocketPaths = []string{
|
||||
"/var/run/docker.sock",
|
||||
"/run/podman/podman.sock",
|
||||
"$HOME/.colima/docker.sock",
|
||||
"$XDG_RUNTIME_DIR/docker.sock",
|
||||
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
||||
`\\.\pipe\docker_engine`,
|
||||
"$HOME/.docker/run/docker.sock",
|
||||
}
|
||||
|
||||
func getDockerSocketPath(configDockerHost string) (string, error) {
|
||||
// a `-` means don't mount the docker socket to job containers
|
||||
if configDockerHost != "" && configDockerHost != "-" {
|
||||
return configDockerHost, nil
|
||||
}
|
||||
|
||||
socket, found := os.LookupEnv("DOCKER_HOST")
|
||||
if found {
|
||||
return socket, nil
|
||||
}
|
||||
|
||||
for _, p := range commonSocketPaths {
|
||||
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
||||
if strings.HasPrefix(p, `\\.\`) {
|
||||
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), nil
|
||||
}
|
||||
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("daemon Docker Engine socket not found and docker_host config was invalid")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/nektos/act/pkg/artifactcache"
|
||||
"github.com/nektos/act/pkg/artifacts"
|
||||
"github.com/nektos/act/pkg/common"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
@@ -21,8 +23,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/artifactcache"
|
||||
)
|
||||
|
||||
type executeArgs struct {
|
||||
@@ -39,7 +39,8 @@ type executeArgs struct {
|
||||
envs []string
|
||||
envfile string
|
||||
secrets []string
|
||||
defaultActionsUrl string
|
||||
vars []string
|
||||
defaultActionsURL string
|
||||
insecureSecrets bool
|
||||
privileged bool
|
||||
usernsMode string
|
||||
@@ -57,6 +58,8 @@ type executeArgs struct {
|
||||
dryrun bool
|
||||
image string
|
||||
cacheHandler *artifactcache.Handler
|
||||
network string
|
||||
githubInstance string
|
||||
}
|
||||
|
||||
// WorkflowsPath returns path to workflow file(s)
|
||||
@@ -128,6 +131,22 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
return envs
|
||||
}
|
||||
|
||||
func (i *executeArgs) LoadVars() map[string]string {
|
||||
vars := make(map[string]string)
|
||||
if i.vars != nil {
|
||||
for _, runVar := range i.vars {
|
||||
e := strings.SplitN(runVar, `=`, 2)
|
||||
if len(e) == 2 {
|
||||
vars[e[0]] = e[1]
|
||||
} else {
|
||||
vars[e[0]] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// Workdir returns path to workdir
|
||||
func (i *executeArgs) Workdir() string {
|
||||
return i.resolve(".")
|
||||
@@ -250,7 +269,7 @@ func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *e
|
||||
var filterPlan *model.Plan
|
||||
|
||||
// Determine the event name to be filtered
|
||||
var filterEventName string = ""
|
||||
var filterEventName string
|
||||
|
||||
if len(execArgs.event) > 0 {
|
||||
log.Infof("Using chosed event for filtering: %s", execArgs.event)
|
||||
@@ -287,7 +306,7 @@ func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *e
|
||||
}
|
||||
}
|
||||
|
||||
printList(filterPlan)
|
||||
_ = printList(filterPlan)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -314,7 +333,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
|
||||
if len(execArgs.event) > 0 {
|
||||
log.Infof("Using chosed event for filtering: %s", execArgs.event)
|
||||
eventName = args[0]
|
||||
eventName = execArgs.event
|
||||
} else if len(events) == 1 && len(events[0]) > 0 {
|
||||
log.Infof("Using the only detected workflow event: %s", events[0])
|
||||
eventName = events[0]
|
||||
@@ -349,13 +368,31 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
// init a cache server
|
||||
handler, err := artifactcache.StartHandler("", "", 0)
|
||||
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("cache handler listens on: %v", handler.ExternalURL())
|
||||
execArgs.cacheHandler = handler
|
||||
|
||||
if len(execArgs.artifactServerAddr) == 0 {
|
||||
ip := common.GetOutboundIP()
|
||||
if ip == nil {
|
||||
return fmt.Errorf("unable to determine outbound IP address")
|
||||
}
|
||||
execArgs.artifactServerAddr = ip.String()
|
||||
}
|
||||
|
||||
if len(execArgs.artifactServerPath) == 0 {
|
||||
tempDir, err := os.MkdirTemp("", "gitea-act-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
execArgs.artifactServerPath = tempDir
|
||||
}
|
||||
|
||||
// run the plan
|
||||
config := &runner.Config{
|
||||
Workdir: execArgs.Workdir(),
|
||||
@@ -366,6 +403,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
LogOutput: true,
|
||||
JSONLogger: execArgs.jsonLogger,
|
||||
Env: execArgs.LoadEnvs(),
|
||||
Vars: execArgs.LoadVars(),
|
||||
Secrets: execArgs.LoadSecrets(),
|
||||
InsecureSecrets: execArgs.insecureSecrets,
|
||||
Privileged: execArgs.privileged,
|
||||
@@ -373,47 +411,45 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
ContainerArchitecture: execArgs.containerArchitecture,
|
||||
ContainerDaemonSocket: execArgs.containerDaemonSocket,
|
||||
UseGitIgnore: execArgs.useGitIgnore,
|
||||
// GitHubInstance: t.client.Address(),
|
||||
ContainerCapAdd: execArgs.containerCapAdd,
|
||||
ContainerCapDrop: execArgs.containerCapDrop,
|
||||
ContainerOptions: execArgs.containerOptions,
|
||||
AutoRemove: true,
|
||||
ArtifactServerPath: execArgs.artifactServerPath,
|
||||
ArtifactServerPort: execArgs.artifactServerPort,
|
||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||
GitHubInstance: execArgs.githubInstance,
|
||||
ContainerCapAdd: execArgs.containerCapAdd,
|
||||
ContainerCapDrop: execArgs.containerCapDrop,
|
||||
ContainerOptions: execArgs.containerOptions,
|
||||
AutoRemove: true,
|
||||
ArtifactServerPath: execArgs.artifactServerPath,
|
||||
ArtifactServerPort: execArgs.artifactServerPort,
|
||||
ArtifactServerAddr: execArgs.artifactServerAddr,
|
||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||
// PresetGitHubContext: preset,
|
||||
// EventJSON: string(eventJSON),
|
||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%s", eventName),
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: "bridge",
|
||||
DefaultActionInstance: execArgs.defaultActionsUrl,
|
||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return execArgs.image
|
||||
},
|
||||
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
|
||||
}
|
||||
|
||||
// TODO: handle log level config
|
||||
// waiting https://gitea.com/gitea/act/pulls/19
|
||||
// if !execArgs.debug {
|
||||
// logLevel := log.Level(log.InfoLevel)
|
||||
// config.JobLoggerLevel = &logLevel
|
||||
// }
|
||||
config.Env["ACT_EXEC"] = "true"
|
||||
|
||||
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
|
||||
config.Token = t
|
||||
} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
|
||||
config.Token = t
|
||||
}
|
||||
|
||||
if !execArgs.debug {
|
||||
logLevel := log.InfoLevel
|
||||
config.JobLoggerLevel = &logLevel
|
||||
}
|
||||
|
||||
r, err := runner.New(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(execArgs.artifactServerPath) == 0 {
|
||||
tempDir, err := os.MkdirTemp("", "gitea-act-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
execArgs.artifactServerPath = tempDir
|
||||
}
|
||||
|
||||
artifactCancel := artifacts.Serve(ctx, execArgs.artifactServerPath, execArgs.artifactServerAddr, execArgs.artifactServerPort)
|
||||
log.Debugf("artifacts server started at %s:%s", execArgs.artifactServerPath, execArgs.artifactServerPort)
|
||||
|
||||
@@ -450,6 +486,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
||||
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
|
||||
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
|
||||
execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.insecureSecrets, "insecure-secrets", "", false, "NOT RECOMMENDED! Doesn't hide secrets while printing logs.")
|
||||
execCmd.Flags().BoolVar(&execArg.privileged, "privileged", false, "use privileged mode")
|
||||
execCmd.Flags().StringVar(&execArg.usernsMode, "userns", "", "user namespace to use")
|
||||
@@ -460,12 +497,15 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
||||
execCmd.Flags().StringArrayVarP(&execArg.containerCapDrop, "container-cap-drop", "", []string{}, "kernel capabilities to remove from the workflow containers (e.g. --container-cap-drop SYS_PTRACE)")
|
||||
execCmd.Flags().StringVarP(&execArg.containerOptions, "container-opts", "", "", "container options")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPath, "artifact-server-path", "", ".", "Defines the path where the artifact server stores uploads and retrieves downloads from. If not specified the artifact server will not start.")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerAddr, "artifact-server-addr", "", "", "Defines the address where the artifact server listens")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPort, "artifact-server-port", "", "34567", "Defines the port where the artifact server listens (will only bind to localhost).")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsUrl, "default-actions-url", "", "https://gitea.com", "Defines the default url of action instance.")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsURL, "default-actions-url", "", "https://github.com", "Defines the default url of action instance.")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:16-bullseye", "docker image to use")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "docker.gitea.com/runner-images:ubuntu-latest", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
|
||||
|
||||
return execCmd
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -47,12 +47,12 @@ func runRegister(ctx context.Context, regArgs *registerArgs, configFile *string)
|
||||
}
|
||||
|
||||
if regArgs.NoInteractive {
|
||||
if err := registerNoInteractive(*configFile, regArgs); err != nil {
|
||||
if err := registerNoInteractive(ctx, *configFile, regArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
go func() {
|
||||
if err := registerInteractive(*configFile); err != nil {
|
||||
if err := registerInteractive(ctx, *configFile, regArgs); err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
@@ -75,6 +75,7 @@ type registerArgs struct {
|
||||
Token string
|
||||
RunnerName string
|
||||
Labels string
|
||||
Ephemeral bool
|
||||
}
|
||||
|
||||
type registerStage int8
|
||||
@@ -85,23 +86,23 @@ const (
|
||||
StageInputInstance
|
||||
StageInputToken
|
||||
StageInputRunnerName
|
||||
StageInputCustomLabels
|
||||
StageInputLabels
|
||||
StageWaitingForRegistration
|
||||
StageExit
|
||||
)
|
||||
|
||||
var defaultLabels = []string{
|
||||
"ubuntu-latest:docker://node:16-bullseye",
|
||||
"ubuntu-22.04:docker://node:16-bullseye", // There's no node:16-bookworm yet
|
||||
"ubuntu-20.04:docker://node:16-bullseye",
|
||||
"ubuntu-18.04:docker://node:16-buster",
|
||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
||||
}
|
||||
|
||||
type registerInputs struct {
|
||||
InstanceAddr string
|
||||
Token string
|
||||
RunnerName string
|
||||
CustomLabels []string
|
||||
Labels []string
|
||||
Ephemeral bool
|
||||
}
|
||||
|
||||
func (r *registerInputs) validate() error {
|
||||
@@ -111,8 +112,8 @@ func (r *registerInputs) validate() error {
|
||||
if r.Token == "" {
|
||||
return fmt.Errorf("token is empty")
|
||||
}
|
||||
if len(r.CustomLabels) > 0 {
|
||||
return validateLabels(r.CustomLabels)
|
||||
if len(r.Labels) > 0 {
|
||||
return validateLabels(r.Labels)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -126,7 +127,23 @@ func validateLabels(ls []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *registerInputs) assignToNext(stage registerStage, value string) registerStage {
|
||||
func (r *registerInputs) stageValue(stage registerStage) string {
|
||||
switch stage {
|
||||
case StageInputInstance:
|
||||
return r.InstanceAddr
|
||||
case StageInputToken:
|
||||
return r.Token
|
||||
case StageInputRunnerName:
|
||||
return r.RunnerName
|
||||
case StageInputLabels:
|
||||
if len(r.Labels) > 0 {
|
||||
return strings.Join(r.Labels, ",")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *config.Config) registerStage {
|
||||
// must set instance address and token.
|
||||
// if empty, keep current stage.
|
||||
if stage == StageInputInstance || stage == StageInputToken {
|
||||
@@ -154,27 +171,59 @@ func (r *registerInputs) assignToNext(stage registerStage, value string) registe
|
||||
return StageInputRunnerName
|
||||
case StageInputRunnerName:
|
||||
r.RunnerName = value
|
||||
return StageInputCustomLabels
|
||||
case StageInputCustomLabels:
|
||||
r.CustomLabels = defaultLabels
|
||||
// if there are some labels configured in config file, skip input labels stage
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
ls := make([]string, 0, len(cfg.Runner.Labels))
|
||||
for _, l := range cfg.Runner.Labels {
|
||||
_, err := labels.Parse(l)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("ignored invalid label %q", l)
|
||||
continue
|
||||
}
|
||||
ls = append(ls, l)
|
||||
}
|
||||
if len(ls) == 0 {
|
||||
log.Warn("no valid labels configured in config file, runner may not be able to pick up jobs")
|
||||
}
|
||||
r.Labels = ls
|
||||
return StageWaitingForRegistration
|
||||
}
|
||||
return StageInputLabels
|
||||
case StageInputLabels:
|
||||
r.Labels = defaultLabels
|
||||
if value != "" {
|
||||
r.CustomLabels = strings.Split(value, ",")
|
||||
r.Labels = strings.Split(value, ",")
|
||||
}
|
||||
|
||||
if validateLabels(r.CustomLabels) != nil {
|
||||
log.Infoln("Invalid labels, please input again, leave blank to use the default labels (for example, ubuntu-20.04:docker://node:16-bullseye,ubuntu-18.04:docker://node:16-buster,linux_arm:host)")
|
||||
return StageInputCustomLabels
|
||||
if validateLabels(r.Labels) != nil {
|
||||
log.Infoln("Invalid labels, please input again, leave blank to use the default labels (for example, ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest)")
|
||||
r.Labels = nil
|
||||
return StageInputLabels
|
||||
}
|
||||
return StageWaitingForRegistration
|
||||
}
|
||||
return StageUnknown
|
||||
}
|
||||
|
||||
func registerInteractive(configFile string) error {
|
||||
func initInputs(regArgs *registerArgs) *registerInputs {
|
||||
inputs := ®isterInputs{
|
||||
InstanceAddr: regArgs.InstanceAddr,
|
||||
Token: regArgs.Token,
|
||||
RunnerName: regArgs.RunnerName,
|
||||
Ephemeral: regArgs.Ephemeral,
|
||||
}
|
||||
regArgs.Labels = strings.TrimSpace(regArgs.Labels)
|
||||
// command line flag.
|
||||
if regArgs.Labels != "" {
|
||||
inputs.Labels = strings.Split(regArgs.Labels, ",")
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
|
||||
var (
|
||||
reader = bufio.NewReader(os.Stdin)
|
||||
stage = StageInputInstance
|
||||
inputs = new(registerInputs)
|
||||
)
|
||||
|
||||
cfg, err := config.LoadDefault(configFile)
|
||||
@@ -184,23 +233,26 @@ func registerInteractive(configFile string) error {
|
||||
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
|
||||
stage = StageOverwriteLocalConfig
|
||||
}
|
||||
inputs := initInputs(regArgs)
|
||||
|
||||
for {
|
||||
printStageHelp(stage)
|
||||
|
||||
cmdString, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
cmdString := inputs.stageValue(stage)
|
||||
if cmdString == "" {
|
||||
printStageHelp(stage)
|
||||
var err error
|
||||
cmdString, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stage = inputs.assignToNext(stage, strings.TrimSpace(cmdString))
|
||||
stage = inputs.assignToNext(stage, strings.TrimSpace(cmdString), cfg)
|
||||
|
||||
if stage == StageWaitingForRegistration {
|
||||
log.Infof("Registering runner, name=%s, instance=%s, labels=%v.", inputs.RunnerName, inputs.InstanceAddr, inputs.CustomLabels)
|
||||
if err := doRegister(cfg, inputs); err != nil {
|
||||
log.Errorf("Failed to register runner: %v", err)
|
||||
} else {
|
||||
log.Infof("Runner registered successfully.")
|
||||
log.Infof("Registering runner, name=%s, instance=%s, labels=%v.", inputs.RunnerName, inputs.InstanceAddr, inputs.Labels)
|
||||
if err := doRegister(ctx, cfg, inputs); err != nil {
|
||||
return fmt.Errorf("Failed to register runner: %w", err)
|
||||
}
|
||||
log.Infof("Runner registered successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,47 +278,46 @@ func printStageHelp(stage registerStage) {
|
||||
case StageInputRunnerName:
|
||||
hostname, _ := os.Hostname()
|
||||
log.Infof("Enter the runner name (if set empty, use hostname: %s):\n", hostname)
|
||||
case StageInputCustomLabels:
|
||||
log.Infoln("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-20.04:docker://node:16-bullseye,ubuntu-18.04:docker://node:16-buster,linux_arm:host):")
|
||||
case StageInputLabels:
|
||||
log.Infoln("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest):")
|
||||
case StageWaitingForRegistration:
|
||||
log.Infoln("Waiting for registration...")
|
||||
}
|
||||
}
|
||||
|
||||
func registerNoInteractive(configFile string, regArgs *registerArgs) error {
|
||||
func registerNoInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
|
||||
cfg, err := config.LoadDefault(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs := ®isterInputs{
|
||||
InstanceAddr: regArgs.InstanceAddr,
|
||||
Token: regArgs.Token,
|
||||
RunnerName: regArgs.RunnerName,
|
||||
CustomLabels: defaultLabels,
|
||||
inputs := initInputs(regArgs)
|
||||
// specify labels in config file.
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
if regArgs.Labels != "" {
|
||||
log.Warn("Labels from command will be ignored, use labels defined in config file.")
|
||||
}
|
||||
inputs.Labels = cfg.Runner.Labels
|
||||
}
|
||||
regArgs.Labels = strings.TrimSpace(regArgs.Labels)
|
||||
if regArgs.Labels != "" {
|
||||
inputs.CustomLabels = strings.Split(regArgs.Labels, ",")
|
||||
if len(inputs.Labels) == 0 {
|
||||
inputs.Labels = defaultLabels
|
||||
}
|
||||
|
||||
if inputs.RunnerName == "" {
|
||||
inputs.RunnerName, _ = os.Hostname()
|
||||
log.Infof("Runner name is empty, use hostname '%s'.", inputs.RunnerName)
|
||||
}
|
||||
if err := inputs.validate(); err != nil {
|
||||
log.WithError(err).Errorf("Invalid input, please re-run act command.")
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
if err := doRegister(cfg, inputs); err != nil {
|
||||
log.Errorf("Failed to register runner: %v", err)
|
||||
return nil
|
||||
if err := doRegister(ctx, cfg, inputs); err != nil {
|
||||
return fmt.Errorf("Failed to register runner: %w", err)
|
||||
}
|
||||
log.Infof("Runner registered successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doRegister(cfg *config.Config, inputs *registerInputs) error {
|
||||
ctx := context.Background()
|
||||
|
||||
func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) error {
|
||||
// initial http client
|
||||
cli := client.New(
|
||||
inputs.InstanceAddr,
|
||||
@@ -282,7 +333,7 @@ func doRegister(cfg *config.Config, inputs *registerInputs) error {
|
||||
}))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@@ -300,10 +351,11 @@ func doRegister(cfg *config.Config, inputs *registerInputs) error {
|
||||
}
|
||||
|
||||
reg := &config.Registration{
|
||||
Name: inputs.RunnerName,
|
||||
Token: inputs.Token,
|
||||
Address: inputs.InstanceAddr,
|
||||
Labels: inputs.CustomLabels,
|
||||
Name: inputs.RunnerName,
|
||||
Token: inputs.Token,
|
||||
Address: inputs.InstanceAddr,
|
||||
Labels: inputs.Labels,
|
||||
Ephemeral: inputs.Ephemeral,
|
||||
}
|
||||
|
||||
ls := make([]string, len(reg.Labels))
|
||||
@@ -315,7 +367,10 @@ func doRegister(cfg *config.Config, inputs *registerInputs) error {
|
||||
resp, err := cli.Register(ctx, connect.NewRequest(&runnerv1.RegisterRequest{
|
||||
Name: reg.Name,
|
||||
Token: reg.Token,
|
||||
AgentLabels: ls,
|
||||
Version: ver.Version(),
|
||||
AgentLabels: ls, // Could be removed after Gitea 1.20
|
||||
Labels: ls,
|
||||
Ephemeral: reg.Ephemeral,
|
||||
}))
|
||||
if err != nil {
|
||||
log.WithError(err).Error("poller: cannot register new runner")
|
||||
@@ -327,6 +382,11 @@ func doRegister(cfg *config.Config, inputs *registerInputs) error {
|
||||
reg.Name = resp.Msg.Runner.Name
|
||||
reg.Token = resp.Msg.Runner.Token
|
||||
|
||||
if inputs.Ephemeral != resp.Msg.Runner.Ephemeral {
|
||||
// TODO we cannot remove the configuration via runner api, if we return an error here we just fill the database
|
||||
log.Error("poller: cannot register new runner as ephemeral upgrade Gitea to gain security, run-once will be used automatically")
|
||||
}
|
||||
|
||||
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
|
||||
return fmt.Errorf("failed to save runner config: %w", err)
|
||||
}
|
||||
|
||||
19
internal/app/cmd/register_test.go
Normal file
19
internal/app/cmd/register_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
|
||||
err := registerNoInteractive(t.Context(), "", ®isterArgs{
|
||||
Labels: "label:invalid",
|
||||
Token: "token",
|
||||
InstanceAddr: "http://localhost:3000",
|
||||
})
|
||||
assert.Error(t, err, "unsupported schema: invalid")
|
||||
}
|
||||
@@ -6,58 +6,157 @@ package poll
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"connectrpc.com/connect"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/run"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/envcheck"
|
||||
)
|
||||
|
||||
type Poller struct {
|
||||
client client.Client
|
||||
runner *run.Runner
|
||||
cfg *config.Config
|
||||
client client.Client
|
||||
runner *run.Runner
|
||||
cfg *config.Config
|
||||
tasksVersion atomic.Int64 // tasksVersion used to store the version of the last task fetched from the Gitea.
|
||||
bandwidthManager *envcheck.BandwidthManager
|
||||
|
||||
pollingCtx context.Context
|
||||
shutdownPolling context.CancelFunc
|
||||
|
||||
jobsCtx context.Context
|
||||
shutdownJobs context.CancelFunc
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
|
||||
pollingCtx, shutdownPolling := context.WithCancel(context.Background())
|
||||
|
||||
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
return &Poller{
|
||||
client: client,
|
||||
runner: runner,
|
||||
cfg: cfg,
|
||||
|
||||
pollingCtx: pollingCtx,
|
||||
shutdownPolling: shutdownPolling,
|
||||
|
||||
jobsCtx: jobsCtx,
|
||||
shutdownJobs: shutdownJobs,
|
||||
|
||||
done: done,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) Poll(ctx context.Context) {
|
||||
// SetBandwidthManager sets the bandwidth manager for on-demand testing
|
||||
func (p *Poller) SetBandwidthManager(bm *envcheck.BandwidthManager) {
|
||||
p.bandwidthManager = bm
|
||||
}
|
||||
|
||||
func (p *Poller) Poll() {
|
||||
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
|
||||
wg := &sync.WaitGroup{}
|
||||
for i := 0; i < p.cfg.Runner.Capacity; i++ {
|
||||
wg.Add(1)
|
||||
go p.poll(ctx, wg, limiter)
|
||||
go p.poll(wg, limiter)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// signal that we shutdown
|
||||
close(p.done)
|
||||
}
|
||||
|
||||
func (p *Poller) poll(ctx context.Context, wg *sync.WaitGroup, limiter *rate.Limiter) {
|
||||
func (p *Poller) PollOnce() {
|
||||
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
|
||||
|
||||
p.pollOnce(limiter)
|
||||
|
||||
// signal that we're done
|
||||
close(p.done)
|
||||
}
|
||||
|
||||
func (p *Poller) Shutdown(ctx context.Context) error {
|
||||
p.shutdownPolling()
|
||||
|
||||
select {
|
||||
// graceful shutdown completed succesfully
|
||||
case <-p.done:
|
||||
return nil
|
||||
|
||||
// our timeout for shutting down ran out
|
||||
case <-ctx.Done():
|
||||
// when both the timeout fires and the graceful shutdown
|
||||
// completed succsfully, this branch of the select may
|
||||
// fire. Do a non-blocking check here against the graceful
|
||||
// shutdown status to avoid sending an error if we don't need to.
|
||||
_, ok := <-p.done
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// force a shutdown of all running jobs
|
||||
p.shutdownJobs()
|
||||
|
||||
// wait for running jobs to report their status to Gitea
|
||||
_, _ = <-p.done
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) poll(wg *sync.WaitGroup, limiter *rate.Limiter) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
if err := limiter.Wait(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
p.pollOnce(limiter)
|
||||
|
||||
select {
|
||||
case <-p.pollingCtx.Done():
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) pollOnce(limiter *rate.Limiter) {
|
||||
for {
|
||||
if err := limiter.Wait(p.pollingCtx); err != nil {
|
||||
if p.pollingCtx.Err() != nil {
|
||||
log.WithError(err).Debug("limiter wait failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
task, ok := p.fetchTask(ctx)
|
||||
task, ok := p.fetchTask(p.pollingCtx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := p.runner.Run(ctx, task); err != nil {
|
||||
log.WithError(err).Error("failed to run task")
|
||||
|
||||
p.runTaskWithRecover(p.jobsCtx, task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err := fmt.Errorf("panic: %v", r)
|
||||
log.WithError(err).Error("panic in runTaskWithRecover")
|
||||
}
|
||||
}()
|
||||
|
||||
if err := p.runner.Run(ctx, task); err != nil {
|
||||
log.WithError(err).Error("failed to run task")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +164,23 @@ func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.client.FetchTask(reqCtx, connect.NewRequest(&runnerv1.FetchTaskRequest{}))
|
||||
// Detect capabilities including current disk space
|
||||
caps := envcheck.DetectCapabilities(ctx, p.cfg.Container.DockerHost, p.cfg.Container.WorkdirParent)
|
||||
|
||||
// Include latest bandwidth result if available
|
||||
if p.bandwidthManager != nil {
|
||||
caps.Bandwidth = p.bandwidthManager.GetLastResult()
|
||||
}
|
||||
|
||||
capsJson := caps.ToJSON()
|
||||
|
||||
// Load the version value that was in the cache when the request was sent.
|
||||
v := p.tasksVersion.Load()
|
||||
fetchReq := &runnerv1.FetchTaskRequest{
|
||||
TasksVersion: v,
|
||||
CapabilitiesJson: capsJson,
|
||||
}
|
||||
resp, err := p.client.FetchTask(reqCtx, connect.NewRequest(fetchReq))
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
err = nil
|
||||
}
|
||||
@@ -74,8 +189,32 @@ func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if resp == nil || resp.Msg == nil || resp.Msg.Task == nil {
|
||||
if resp == nil || resp.Msg == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if server requested a bandwidth test
|
||||
if resp.Msg.RequestBandwidthTest && p.bandwidthManager != nil {
|
||||
log.Info("Server requested bandwidth test, running now...")
|
||||
go func() {
|
||||
result := p.bandwidthManager.RunTest(ctx)
|
||||
if result != nil {
|
||||
log.Infof("Bandwidth test completed: %.1f Mbps download, %.0f ms latency",
|
||||
result.DownloadMbps, result.Latency)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if resp.Msg.TasksVersion > v {
|
||||
p.tasksVersion.CompareAndSwap(v, resp.Msg.TasksVersion)
|
||||
}
|
||||
|
||||
if resp.Msg.Task == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// got a task, set tasksVersion to zero to force query db in next request.
|
||||
p.tasksVersion.CompareAndSwap(resp.Msg.TasksVersion, 0)
|
||||
|
||||
return resp.Msg.Task, true
|
||||
}
|
||||
|
||||
24
internal/app/run/logging.go
Normal file
24
internal/app/run/logging.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// NullLogger is used to create a new JobLogger to discard logs. This
|
||||
// will prevent these logs from being logged to the stdout, but
|
||||
// forward them to the Reporter via its hook.
|
||||
type NullLogger struct{}
|
||||
|
||||
// WithJobLogger creates a new logrus.Logger that will discard all logs.
|
||||
func (n NullLogger) WithJobLogger() *log.Logger {
|
||||
logger := log.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
logger.SetLevel(log.TraceLevel)
|
||||
|
||||
return logger
|
||||
}
|
||||
@@ -4,22 +4,24 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/nektos/act/pkg/artifactcache"
|
||||
"github.com/nektos/act/pkg/common"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
"github.com/nektos/act/pkg/runner"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/artifactcache"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/labels"
|
||||
@@ -40,6 +42,48 @@ type Runner struct {
|
||||
runningTasks sync.Map
|
||||
}
|
||||
|
||||
// getJobCacheDir returns a job-isolated cache directory
|
||||
func (r *Runner) getJobCacheDir(taskID int64) string {
|
||||
return filepath.Join(r.cfg.Host.WorkdirParent, "jobs", fmt.Sprintf("%d", taskID))
|
||||
}
|
||||
|
||||
// cleanupJobCache removes the job-specific cache directory after completion
|
||||
func (r *Runner) cleanupJobCache(taskID int64) {
|
||||
jobCacheDir := r.getJobCacheDir(taskID)
|
||||
if err := os.RemoveAll(jobCacheDir); err != nil {
|
||||
log.Warnf("failed to cleanup job cache %s: %v", jobCacheDir, err)
|
||||
} else {
|
||||
log.Infof("cleaned up job cache: %s", jobCacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanStaleJobCaches removes job cache directories older than maxAge
|
||||
func (r *Runner) CleanStaleJobCaches(maxAge time.Duration) {
|
||||
jobsDir := filepath.Join(r.cfg.Host.WorkdirParent, "jobs")
|
||||
entries, err := os.ReadDir(jobsDir)
|
||||
if err != nil {
|
||||
return // directory may not exist yet
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
jobPath := filepath.Join(jobsDir, entry.Name())
|
||||
if err := os.RemoveAll(jobPath); err != nil {
|
||||
log.Warnf("failed to remove stale job cache %s: %v", jobPath, err)
|
||||
} else {
|
||||
log.Infof("evicted stale job cache: %s", jobPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
|
||||
ls := labels.Labels{}
|
||||
for _, v := range reg.Labels {
|
||||
@@ -52,18 +96,28 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
envs[k] = v
|
||||
}
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
cacheHandler, err := artifactcache.StartHandler(cfg.Cache.Dir, cfg.Cache.Host, cfg.Cache.Port)
|
||||
if err != nil {
|
||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
// go on
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
||||
} else {
|
||||
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/"
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
cfg.Cache.Dir,
|
||||
cfg.Cache.Host,
|
||||
cfg.Cache.Port,
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
// go on
|
||||
} else {
|
||||
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set artifact gitea api
|
||||
artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
|
||||
envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI
|
||||
envs["ACTIONS_RESULTS_URL"] = strings.TrimSuffix(cli.Address(), "/")
|
||||
|
||||
// Set specific environments to distinguish between Gitea and GitHub
|
||||
envs["GITEA_ACTIONS"] = "true"
|
||||
@@ -81,10 +135,10 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||
if _, ok := r.runningTasks.Load(task.Id); ok {
|
||||
return fmt.Errorf("task %d is already running", task.Id)
|
||||
} else {
|
||||
r.runningTasks.Store(task.Id, struct{}{})
|
||||
defer r.runningTasks.Delete(task.Id)
|
||||
}
|
||||
r.runningTasks.Store(task.Id, struct{}{})
|
||||
defer r.runningTasks.Delete(task.Id)
|
||||
defer r.cleanupJobCache(task.Id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout)
|
||||
defer cancel()
|
||||
@@ -103,6 +157,17 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDefaultActionsURL
|
||||
// when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank,
|
||||
// it should be set to GithubMirror first.
|
||||
func (r *Runner) getDefaultActionsURL(ctx context.Context, task *runnerv1.Task) string {
|
||||
giteaDefaultActionsURL := task.Context.Fields["gitea_default_actions_url"].GetStringValue()
|
||||
if giteaDefaultActionsURL == "https://github.com" && r.cfg.Runner.GithubMirror != "" {
|
||||
return r.cfg.Runner.GithubMirror
|
||||
}
|
||||
return giteaDefaultActionsURL
|
||||
}
|
||||
|
||||
func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -112,16 +177,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
|
||||
reporter.Logf("%s(version:%s) received task %v of job %v, be triggered by event: %s", r.name, ver.Version(), task.Id, task.Context.Fields["job"].GetStringValue(), task.Context.Fields["event_name"].GetStringValue())
|
||||
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload))
|
||||
workflow, jobID, err := generateWorkflow(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobIDs := workflow.GetJobIDs()
|
||||
if len(jobIDs) != 1 {
|
||||
return fmt.Errorf("multiple jobs found: %v", jobIDs)
|
||||
}
|
||||
jobID := jobIDs[0]
|
||||
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob(jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -132,7 +192,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
taskContext := task.Context.Fields
|
||||
|
||||
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
||||
taskContext["gitea_default_actions_url"].GetStringValue(),
|
||||
r.getDefaultActionsURL(ctx, task),
|
||||
r.client.Address())
|
||||
|
||||
preset := &model.GithubContext{
|
||||
@@ -158,8 +218,18 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
preset.Token = t
|
||||
}
|
||||
|
||||
// use task token to action api token
|
||||
r.envs["ACTIONS_RUNTIME_TOKEN"] = preset.Token
|
||||
if actionsIdTokenRequestUrl := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIdTokenRequestUrl != "" {
|
||||
r.envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIdTokenRequestUrl
|
||||
r.envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = taskContext["actions_id_token_request_token"].GetStringValue()
|
||||
task.Secrets["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = r.envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
|
||||
}
|
||||
|
||||
giteaRuntimeToken := taskContext["gitea_runtime_token"].GetStringValue()
|
||||
if giteaRuntimeToken == "" {
|
||||
// use task token to action api token for previous Gitea Server Versions
|
||||
giteaRuntimeToken = preset.Token
|
||||
}
|
||||
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
|
||||
|
||||
eventJSON, err := json.Marshal(preset.Event)
|
||||
if err != nil {
|
||||
@@ -171,31 +241,47 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
maxLifetime = time.Until(deadline)
|
||||
}
|
||||
|
||||
// Create job-specific environment with isolated cache directories
|
||||
jobCacheDir := r.getJobCacheDir(task.Id)
|
||||
jobEnvs := make(map[string]string, len(r.envs)+2)
|
||||
for k, v := range r.envs {
|
||||
jobEnvs[k] = v
|
||||
}
|
||||
// Isolate golangci-lint cache to prevent parallel job conflicts
|
||||
jobEnvs["GOLANGCI_LINT_CACHE"] = filepath.Join(jobCacheDir, "golangci-lint")
|
||||
// Set XDG_CACHE_HOME to isolate other tools that respect it
|
||||
jobEnvs["XDG_CACHE_HOME"] = jobCacheDir
|
||||
|
||||
runnerConfig := &runner.Config{
|
||||
// On Linux, Workdir will be like "/<owner>/<repo>"
|
||||
// On Windows, Workdir will be like "\<owner>\<repo>"
|
||||
Workdir: filepath.FromSlash(string(filepath.Separator) + preset.Repository),
|
||||
BindWorkdir: false,
|
||||
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
|
||||
// On Windows, Workdir will be like "\<parent_directory>\<owner>\<repo>"
|
||||
Workdir: filepath.FromSlash(fmt.Sprintf("/%s/%s", strings.TrimLeft(r.cfg.Container.WorkdirParent, "/"), preset.Repository)),
|
||||
BindWorkdir: false,
|
||||
ActionCacheDir: filepath.FromSlash(jobCacheDir),
|
||||
|
||||
ReuseContainers: false,
|
||||
ForcePull: false,
|
||||
ForceRebuild: false,
|
||||
ForcePull: r.cfg.Container.ForcePull,
|
||||
ForceRebuild: r.cfg.Container.ForceRebuild,
|
||||
LogOutput: true,
|
||||
JSONLogger: false,
|
||||
Env: r.envs,
|
||||
Env: jobEnvs,
|
||||
Secrets: task.Secrets,
|
||||
GitHubInstance: r.client.Address(),
|
||||
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
|
||||
AutoRemove: true,
|
||||
NoSkipCheckout: true,
|
||||
PresetGitHubContext: preset,
|
||||
EventJSON: string(eventJSON),
|
||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: r.cfg.Container.NetworkMode,
|
||||
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
|
||||
ContainerOptions: r.cfg.Container.Options,
|
||||
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
||||
Privileged: r.cfg.Container.Privileged,
|
||||
DefaultActionInstance: taskContext["gitea_default_actions_url"].GetStringValue(),
|
||||
DefaultActionInstance: r.getDefaultActionsURL(ctx, task),
|
||||
PlatformPicker: r.labels.PickPlatform,
|
||||
Vars: task.Vars,
|
||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
}
|
||||
|
||||
rr, err := runner.New(runnerConfig)
|
||||
@@ -209,5 +295,19 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
// add logger recorders
|
||||
ctx = common.WithLoggerHook(ctx, reporter)
|
||||
|
||||
return executor(ctx)
|
||||
if !log.IsLevelEnabled(log.DebugLevel) {
|
||||
ctx = runner.WithJobLoggerFactory(ctx, NullLogger{})
|
||||
}
|
||||
|
||||
execErr := executor(ctx)
|
||||
reporter.SetOutputs(job.Outputs)
|
||||
return execErr
|
||||
}
|
||||
|
||||
func (r *Runner) Declare(ctx context.Context, labels []string, capabilitiesJson string) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
|
||||
Version: ver.Version(),
|
||||
Labels: labels,
|
||||
CapabilitiesJson: capabilitiesJson,
|
||||
}))
|
||||
}
|
||||
|
||||
54
internal/app/run/workflow.go
Normal file
54
internal/app/run/workflow.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
jobIDs := workflow.GetJobIDs()
|
||||
if len(jobIDs) != 1 {
|
||||
return nil, "", fmt.Errorf("multiple jobs found: %v", jobIDs)
|
||||
}
|
||||
jobID := jobIDs[0]
|
||||
|
||||
needJobIDs := make([]string, 0, len(task.Needs))
|
||||
for id, need := range task.Needs {
|
||||
needJobIDs = append(needJobIDs, id)
|
||||
needJob := &model.Job{
|
||||
Outputs: need.Outputs,
|
||||
Result: strings.ToLower(strings.TrimPrefix(need.Result.String(), "RESULT_")),
|
||||
}
|
||||
workflow.Jobs[id] = needJob
|
||||
}
|
||||
sort.Strings(needJobIDs)
|
||||
|
||||
rawNeeds := yaml.Node{
|
||||
Kind: yaml.SequenceNode,
|
||||
Content: make([]*yaml.Node, 0, len(needJobIDs)),
|
||||
}
|
||||
for _, id := range needJobIDs {
|
||||
rawNeeds.Content = append(rawNeeds.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: id,
|
||||
})
|
||||
}
|
||||
|
||||
workflow.Jobs[jobID].RawNeeds = rawNeeds
|
||||
|
||||
return workflow, jobID, nil
|
||||
}
|
||||
74
internal/app/run/workflow_test.go
Normal file
74
internal/app/run/workflow_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func Test_generateWorkflow(t *testing.T) {
|
||||
type args struct {
|
||||
task *runnerv1.Task
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
assert func(t *testing.T, wf *model.Workflow)
|
||||
want1 string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "has needs",
|
||||
args: args{
|
||||
task: &runnerv1.Task{
|
||||
WorkflowPayload: []byte(`
|
||||
name: Build and deploy
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
job9:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: ./deploy --build ${{ needs.job1.outputs.output1 }}
|
||||
- run: ./deploy --build ${{ needs.job2.outputs.output2 }}
|
||||
`),
|
||||
Needs: map[string]*runnerv1.TaskNeed{
|
||||
"job1": {
|
||||
Outputs: map[string]string{
|
||||
"output1": "output1 value",
|
||||
},
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
},
|
||||
"job2": {
|
||||
Outputs: map[string]string{
|
||||
"output2": "output2 value",
|
||||
},
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, wf *model.Workflow) {
|
||||
assert.DeepEqual(t, wf.GetJob("job9").Needs(), []string{"job1", "job2"})
|
||||
},
|
||||
want1: "job9",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, got1, err := generateWorkflow(tt.args.task)
|
||||
require.NoError(t, err)
|
||||
tt.assert(t, got)
|
||||
assert.Equal(t, got1, tt.want1)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
// A Client manages communication with the runner.
|
||||
//
|
||||
//go:generate mockery --name Client
|
||||
type Client interface {
|
||||
pingv1connect.PingServiceClient
|
||||
runnerv1connect.RunnerServiceClient
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
package client
|
||||
|
||||
const (
|
||||
UUIDHeader = "x-runner-uuid"
|
||||
TokenHeader = "x-runner-token"
|
||||
UUIDHeader = "x-runner-uuid"
|
||||
TokenHeader = "x-runner-token"
|
||||
// Deprecated: could be removed after Gitea 1.20 released
|
||||
VersionHeader = "x-runner-version"
|
||||
)
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"connectrpc.com/connect"
|
||||
)
|
||||
|
||||
func getHttpClient(endpoint string, insecure bool) *http.Client {
|
||||
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
||||
if strings.HasPrefix(endpoint, "https://") && insecure {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -39,6 +39,7 @@ func New(endpoint string, insecure bool, uuid, token, version string, opts ...co
|
||||
if token != "" {
|
||||
req.Header().Set(TokenHeader, token)
|
||||
}
|
||||
// TODO: version will be removed from request header after Gitea 1.20 released.
|
||||
if version != "" {
|
||||
req.Header().Set(VersionHeader, version)
|
||||
}
|
||||
@@ -48,12 +49,12 @@ func New(endpoint string, insecure bool, uuid, token, version string, opts ...co
|
||||
|
||||
return &HTTPClient{
|
||||
PingServiceClient: pingv1connect.NewPingServiceClient(
|
||||
getHttpClient(endpoint, insecure),
|
||||
getHTTPClient(endpoint, insecure),
|
||||
baseURL,
|
||||
opts...,
|
||||
),
|
||||
RunnerServiceClient: runnerv1connect.NewRunnerServiceClient(
|
||||
getHttpClient(endpoint, insecure),
|
||||
getHTTPClient(endpoint, insecure),
|
||||
baseURL,
|
||||
opts...,
|
||||
),
|
||||
|
||||
251
internal/pkg/client/mocks/Client.go
Normal file
251
internal/pkg/client/mocks/Client.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Code generated by mockery v2.42.1. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
connect "connectrpc.com/connect"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
)
|
||||
|
||||
// Client is an autogenerated mock type for the Client type
|
||||
type Client struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Address provides a mock function with given fields:
|
||||
func (_m *Client) Address() string {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Address")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Declare provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) Declare(_a0 context.Context, _a1 *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Declare")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[runnerv1.DeclareResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) *connect.Response[runnerv1.DeclareResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[runnerv1.DeclareResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FetchTask provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) FetchTask(_a0 context.Context, _a1 *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for FetchTask")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[runnerv1.FetchTaskResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) *connect.Response[runnerv1.FetchTaskResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[runnerv1.FetchTaskResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Insecure provides a mock function with given fields:
|
||||
func (_m *Client) Insecure() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Insecure")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Ping provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) Ping(_a0 context.Context, _a1 *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Ping")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[pingv1.PingResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[pingv1.PingRequest]) *connect.Response[pingv1.PingResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[pingv1.PingResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[pingv1.PingRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Register provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) Register(_a0 context.Context, _a1 *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Register")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[runnerv1.RegisterResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) *connect.Response[runnerv1.RegisterResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[runnerv1.RegisterResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateLog provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) UpdateLog(_a0 context.Context, _a1 *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateLog")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[runnerv1.UpdateLogResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) *connect.Response[runnerv1.UpdateLogResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[runnerv1.UpdateLogResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateTask provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Client) UpdateTask(_a0 context.Context, _a1 *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateTask")
|
||||
}
|
||||
|
||||
var r0 *connect.Response[runnerv1.UpdateTaskResponse]
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) *connect.Response[runnerv1.UpdateTaskResponse]); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*connect.Response[runnerv1.UpdateTaskResponse])
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
},
|
||||
) *Client {
|
||||
mock := &Client{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./act_runner generate-config > config.yaml` to generate a config file.
|
||||
|
||||
log:
|
||||
# The level of logging, can be trace, debug, info, warn, error, fatal
|
||||
level: info
|
||||
@@ -18,14 +21,31 @@ runner:
|
||||
env_file: .env
|
||||
# The timeout for a job to be finished.
|
||||
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
||||
# So the job could be stopped by the Gitea instance if it's timeout is shorter than this.
|
||||
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
|
||||
timeout: 3h
|
||||
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
||||
# Any running jobs that haven't finished after this timeout will be cancelled.
|
||||
shutdown_timeout: 0s
|
||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||
insecure: false
|
||||
# The timeout for fetching the job from the Gitea instance.
|
||||
fetch_timeout: 5s
|
||||
# The interval for fetching the job from the Gitea instance.
|
||||
fetch_interval: 2s
|
||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||
# and github_mirror is not empty. In this case,
|
||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||
github_mirror: ''
|
||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||
# If it's empty when registering, it will ask for inputting labels.
|
||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||
|
||||
cache:
|
||||
# Enable cache server to use actions/cache.
|
||||
@@ -40,11 +60,51 @@ cache:
|
||||
# The port of the cache server.
|
||||
# 0 means to use a random available port.
|
||||
port: 0
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
# Which network to use for the job containers. Could be bridge, host, none, or the name of a custom network.
|
||||
network_mode: bridge
|
||||
# Specifies the network to which the container will connect.
|
||||
# Could be host, bridge or the name of a custom network.
|
||||
# If it's empty, act_runner will create a network automatically.
|
||||
network: ""
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway).
|
||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||
options:
|
||||
# The parent directory of a job's working directory.
|
||||
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
|
||||
# If the path starts with '/', the '/' will be trimmed.
|
||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||
# If it's empty, /workspace will be used.
|
||||
workdir_parent:
|
||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
||||
# valid_volumes:
|
||||
# - data
|
||||
# - /src/*.json
|
||||
# If you want to allow any volume, please use the following configuration:
|
||||
# valid_volumes:
|
||||
# - '**'
|
||||
valid_volumes: []
|
||||
# Overrides the docker client host with the specified one.
|
||||
# If it's empty, act_runner will find an available docker host automatically.
|
||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||
docker_host: ""
|
||||
# Pull docker image(s) even if already present
|
||||
force_pull: true
|
||||
# Rebuild docker image(s) even if already present
|
||||
force_rebuild: false
|
||||
# Always require a reachable docker daemon, even if not required by act_runner
|
||||
require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
# The parent directory of a job's working directory.
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
workdir_parent:
|
||||
|
||||
@@ -10,34 +10,66 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Log represents the configuration for logging.
|
||||
type Log struct {
|
||||
Level string `yaml:"level"` // Level indicates the logging level.
|
||||
}
|
||||
|
||||
// Runner represents the configuration for the runner.
|
||||
type Runner struct {
|
||||
File string `yaml:"file"` // File specifies the file path for the runner.
|
||||
Capacity int `yaml:"capacity"` // Capacity specifies the capacity of the runner.
|
||||
Envs map[string]string `yaml:"envs"` // Envs stores environment variables for the runner.
|
||||
EnvFile string `yaml:"env_file"` // EnvFile specifies the path to the file containing environment variables for the runner.
|
||||
Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
|
||||
ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner.
|
||||
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
|
||||
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
||||
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
||||
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||
}
|
||||
|
||||
// Cache represents the configuration for caching.
|
||||
type Cache struct {
|
||||
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
|
||||
Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
|
||||
Host string `yaml:"host"` // Host specifies the caching host.
|
||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||
}
|
||||
|
||||
// Container represents the configuration for the container.
|
||||
type Container struct {
|
||||
Network string `yaml:"network"` // Network specifies the network for the container.
|
||||
NetworkMode string `yaml:"network_mode"` // Deprecated: use Network instead. Could be removed after Gitea 1.20
|
||||
Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
|
||||
Options string `yaml:"options"` // Options specifies additional options for the container.
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory.
|
||||
ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers.
|
||||
DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
|
||||
ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present
|
||||
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
|
||||
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by act_runner
|
||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
}
|
||||
|
||||
// Host represents the configuration for the host.
|
||||
type Host struct {
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||
}
|
||||
|
||||
// Config represents the overall configuration.
|
||||
type Config struct {
|
||||
Log struct {
|
||||
Level string `yaml:"level"`
|
||||
} `yaml:"log"`
|
||||
Runner struct {
|
||||
File string `yaml:"file"`
|
||||
Capacity int `yaml:"capacity"`
|
||||
Envs map[string]string `yaml:"envs"`
|
||||
EnvFile string `yaml:"env_file"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
FetchTimeout time.Duration `yaml:"fetch_timeout"`
|
||||
FetchInterval time.Duration `yaml:"fetch_interval"`
|
||||
} `yaml:"runner"`
|
||||
Cache struct {
|
||||
Enabled *bool `yaml:"enabled"` // pointer to distinguish between false and not set, and it will be true if not set
|
||||
Dir string `yaml:"dir"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
} `yaml:"cache"`
|
||||
Container struct {
|
||||
NetworkMode string `yaml:"network_mode"`
|
||||
Privileged bool `yaml:"privileged"`
|
||||
Options string `yaml:"options"`
|
||||
} `yaml:"container"`
|
||||
Log Log `yaml:"log"` // Log represents the configuration for logging.
|
||||
Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner.
|
||||
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
|
||||
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
||||
}
|
||||
|
||||
// LoadDefault returns the default configuration.
|
||||
@@ -45,14 +77,12 @@ type Config struct {
|
||||
func LoadDefault(file string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
if file != "" {
|
||||
f, err := os.Open(file)
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("open config file %q: %w", file, err)
|
||||
}
|
||||
defer f.Close()
|
||||
decoder := yaml.NewDecoder(f)
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, err
|
||||
if err := yaml.Unmarshal(content, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
||||
}
|
||||
}
|
||||
compatibleWithOldEnvs(file != "", cfg)
|
||||
@@ -63,6 +93,9 @@ func LoadDefault(file string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read env file %q: %w", cfg.Runner.EnvFile, err)
|
||||
}
|
||||
if cfg.Runner.Envs == nil {
|
||||
cfg.Runner.Envs = map[string]string{}
|
||||
}
|
||||
for k, v := range envs {
|
||||
cfg.Runner.Envs[k] = v
|
||||
}
|
||||
@@ -91,8 +124,12 @@ func LoadDefault(file string) (*Config, error) {
|
||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
}
|
||||
if cfg.Container.NetworkMode == "" {
|
||||
cfg.Container.NetworkMode = "bridge"
|
||||
if cfg.Container.WorkdirParent == "" {
|
||||
cfg.Container.WorkdirParent = "workspace"
|
||||
}
|
||||
if cfg.Host.WorkdirParent == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
||||
}
|
||||
if cfg.Runner.FetchTimeout <= 0 {
|
||||
cfg.Runner.FetchTimeout = 5 * time.Second
|
||||
@@ -101,5 +138,18 @@ func LoadDefault(file string) (*Config, error) {
|
||||
cfg.Runner.FetchInterval = 2 * time.Second
|
||||
}
|
||||
|
||||
// although `container.network_mode` will be deprecated, but we have to be compatible with it for now.
|
||||
if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {
|
||||
log.Warn("You are trying to use deprecated configuration item of `container.network_mode`, please use `container.network` instead.")
|
||||
if cfg.Container.NetworkMode == "bridge" {
|
||||
// Previously, if the value of `container.network_mode` is `bridge`, we will create a new network for job.
|
||||
// But “bridge” is easily confused with the bridge network created by Docker by default.
|
||||
// So we set the value of `container.network` to empty string to make `act_runner` automatically create a new network for job.
|
||||
cfg.Container.Network = ""
|
||||
} else {
|
||||
cfg.Container.Network = cfg.Container.NetworkMode
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ const registrationWarning = "This file is automatically generated by act-runner.
|
||||
type Registration struct {
|
||||
Warning string `json:"WARNING"` // Warning message to display, it's always the registrationWarning constant
|
||||
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Address string `json:"address"`
|
||||
Labels []string `json:"labels"`
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Address string `json:"address"`
|
||||
Labels []string `json:"labels"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
}
|
||||
|
||||
func LoadRegistration(file string) (*Registration, error) {
|
||||
|
||||
209
internal/pkg/envcheck/bandwidth.go
Normal file
209
internal/pkg/envcheck/bandwidth.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BandwidthInfo holds network bandwidth test results
|
||||
type BandwidthInfo struct {
|
||||
DownloadMbps float64 `json:"download_mbps"`
|
||||
UploadMbps float64 `json:"upload_mbps,omitempty"`
|
||||
Latency float64 `json:"latency_ms,omitempty"`
|
||||
TestedAt time.Time `json:"tested_at"`
|
||||
}
|
||||
|
||||
// BandwidthManager handles periodic bandwidth testing
|
||||
type BandwidthManager struct {
|
||||
serverURL string
|
||||
lastResult *BandwidthInfo
|
||||
mu sync.RWMutex
|
||||
testInterval time.Duration
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// NewBandwidthManager creates a new bandwidth manager
|
||||
func NewBandwidthManager(serverURL string, testInterval time.Duration) *BandwidthManager {
|
||||
return &BandwidthManager{
|
||||
serverURL: serverURL,
|
||||
testInterval: testInterval,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic bandwidth testing
|
||||
func (bm *BandwidthManager) Start(ctx context.Context) {
|
||||
// Run initial test
|
||||
bm.RunTest(ctx)
|
||||
|
||||
// Start periodic testing
|
||||
go func() {
|
||||
ticker := time.NewTicker(bm.testInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
bm.RunTest(ctx)
|
||||
case <-bm.stopChan:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the periodic testing
|
||||
func (bm *BandwidthManager) Stop() {
|
||||
close(bm.stopChan)
|
||||
}
|
||||
|
||||
// RunTest runs a bandwidth test and stores the result
|
||||
func (bm *BandwidthManager) RunTest(ctx context.Context) *BandwidthInfo {
|
||||
result := TestBandwidth(ctx, bm.serverURL)
|
||||
|
||||
bm.mu.Lock()
|
||||
bm.lastResult = result
|
||||
bm.mu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLastResult returns the most recent bandwidth test result
|
||||
func (bm *BandwidthManager) GetLastResult() *BandwidthInfo {
|
||||
bm.mu.RLock()
|
||||
defer bm.mu.RUnlock()
|
||||
return bm.lastResult
|
||||
}
|
||||
|
||||
// TestBandwidth tests network bandwidth to the Gitea server
|
||||
func TestBandwidth(ctx context.Context, serverURL string) *BandwidthInfo {
|
||||
if serverURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := &BandwidthInfo{
|
||||
TestedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Test latency first
|
||||
info.Latency = testLatency(ctx, serverURL)
|
||||
|
||||
// Test download speed
|
||||
info.DownloadMbps = testDownloadSpeed(ctx, serverURL)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func testLatency(ctx context.Context, serverURL string) float64 {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, "HEAD", serverURL, nil)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
latency := time.Since(start).Seconds() * 1000 // Convert to ms
|
||||
return float64(int(latency*100)) / 100 // Round to 2 decimals
|
||||
}
|
||||
|
||||
func testDownloadSpeed(ctx context.Context, serverURL string) float64 {
|
||||
// Try multiple endpoints to accumulate ~1MB of data
|
||||
endpoints := []string{
|
||||
"/assets/css/index.css",
|
||||
"/assets/js/index.js",
|
||||
"/assets/img/logo.svg",
|
||||
"/assets/img/logo.png",
|
||||
"/",
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
var totalBytes int64
|
||||
var totalDuration time.Duration
|
||||
targetBytes := int64(1024 * 1024) // 1MB target
|
||||
maxAttempts := 10 // Limit iterations
|
||||
|
||||
for attempt := 0; attempt < maxAttempts && totalBytes < targetBytes; attempt++ {
|
||||
for _, endpoint := range endpoints {
|
||||
if totalBytes >= targetBytes {
|
||||
break
|
||||
}
|
||||
|
||||
url := serverURL + endpoint
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
n, _ := io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
if n > 0 {
|
||||
totalBytes += n
|
||||
totalDuration += duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalBytes == 0 || totalDuration == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate speed in Mbps
|
||||
seconds := totalDuration.Seconds()
|
||||
if seconds == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
bytesPerSecond := float64(totalBytes) / seconds
|
||||
mbps := (bytesPerSecond * 8) / (1024 * 1024)
|
||||
|
||||
return float64(int(mbps*100)) / 100
|
||||
}
|
||||
|
||||
// FormatBandwidth formats bandwidth for display
|
||||
func FormatBandwidth(mbps float64) string {
|
||||
if mbps == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
if mbps >= 1000 {
|
||||
return fmt.Sprintf("%.1f Gbps", mbps/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.1f Mbps", mbps)
|
||||
}
|
||||
889
internal/pkg/envcheck/capabilities.go
Normal file
889
internal/pkg/envcheck/capabilities.go
Normal file
@@ -0,0 +1,889 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envcheck
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DiskInfo holds disk space information
|
||||
type DiskInfo struct {
|
||||
Path string `json:"path,omitempty"` // Path being checked (working directory)
|
||||
Total uint64 `json:"total_bytes"`
|
||||
Free uint64 `json:"free_bytes"`
|
||||
Used uint64 `json:"used_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// DistroInfo holds Linux distribution information
|
||||
type DistroInfo struct {
|
||||
ID string `json:"id,omitempty"` // e.g., "ubuntu", "debian", "fedora"
|
||||
VersionID string `json:"version_id,omitempty"` // e.g., "24.04", "12"
|
||||
PrettyName string `json:"pretty_name,omitempty"` // e.g., "Ubuntu 24.04 LTS"
|
||||
}
|
||||
|
||||
// XcodeInfo holds Xcode and iOS development information
|
||||
type XcodeInfo struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
SDKs []string `json:"sdks,omitempty"` // e.g., ["iOS 17.0", "macOS 14.0"]
|
||||
Simulators []string `json:"simulators,omitempty"` // Available iOS simulators
|
||||
}
|
||||
|
||||
// RunnerCapabilities represents the capabilities of a runner for AI consumption
|
||||
type RunnerCapabilities struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Distro *DistroInfo `json:"distro,omitempty"`
|
||||
Xcode *XcodeInfo `json:"xcode,omitempty"`
|
||||
Docker bool `json:"docker"`
|
||||
DockerCompose bool `json:"docker_compose"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
Shell []string `json:"shell,omitempty"`
|
||||
Tools map[string][]string `json:"tools,omitempty"`
|
||||
BuildTools []string `json:"build_tools,omitempty"` // Available build/installer tools
|
||||
PackageManagers []string `json:"package_managers,omitempty"`
|
||||
Features *CapabilityFeatures `json:"features,omitempty"`
|
||||
Limitations []string `json:"limitations,omitempty"`
|
||||
Disk *DiskInfo `json:"disk,omitempty"`
|
||||
Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"`
|
||||
SuggestedLabels []string `json:"suggested_labels,omitempty"`
|
||||
}
|
||||
|
||||
// CapabilityFeatures represents feature support flags
|
||||
type CapabilityFeatures struct {
|
||||
ArtifactsV4 bool `json:"artifacts_v4"`
|
||||
Cache bool `json:"cache"`
|
||||
Services bool `json:"services"`
|
||||
CompositeActions bool `json:"composite_actions"`
|
||||
}
|
||||
|
||||
// DetectCapabilities detects the runner's capabilities
|
||||
// workingDir is the directory where builds will run (for disk space detection)
|
||||
func DetectCapabilities(ctx context.Context, dockerHost string, workingDir string) *RunnerCapabilities {
|
||||
cap := &RunnerCapabilities{
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Tools: make(map[string][]string),
|
||||
BuildTools: []string{},
|
||||
PackageManagers: []string{},
|
||||
Shell: detectShells(),
|
||||
Features: &CapabilityFeatures{
|
||||
ArtifactsV4: false, // Gitea doesn't support v4 artifacts
|
||||
Cache: true,
|
||||
Services: true,
|
||||
CompositeActions: true,
|
||||
},
|
||||
Limitations: []string{
|
||||
"actions/upload-artifact@v4 not supported (use v3 or direct API upload)",
|
||||
"actions/download-artifact@v4 not supported (use v3)",
|
||||
},
|
||||
}
|
||||
|
||||
// Detect Linux distribution
|
||||
if runtime.GOOS == "linux" {
|
||||
cap.Distro = detectLinuxDistro()
|
||||
}
|
||||
|
||||
// Detect macOS Xcode/iOS
|
||||
if runtime.GOOS == "darwin" {
|
||||
cap.Xcode = detectXcode(ctx)
|
||||
}
|
||||
|
||||
// Detect Docker
|
||||
cap.Docker, cap.ContainerRuntime = detectDocker(ctx, dockerHost)
|
||||
if cap.Docker {
|
||||
cap.DockerCompose = detectDockerCompose(ctx)
|
||||
cap.Features.Services = true
|
||||
}
|
||||
|
||||
// Detect common tools
|
||||
detectTools(ctx, cap)
|
||||
|
||||
// Detect build tools
|
||||
detectBuildTools(ctx, cap)
|
||||
|
||||
// Detect package managers
|
||||
detectPackageManagers(ctx, cap)
|
||||
|
||||
// Detect disk space on the working directory's filesystem
|
||||
cap.Disk = detectDiskSpace(workingDir)
|
||||
|
||||
// Generate suggested labels based on detected capabilities
|
||||
cap.SuggestedLabels = generateSuggestedLabels(cap)
|
||||
|
||||
return cap
|
||||
}
|
||||
|
||||
// detectXcode detects Xcode and iOS development capabilities on macOS
|
||||
func detectXcode(ctx context.Context) *XcodeInfo {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check for xcodebuild
|
||||
cmd := exec.CommandContext(timeoutCtx, "xcodebuild", "-version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
xcode := &XcodeInfo{}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "Xcode ") {
|
||||
xcode.Version = strings.TrimPrefix(line, "Xcode ")
|
||||
} else if strings.HasPrefix(line, "Build version ") {
|
||||
xcode.Build = strings.TrimPrefix(line, "Build version ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get available SDKs
|
||||
cmd = exec.CommandContext(timeoutCtx, "xcodebuild", "-showsdks")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Look for SDK lines like "-sdk iphoneos17.0" or "iOS 17.0"
|
||||
if strings.Contains(line, "SDK") || strings.HasPrefix(line, "-sdk") {
|
||||
continue // Skip header lines
|
||||
}
|
||||
if strings.Contains(line, "iOS") || strings.Contains(line, "macOS") ||
|
||||
strings.Contains(line, "watchOS") || strings.Contains(line, "tvOS") ||
|
||||
strings.Contains(line, "visionOS") || strings.Contains(line, "xrOS") {
|
||||
// Extract SDK name
|
||||
if idx := strings.Index(line, "-sdk"); idx != -1 {
|
||||
sdkPart := strings.TrimSpace(line[:idx])
|
||||
if sdkPart != "" {
|
||||
xcode.SDKs = append(xcode.SDKs, sdkPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available simulators
|
||||
cmd = exec.CommandContext(timeoutCtx, "xcrun", "simctl", "list", "devices", "available", "-j")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
var simData struct {
|
||||
Devices map[string][]struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
if json.Unmarshal(output, &simData) == nil {
|
||||
seen := make(map[string]bool)
|
||||
for runtime, devices := range simData.Devices {
|
||||
if strings.Contains(runtime, "iOS") {
|
||||
for _, dev := range devices {
|
||||
key := dev.Name
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
xcode.Simulators = append(xcode.Simulators, dev.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if xcode.Version == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return xcode
|
||||
}
|
||||
|
||||
// detectLinuxDistro reads /etc/os-release to get distribution info
|
||||
func detectLinuxDistro() *DistroInfo {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
distro := &DistroInfo{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
distro.ID = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
} else if strings.HasPrefix(line, "VERSION_ID=") {
|
||||
distro.VersionID = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
||||
} else if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||
distro.PrettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
||||
}
|
||||
}
|
||||
|
||||
if distro.ID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return distro
|
||||
}
|
||||
|
||||
// generateSuggestedLabels creates industry-standard labels based on capabilities
|
||||
func generateSuggestedLabels(cap *RunnerCapabilities) []string {
|
||||
labels := []string{}
|
||||
seen := make(map[string]bool)
|
||||
|
||||
addLabel := func(label string) {
|
||||
if label != "" && !seen[label] {
|
||||
seen[label] = true
|
||||
labels = append(labels, label)
|
||||
}
|
||||
}
|
||||
|
||||
// OS labels
|
||||
switch cap.OS {
|
||||
case "linux":
|
||||
addLabel("linux")
|
||||
addLabel("linux-latest")
|
||||
case "windows":
|
||||
addLabel("windows")
|
||||
addLabel("windows-latest")
|
||||
case "darwin":
|
||||
addLabel("macos")
|
||||
addLabel("macos-latest")
|
||||
}
|
||||
|
||||
// Distro labels (Linux only)
|
||||
if cap.Distro != nil && cap.Distro.ID != "" {
|
||||
distro := strings.ToLower(cap.Distro.ID)
|
||||
addLabel(distro)
|
||||
addLabel(distro + "-latest")
|
||||
}
|
||||
|
||||
// Xcode/iOS labels (macOS only)
|
||||
if cap.Xcode != nil {
|
||||
addLabel("xcode")
|
||||
// Check for SDKs
|
||||
for _, sdk := range cap.Xcode.SDKs {
|
||||
sdkLower := strings.ToLower(sdk)
|
||||
if strings.Contains(sdkLower, "ios") {
|
||||
addLabel("ios")
|
||||
}
|
||||
if strings.Contains(sdkLower, "visionos") || strings.Contains(sdkLower, "xros") {
|
||||
addLabel("visionos")
|
||||
}
|
||||
if strings.Contains(sdkLower, "watchos") {
|
||||
addLabel("watchos")
|
||||
}
|
||||
if strings.Contains(sdkLower, "tvos") {
|
||||
addLabel("tvos")
|
||||
}
|
||||
}
|
||||
// If simulators available, add simulator label
|
||||
if len(cap.Xcode.Simulators) > 0 {
|
||||
addLabel("ios-simulator")
|
||||
}
|
||||
}
|
||||
|
||||
// Tool-based labels
|
||||
if _, ok := cap.Tools["dotnet"]; ok {
|
||||
addLabel("dotnet")
|
||||
}
|
||||
if _, ok := cap.Tools["java"]; ok {
|
||||
addLabel("java")
|
||||
}
|
||||
if _, ok := cap.Tools["node"]; ok {
|
||||
addLabel("node")
|
||||
}
|
||||
|
||||
// Build tool labels
|
||||
for _, tool := range cap.BuildTools {
|
||||
switch tool {
|
||||
case "msbuild":
|
||||
addLabel("msbuild")
|
||||
case "visual-studio":
|
||||
addLabel("vs2022") // or detect actual version
|
||||
case "inno-setup":
|
||||
addLabel("inno-setup")
|
||||
case "nsis":
|
||||
addLabel("nsis")
|
||||
}
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
// ToJSON converts capabilities to JSON string for transmission
|
||||
func (c *RunnerCapabilities) ToJSON() string {
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func detectShells() []string {
|
||||
shells := []string{}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if _, err := exec.LookPath("pwsh"); err == nil {
|
||||
shells = append(shells, "pwsh")
|
||||
}
|
||||
if _, err := exec.LookPath("powershell"); err == nil {
|
||||
shells = append(shells, "powershell")
|
||||
}
|
||||
shells = append(shells, "cmd")
|
||||
case "darwin":
|
||||
if _, err := exec.LookPath("zsh"); err == nil {
|
||||
shells = append(shells, "zsh")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err == nil {
|
||||
shells = append(shells, "bash")
|
||||
}
|
||||
shells = append(shells, "sh")
|
||||
default: // linux and others
|
||||
if _, err := exec.LookPath("bash"); err == nil {
|
||||
shells = append(shells, "bash")
|
||||
}
|
||||
shells = append(shells, "sh")
|
||||
}
|
||||
|
||||
return shells
|
||||
}
|
||||
|
||||
func detectDocker(ctx context.Context, dockerHost string) (bool, string) {
|
||||
opts := []client.Opt{client.FromEnv}
|
||||
if dockerHost != "" {
|
||||
opts = append(opts, client.WithHost(dockerHost))
|
||||
}
|
||||
|
||||
cli, err := client.NewClientWithOpts(opts...)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = cli.Ping(timeoutCtx)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if it's podman or docker
|
||||
info, err := cli.Info(timeoutCtx)
|
||||
if err == nil {
|
||||
if strings.Contains(strings.ToLower(info.Name), "podman") {
|
||||
return true, "podman"
|
||||
}
|
||||
}
|
||||
|
||||
return true, "docker"
|
||||
}
|
||||
|
||||
func detectDockerCompose(ctx context.Context) bool {
|
||||
// Check for docker compose v2 (docker compose)
|
||||
cmd := exec.CommandContext(ctx, "docker", "compose", "version")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for docker-compose v1
|
||||
if _, err := exec.LookPath("docker-compose"); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func detectTools(ctx context.Context, cap *RunnerCapabilities) {
|
||||
toolDetectors := map[string]func(context.Context) []string{
|
||||
"node": detectNodeVersions,
|
||||
"go": detectGoVersions,
|
||||
"python": detectPythonVersions,
|
||||
"java": detectJavaVersions,
|
||||
"dotnet": detectDotnetVersions,
|
||||
"rust": detectRustVersions,
|
||||
"ruby": detectRubyVersions,
|
||||
"php": detectPHPVersions,
|
||||
"swift": detectSwiftVersions,
|
||||
"kotlin": detectKotlinVersions,
|
||||
"flutter": detectFlutterVersions,
|
||||
"dart": detectDartVersions,
|
||||
"powershell": detectPowerShellVersions,
|
||||
}
|
||||
|
||||
for tool, detector := range toolDetectors {
|
||||
if versions := detector(ctx); len(versions) > 0 {
|
||||
cap.Tools[tool] = versions
|
||||
}
|
||||
}
|
||||
|
||||
// Detect additional tools that just need presence check
|
||||
simpleTools := map[string]string{
|
||||
"git": "git",
|
||||
"cmake": "cmake",
|
||||
"make": "make",
|
||||
"ninja": "ninja",
|
||||
"gradle": "gradle",
|
||||
"maven": "mvn",
|
||||
"npm": "npm",
|
||||
"yarn": "yarn",
|
||||
"pnpm": "pnpm",
|
||||
"cargo": "cargo",
|
||||
"pip": "pip3",
|
||||
}
|
||||
|
||||
for name, cmd := range simpleTools {
|
||||
if v := detectSimpleToolVersion(ctx, cmd); v != "" {
|
||||
cap.Tools[name] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detectBuildTools(ctx context.Context, cap *RunnerCapabilities) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
detectWindowsBuildTools(ctx, cap)
|
||||
case "darwin":
|
||||
detectMacOSBuildTools(ctx, cap)
|
||||
case "linux":
|
||||
detectLinuxBuildTools(ctx, cap)
|
||||
}
|
||||
}
|
||||
|
||||
func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
|
||||
// Check for Visual Studio via vswhere
|
||||
vswherePaths := []string{
|
||||
`C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe`,
|
||||
`C:\Program Files\Microsoft Visual Studio\Installer\vswhere.exe`,
|
||||
}
|
||||
for _, vswhere := range vswherePaths {
|
||||
if _, err := os.Stat(vswhere); err == nil {
|
||||
cmd := exec.CommandContext(ctx, vswhere, "-latest", "-property", "displayName")
|
||||
if output, err := cmd.Output(); err == nil && len(output) > 0 {
|
||||
cap.BuildTools = append(cap.BuildTools, "visual-studio")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for MSBuild
|
||||
msbuildPaths := []string{
|
||||
`C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
`C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
`C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
`C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
`C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
`C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe`,
|
||||
}
|
||||
for _, msbuild := range msbuildPaths {
|
||||
if _, err := os.Stat(msbuild); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "msbuild")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Inno Setup
|
||||
innoSetupPaths := []string{
|
||||
`C:\Program Files (x86)\Inno Setup 6\ISCC.exe`,
|
||||
`C:\Program Files\Inno Setup 6\ISCC.exe`,
|
||||
`C:\Program Files (x86)\Inno Setup 5\ISCC.exe`,
|
||||
`C:\Program Files\Inno Setup 5\ISCC.exe`,
|
||||
}
|
||||
for _, iscc := range innoSetupPaths {
|
||||
if _, err := os.Stat(iscc); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "inno-setup")
|
||||
break
|
||||
}
|
||||
}
|
||||
// Also check PATH
|
||||
if _, err := exec.LookPath("iscc"); err == nil {
|
||||
if !contains(cap.BuildTools, "inno-setup") {
|
||||
cap.BuildTools = append(cap.BuildTools, "inno-setup")
|
||||
}
|
||||
}
|
||||
|
||||
// Check for NSIS
|
||||
nsisPaths := []string{
|
||||
`C:\Program Files (x86)\NSIS\makensis.exe`,
|
||||
`C:\Program Files\NSIS\makensis.exe`,
|
||||
}
|
||||
for _, nsis := range nsisPaths {
|
||||
if _, err := os.Stat(nsis); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "nsis")
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("makensis"); err == nil {
|
||||
if !contains(cap.BuildTools, "nsis") {
|
||||
cap.BuildTools = append(cap.BuildTools, "nsis")
|
||||
}
|
||||
}
|
||||
|
||||
// Check for WiX Toolset
|
||||
wixPaths := []string{
|
||||
`C:\Program Files (x86)\WiX Toolset v3.11\bin\candle.exe`,
|
||||
`C:\Program Files (x86)\WiX Toolset v3.14\bin\candle.exe`,
|
||||
}
|
||||
for _, wix := range wixPaths {
|
||||
if _, err := os.Stat(wix); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "wix")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for signtool (Windows SDK)
|
||||
signtoolPaths, _ := filepath.Glob(`C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe`)
|
||||
if len(signtoolPaths) > 0 {
|
||||
cap.BuildTools = append(cap.BuildTools, "signtool")
|
||||
}
|
||||
}
|
||||
|
||||
func detectMacOSBuildTools(ctx context.Context, cap *RunnerCapabilities) {
|
||||
// Check for xcpretty
|
||||
if _, err := exec.LookPath("xcpretty"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "xcpretty")
|
||||
}
|
||||
|
||||
// Check for fastlane
|
||||
if _, err := exec.LookPath("fastlane"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "fastlane")
|
||||
}
|
||||
|
||||
// Check for CocoaPods
|
||||
if _, err := exec.LookPath("pod"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "cocoapods")
|
||||
}
|
||||
|
||||
// Check for Carthage
|
||||
if _, err := exec.LookPath("carthage"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "carthage")
|
||||
}
|
||||
|
||||
// Check for SwiftLint
|
||||
if _, err := exec.LookPath("swiftlint"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "swiftlint")
|
||||
}
|
||||
|
||||
// Check for create-dmg or similar
|
||||
if _, err := exec.LookPath("create-dmg"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "create-dmg")
|
||||
}
|
||||
|
||||
// Check for Packages (packagesbuild)
|
||||
if _, err := exec.LookPath("packagesbuild"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "packages")
|
||||
}
|
||||
|
||||
// Check for pkgbuild (built-in)
|
||||
if _, err := exec.LookPath("pkgbuild"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "pkgbuild")
|
||||
}
|
||||
|
||||
// Check for codesign (built-in)
|
||||
if _, err := exec.LookPath("codesign"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "codesign")
|
||||
}
|
||||
|
||||
// Check for notarytool (built-in with Xcode)
|
||||
if _, err := exec.LookPath("notarytool"); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, "notarytool")
|
||||
}
|
||||
}
|
||||
|
||||
func detectLinuxBuildTools(ctx context.Context, cap *RunnerCapabilities) {
|
||||
// Check for common Linux build tools
|
||||
tools := []string{
|
||||
"gcc", "g++", "clang", "clang++",
|
||||
"autoconf", "automake", "libtool",
|
||||
"pkg-config", "meson",
|
||||
"dpkg-deb", "rpmbuild", "fpm",
|
||||
"appimage-builder", "linuxdeploy",
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err == nil {
|
||||
cap.BuildTools = append(cap.BuildTools, tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detectPackageManagers(ctx context.Context, cap *RunnerCapabilities) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if _, err := exec.LookPath("choco"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "chocolatey")
|
||||
}
|
||||
if _, err := exec.LookPath("scoop"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "scoop")
|
||||
}
|
||||
if _, err := exec.LookPath("winget"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "winget")
|
||||
}
|
||||
case "darwin":
|
||||
if _, err := exec.LookPath("brew"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "homebrew")
|
||||
}
|
||||
if _, err := exec.LookPath("port"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "macports")
|
||||
}
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("apt"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "apt")
|
||||
}
|
||||
if _, err := exec.LookPath("yum"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "yum")
|
||||
}
|
||||
if _, err := exec.LookPath("dnf"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "dnf")
|
||||
}
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "pacman")
|
||||
}
|
||||
if _, err := exec.LookPath("zypper"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "zypper")
|
||||
}
|
||||
if _, err := exec.LookPath("apk"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "apk")
|
||||
}
|
||||
if _, err := exec.LookPath("snap"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "snap")
|
||||
}
|
||||
if _, err := exec.LookPath("flatpak"); err == nil {
|
||||
cap.PackageManagers = append(cap.PackageManagers, "flatpak")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detectNodeVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "node", "--version", "v")
|
||||
}
|
||||
|
||||
func detectGoVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "go", "version", "go")
|
||||
}
|
||||
|
||||
func detectPythonVersions(ctx context.Context) []string {
|
||||
versions := []string{}
|
||||
|
||||
// Try python3 first
|
||||
if v := detectToolVersion(ctx, "python3", "--version", "Python "); len(v) > 0 {
|
||||
versions = append(versions, v...)
|
||||
}
|
||||
|
||||
// Also try python
|
||||
if v := detectToolVersion(ctx, "python", "--version", "Python "); len(v) > 0 {
|
||||
for _, ver := range v {
|
||||
if !contains(versions, ver) {
|
||||
versions = append(versions, ver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
func detectJavaVersions(ctx context.Context) []string {
|
||||
cmd := exec.CommandContext(ctx, "java", "-version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "version") {
|
||||
start := strings.Index(line, "\"")
|
||||
end := strings.LastIndex(line, "\"")
|
||||
if start != -1 && end > start {
|
||||
version := line[start+1 : end]
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) > 0 {
|
||||
if parts[0] == "1" && len(parts) > 1 {
|
||||
return []string{parts[1]}
|
||||
}
|
||||
return []string{parts[0]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectDotnetVersions(ctx context.Context) []string {
|
||||
cmd := exec.CommandContext(ctx, "dotnet", "--list-sdks")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
versions := []string{}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) > 0 {
|
||||
version := parts[0]
|
||||
major := strings.Split(version, ".")[0]
|
||||
if !contains(versions, major) {
|
||||
versions = append(versions, major)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
func detectRustVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "rustc", "--version", "rustc ")
|
||||
}
|
||||
|
||||
func detectRubyVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "ruby", "--version", "ruby ")
|
||||
}
|
||||
|
||||
func detectPHPVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "php", "--version", "PHP ")
|
||||
}
|
||||
|
||||
func detectSwiftVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "swift", "--version", "Swift version ")
|
||||
}
|
||||
|
||||
func detectKotlinVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "kotlin", "-version", "Kotlin version ")
|
||||
}
|
||||
|
||||
func detectFlutterVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "flutter", "--version", "Flutter ")
|
||||
}
|
||||
|
||||
func detectDartVersions(ctx context.Context) []string {
|
||||
return detectToolVersion(ctx, "dart", "--version", "Dart SDK version: ")
|
||||
}
|
||||
|
||||
func detectPowerShellVersions(ctx context.Context) []string {
|
||||
versions := []string{}
|
||||
|
||||
// Check for pwsh (PowerShell Core / PowerShell 7+)
|
||||
if v := detectPwshVersion(ctx, "pwsh"); v != "" {
|
||||
versions = append(versions, "pwsh:"+v)
|
||||
}
|
||||
|
||||
// Check for powershell (Windows PowerShell 5.x)
|
||||
if runtime.GOOS == "windows" {
|
||||
if v := detectPwshVersion(ctx, "powershell"); v != "" {
|
||||
versions = append(versions, "powershell:"+v)
|
||||
}
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
func detectPwshVersion(ctx context.Context, cmd string) string {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use -Command to get version
|
||||
var c *exec.Cmd
|
||||
if cmd == "pwsh" {
|
||||
c = exec.CommandContext(timeoutCtx, cmd, "-Command", "$PSVersionTable.PSVersion.ToString()")
|
||||
} else {
|
||||
c = exec.CommandContext(timeoutCtx, cmd, "-Command", "$PSVersionTable.PSVersion.ToString()")
|
||||
}
|
||||
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(output))
|
||||
// Return major.minor
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) >= 2 {
|
||||
return parts[0] + "." + parts[1]
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func detectSimpleToolVersion(ctx context.Context, cmd string) string {
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := exec.CommandContext(timeoutCtx, cmd, "--version")
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
// Try without --version for tools that don't support it
|
||||
return "installed"
|
||||
}
|
||||
|
||||
line := strings.TrimSpace(strings.Split(string(output), "\n")[0])
|
||||
// Extract version number if possible
|
||||
parts := strings.Fields(line)
|
||||
for _, part := range parts {
|
||||
// Look for something that looks like a version
|
||||
if len(part) > 0 && (part[0] >= '0' && part[0] <= '9' || part[0] == 'v') {
|
||||
return strings.TrimPrefix(part, "v")
|
||||
}
|
||||
}
|
||||
|
||||
return "installed"
|
||||
}
|
||||
|
||||
func detectToolVersion(ctx context.Context, cmd string, args string, prefix string) []string {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c := exec.CommandContext(timeoutCtx, cmd, args)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
line := strings.TrimSpace(string(output))
|
||||
if prefix != "" {
|
||||
if idx := strings.Index(line, prefix); idx != -1 {
|
||||
line = line[idx+len(prefix):]
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 0 {
|
||||
version := parts[0]
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
vparts := strings.Split(version, ".")
|
||||
if len(vparts) >= 2 {
|
||||
return []string{vparts[0] + "." + vparts[1]}
|
||||
}
|
||||
return []string{vparts[0]}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
43
internal/pkg/envcheck/disk_unix.go
Normal file
43
internal/pkg/envcheck/disk_unix.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build unix
|
||||
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envcheck
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// detectDiskSpace detects disk space on the specified path's filesystem (Unix version)
|
||||
// If path is empty, defaults to "/"
|
||||
func detectDiskSpace(path string) *DiskInfo {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
var stat unix.Statfs_t
|
||||
|
||||
err := unix.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
// Fallback to root if the path doesn't exist
|
||||
err = unix.Statfs("/", &stat)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path = "/"
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
free := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
usedPercent := float64(used) / float64(total) * 100
|
||||
|
||||
return &DiskInfo{
|
||||
Path: path,
|
||||
Total: total,
|
||||
Free: free,
|
||||
Used: used,
|
||||
UsedPercent: usedPercent,
|
||||
}
|
||||
}
|
||||
57
internal/pkg/envcheck/disk_windows.go
Normal file
57
internal/pkg/envcheck/disk_windows.go
Normal file
@@ -0,0 +1,57 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envcheck
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// detectDiskSpace detects disk space on the specified path's drive (Windows version)
|
||||
// If path is empty, defaults to "C:\"
|
||||
func detectDiskSpace(path string) *DiskInfo {
|
||||
if path == "" {
|
||||
path = "C:\\"
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
absPath = "C:\\"
|
||||
}
|
||||
|
||||
// Extract drive letter (e.g., "D:\" from "D:\builds\runner")
|
||||
drivePath := filepath.VolumeName(absPath) + "\\"
|
||||
if drivePath == "\\" {
|
||||
drivePath = "C:\\"
|
||||
}
|
||||
|
||||
var freeBytesAvailable, totalNumberOfBytes, totalNumberOfFreeBytes uint64
|
||||
|
||||
pathPtr := windows.StringToUTF16Ptr(drivePath)
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFreeBytes)
|
||||
if err != nil {
|
||||
// Fallback to C: drive
|
||||
pathPtr = windows.StringToUTF16Ptr("C:\\")
|
||||
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFreeBytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
drivePath = "C:\\"
|
||||
}
|
||||
|
||||
used := totalNumberOfBytes - totalNumberOfFreeBytes
|
||||
usedPercent := float64(used) / float64(totalNumberOfBytes) * 100
|
||||
|
||||
return &DiskInfo{
|
||||
Path: drivePath,
|
||||
Total: totalNumberOfBytes,
|
||||
Free: totalNumberOfFreeBytes,
|
||||
Used: used,
|
||||
UsedPercent: usedPercent,
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,16 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func CheckIfDockerRunning(ctx context.Context) error {
|
||||
// TODO: if runner support configures to use docker, we need config.Config to pass in
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
func CheckIfDockerRunning(ctx context.Context, configDockerHost string) error {
|
||||
opts := []client.Opt{
|
||||
client.FromEnv,
|
||||
}
|
||||
|
||||
if configDockerHost != "" {
|
||||
opts = append(opts, client.WithHost(configDockerHost))
|
||||
}
|
||||
|
||||
cli, err := client.NewClientWithOpts(opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -20,7 +27,7 @@ func CheckIfDockerRunning(ctx context.Context) error {
|
||||
|
||||
_, err = cli.Ping(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot ping the docker daemon, does it running? %w", err)
|
||||
return fmt.Errorf("cannot ping the docker daemon, is it running? %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -55,7 +55,6 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
||||
switch label.Schema {
|
||||
case SchemeDocker:
|
||||
// "//" will be ignored
|
||||
// TODO maybe we should use 'ubuntu-18.04:docker:node:16-buster' instead
|
||||
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
||||
case SchemeHost:
|
||||
platforms[label.Name] = "-self-hosted"
|
||||
@@ -80,5 +79,28 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
||||
// So the runner receives a task with a label that the runner doesn't have,
|
||||
// it happens when the user have edited the label of the runner in the web UI.
|
||||
// TODO: it may be not correct, what if the runner is used as host mode only?
|
||||
return "node:16-bullseye"
|
||||
return "docker.gitea.com/runner-images:ubuntu-latest"
|
||||
}
|
||||
|
||||
func (l Labels) Names() []string {
|
||||
names := make([]string, 0, len(l))
|
||||
for _, label := range l {
|
||||
names = append(names, label.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (l Labels) ToStrings() []string {
|
||||
ls := make([]string, 0, len(l))
|
||||
for _, label := range l {
|
||||
lbl := label.Name
|
||||
if label.Schema != "" {
|
||||
lbl += ":" + label.Schema
|
||||
if label.Arg != "" {
|
||||
lbl += ":" + label.Arg
|
||||
}
|
||||
}
|
||||
ls = append(ls, lbl)
|
||||
}
|
||||
return ls
|
||||
}
|
||||
|
||||
@@ -55,9 +55,8 @@ func TestParse(t *testing.T) {
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.DeepEqual(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ package report
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
retry "github.com/avast/retry-go/v4"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/avast/retry-go/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -31,8 +32,14 @@ type Reporter struct {
|
||||
logOffset int
|
||||
logRows []*runnerv1.LogRow
|
||||
logReplacer *strings.Replacer
|
||||
state *runnerv1.TaskState
|
||||
stateM sync.RWMutex
|
||||
oldnew []string
|
||||
|
||||
state *runnerv1.TaskState
|
||||
stateMu sync.RWMutex
|
||||
outputs sync.Map
|
||||
|
||||
debugOutputEnabled bool
|
||||
stopCommandEndToken string
|
||||
}
|
||||
|
||||
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task) *Reporter {
|
||||
@@ -40,24 +47,34 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
||||
if v := task.Context.Fields["token"].GetStringValue(); v != "" {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
for _, v := range task.Secrets {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
|
||||
return &Reporter{
|
||||
rv := &Reporter{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
client: client,
|
||||
oldnew: oldnew,
|
||||
logReplacer: strings.NewReplacer(oldnew...),
|
||||
state: &runnerv1.TaskState{
|
||||
Id: task.Id,
|
||||
},
|
||||
}
|
||||
|
||||
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
|
||||
rv.debugOutputEnabled = true
|
||||
}
|
||||
|
||||
return rv
|
||||
}
|
||||
|
||||
func (r *Reporter) ResetSteps(l int) {
|
||||
r.stateM.Lock()
|
||||
defer r.stateM.Unlock()
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
for i := 0; i < l; i++ {
|
||||
r.state.Steps = append(r.state.Steps, &runnerv1.StepState{
|
||||
Id: int64(i),
|
||||
@@ -69,9 +86,16 @@ func (r *Reporter) Levels() []log.Level {
|
||||
return log.AllLevels
|
||||
}
|
||||
|
||||
func appendIfNotNil[T any](s []*T, v *T) []*T {
|
||||
if v != nil {
|
||||
return append(s, v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
r.stateM.Lock()
|
||||
defer r.stateM.Unlock()
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
|
||||
log.WithFields(entry.Data).Trace(entry.Message)
|
||||
|
||||
@@ -90,12 +114,15 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
for _, s := range r.state.Steps {
|
||||
if s.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
s.Result = runnerv1.Result_RESULT_CANCELLED
|
||||
if jobResult == runnerv1.Result_RESULT_SKIPPED {
|
||||
s.Result = runnerv1.Result_RESULT_SKIPPED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !r.duringSteps() {
|
||||
r.logRows = append(r.logRows, r.parseLogRow(entry))
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -108,7 +135,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
}
|
||||
if step == nil {
|
||||
if !r.duringSteps() {
|
||||
r.logRows = append(r.logRows, r.parseLogRow(entry))
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -116,16 +143,24 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = timestamppb.New(timestamp)
|
||||
}
|
||||
|
||||
// Force reporting log errors as raw output to prevent silent failures
|
||||
if entry.Level == log.ErrorLevel {
|
||||
entry.Data["raw_output"] = true
|
||||
}
|
||||
|
||||
if v, ok := entry.Data["raw_output"]; ok {
|
||||
if rawOutput, ok := v.(bool); ok && rawOutput {
|
||||
if step.LogLength == 0 {
|
||||
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
||||
if row := r.parseLogRow(entry); row != nil {
|
||||
if step.LogLength == 0 {
|
||||
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
||||
}
|
||||
step.LogLength++
|
||||
r.logRows = append(r.logRows, row)
|
||||
}
|
||||
step.LogLength++
|
||||
r.logRows = append(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
} else if !r.duringSteps() {
|
||||
r.logRows = append(r.logRows, r.parseLogRow(entry))
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
if v, ok := entry.Data["stepResult"]; ok {
|
||||
if stepResult, ok := r.parseResult(v); ok {
|
||||
@@ -155,9 +190,13 @@ func (r *Reporter) RunDaemon() {
|
||||
}
|
||||
|
||||
func (r *Reporter) Logf(format string, a ...interface{}) {
|
||||
r.stateM.Lock()
|
||||
defer r.stateM.Unlock()
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
|
||||
r.logf(format, a...)
|
||||
}
|
||||
|
||||
func (r *Reporter) logf(format string, a ...interface{}) {
|
||||
if !r.duringSteps() {
|
||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
||||
Time: timestamppb.Now(),
|
||||
@@ -166,10 +205,30 @@ func (r *Reporter) Logf(format string, a ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) SetOutputs(outputs map[string]string) {
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
|
||||
for k, v := range outputs {
|
||||
if len(k) > 255 {
|
||||
r.logf("ignore output because the key is too long: %q", k)
|
||||
continue
|
||||
}
|
||||
if l := len(v); l > 1024*1024 {
|
||||
log.Println("ignore output because the value is too long:", k, l)
|
||||
r.logf("ignore output because the value %q is too long: %d", k, l)
|
||||
}
|
||||
if _, ok := r.outputs.Load(k); ok {
|
||||
continue
|
||||
}
|
||||
r.outputs.Store(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) Close(lastWords string) error {
|
||||
r.closed = true
|
||||
|
||||
r.stateM.Lock()
|
||||
r.stateMu.Lock()
|
||||
if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
if lastWords == "" {
|
||||
lastWords = "Early termination"
|
||||
@@ -184,14 +243,14 @@ func (r *Reporter) Close(lastWords string) error {
|
||||
Time: timestamppb.Now(),
|
||||
Content: lastWords,
|
||||
})
|
||||
return nil
|
||||
r.state.StoppedAt = timestamppb.Now()
|
||||
} else if lastWords != "" {
|
||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
||||
Time: timestamppb.Now(),
|
||||
Content: lastWords,
|
||||
})
|
||||
}
|
||||
r.stateM.Unlock()
|
||||
r.stateMu.Unlock()
|
||||
|
||||
return retry.Do(func() error {
|
||||
if err := r.ReportLog(true); err != nil {
|
||||
@@ -205,9 +264,9 @@ func (r *Reporter) ReportLog(noMore bool) error {
|
||||
r.clientM.Lock()
|
||||
defer r.clientM.Unlock()
|
||||
|
||||
r.stateM.RLock()
|
||||
r.stateMu.RLock()
|
||||
rows := r.logRows
|
||||
r.stateM.RUnlock()
|
||||
r.stateMu.RUnlock()
|
||||
|
||||
resp, err := r.client.UpdateLog(r.ctx, connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
TaskId: r.state.Id,
|
||||
@@ -224,10 +283,10 @@ func (r *Reporter) ReportLog(noMore bool) error {
|
||||
return fmt.Errorf("submitted logs are lost")
|
||||
}
|
||||
|
||||
r.stateM.Lock()
|
||||
r.stateMu.Lock()
|
||||
r.logRows = r.logRows[ack-r.logOffset:]
|
||||
r.logOffset = ack
|
||||
r.stateM.Unlock()
|
||||
r.stateMu.Unlock()
|
||||
|
||||
if noMore && ack < r.logOffset+len(rows) {
|
||||
return fmt.Errorf("not all logs are submitted")
|
||||
@@ -240,21 +299,45 @@ func (r *Reporter) ReportState() error {
|
||||
r.clientM.Lock()
|
||||
defer r.clientM.Unlock()
|
||||
|
||||
r.stateM.RLock()
|
||||
r.stateMu.RLock()
|
||||
state := proto.Clone(r.state).(*runnerv1.TaskState)
|
||||
r.stateM.RUnlock()
|
||||
r.stateMu.RUnlock()
|
||||
|
||||
outputs := make(map[string]string)
|
||||
r.outputs.Range(func(k, v interface{}) bool {
|
||||
if val, ok := v.(string); ok {
|
||||
outputs[k.(string)] = val
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
resp, err := r.client.UpdateTask(r.ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||
State: state,
|
||||
State: state,
|
||||
Outputs: outputs,
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, k := range resp.Msg.SentOutputs {
|
||||
r.outputs.Store(k, struct{}{})
|
||||
}
|
||||
|
||||
if resp.Msg.State != nil && resp.Msg.State.Result == runnerv1.Result_RESULT_CANCELLED {
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
var noSent []string
|
||||
r.outputs.Range(func(k, v interface{}) bool {
|
||||
if _, ok := v.(string); ok {
|
||||
noSent = append(noSent, k.(string))
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(noSent) > 0 {
|
||||
return fmt.Errorf("there are still outputs that have not been sent: %v", noSent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -288,11 +371,70 @@ func (r *Reporter) parseResult(result interface{}) (runnerv1.Result, bool) {
|
||||
return ret, ok
|
||||
}
|
||||
|
||||
var cmdRegex = regexp.MustCompile(`^::([^ :]+)( .*)?::(.*)$`)
|
||||
|
||||
func (r *Reporter) handleCommand(originalContent, command, parameters, value string) *string {
|
||||
if r.stopCommandEndToken != "" && command != r.stopCommandEndToken {
|
||||
return &originalContent
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "add-mask":
|
||||
r.addMask(value)
|
||||
return nil
|
||||
case "debug":
|
||||
if r.debugOutputEnabled {
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
|
||||
case "notice":
|
||||
// Not implemented yet, so just return the original content.
|
||||
return &originalContent
|
||||
case "warning":
|
||||
// Not implemented yet, so just return the original content.
|
||||
return &originalContent
|
||||
case "error":
|
||||
// Not implemented yet, so just return the original content.
|
||||
return &originalContent
|
||||
case "group":
|
||||
// Returning the original content, because I think the frontend
|
||||
// will use it when rendering the output.
|
||||
return &originalContent
|
||||
case "endgroup":
|
||||
// Ditto
|
||||
return &originalContent
|
||||
case "stop-commands":
|
||||
r.stopCommandEndToken = value
|
||||
return nil
|
||||
case r.stopCommandEndToken:
|
||||
r.stopCommandEndToken = ""
|
||||
return nil
|
||||
}
|
||||
return &originalContent
|
||||
}
|
||||
|
||||
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||
content := strings.TrimRightFunc(entry.Message, func(r rune) bool { return r == '\r' || r == '\n' })
|
||||
|
||||
matches := cmdRegex.FindStringSubmatch(content)
|
||||
if matches != nil {
|
||||
if output := r.handleCommand(content, matches[1], matches[2], matches[3]); output != nil {
|
||||
content = *output
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
content = r.logReplacer.Replace(content)
|
||||
|
||||
return &runnerv1.LogRow{
|
||||
Time: timestamppb.New(entry.Time),
|
||||
Content: content,
|
||||
Content: strings.ToValidUTF8(content, "?"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) addMask(msg string) {
|
||||
r.oldnew = append(r.oldnew, msg, "***")
|
||||
r.logReplacer = strings.NewReplacer(r.oldnew...)
|
||||
}
|
||||
|
||||
197
internal/pkg/report/reporter_test.go
Normal file
197
internal/pkg/report/reporter_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
connect_go "connectrpc.com/connect"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
|
||||
)
|
||||
|
||||
func TestReporter_parseLogRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
debugOutputEnabled bool
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"No command", false,
|
||||
[]string{"Hello, world!"},
|
||||
[]string{"Hello, world!"},
|
||||
},
|
||||
{
|
||||
"Add-mask", false,
|
||||
[]string{
|
||||
"foo mysecret bar",
|
||||
"::add-mask::mysecret",
|
||||
"foo mysecret bar",
|
||||
},
|
||||
[]string{
|
||||
"foo mysecret bar",
|
||||
"<nil>",
|
||||
"foo *** bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Debug enabled", true,
|
||||
[]string{
|
||||
"::debug::GitHub Actions runtime token access controls",
|
||||
},
|
||||
[]string{
|
||||
"GitHub Actions runtime token access controls",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Debug not enabled", false,
|
||||
[]string{
|
||||
"::debug::GitHub Actions runtime token access controls",
|
||||
},
|
||||
[]string{
|
||||
"<nil>",
|
||||
},
|
||||
},
|
||||
{
|
||||
"notice", false,
|
||||
[]string{
|
||||
"::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
[]string{
|
||||
"::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
},
|
||||
{
|
||||
"warning", false,
|
||||
[]string{
|
||||
"::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
[]string{
|
||||
"::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
},
|
||||
{
|
||||
"error", false,
|
||||
[]string{
|
||||
"::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
[]string{
|
||||
"::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
|
||||
},
|
||||
},
|
||||
{
|
||||
"group", false,
|
||||
[]string{
|
||||
"::group::",
|
||||
"::endgroup::",
|
||||
},
|
||||
[]string{
|
||||
"::group::",
|
||||
"::endgroup::",
|
||||
},
|
||||
},
|
||||
{
|
||||
"stop-commands", false,
|
||||
[]string{
|
||||
"::add-mask::foo",
|
||||
"::stop-commands::myverycoolstoptoken",
|
||||
"::add-mask::bar",
|
||||
"::debug::Stuff",
|
||||
"myverycoolstoptoken",
|
||||
"::add-mask::baz",
|
||||
"::myverycoolstoptoken::",
|
||||
"::add-mask::wibble",
|
||||
"foo bar baz wibble",
|
||||
},
|
||||
[]string{
|
||||
"<nil>",
|
||||
"<nil>",
|
||||
"::add-mask::bar",
|
||||
"::debug::Stuff",
|
||||
"myverycoolstoptoken",
|
||||
"::add-mask::baz",
|
||||
"<nil>",
|
||||
"<nil>",
|
||||
"*** bar baz ***",
|
||||
},
|
||||
},
|
||||
{
|
||||
"unknown command", false,
|
||||
[]string{
|
||||
"::set-mask::foo",
|
||||
},
|
||||
[]string{
|
||||
"::set-mask::foo",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &Reporter{
|
||||
logReplacer: strings.NewReplacer(),
|
||||
debugOutputEnabled: tt.debugOutputEnabled,
|
||||
}
|
||||
for idx, arg := range tt.args {
|
||||
rv := r.parseLogRow(&log.Entry{Message: arg})
|
||||
got := "<nil>"
|
||||
|
||||
if rv != nil {
|
||||
got = rv.Content
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want[idx], got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReporter_Fire(t *testing.T) {
|
||||
t.Run("ignore command lines", func(t *testing.T) {
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
|
||||
t.Logf("Received UpdateLog: %s", req.Msg.String())
|
||||
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
|
||||
}), nil
|
||||
})
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
t.Logf("Received UpdateTask: %s", req.Msg.String())
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
taskCtx, err := structpb.NewStruct(map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
|
||||
Context: taskCtx,
|
||||
})
|
||||
defer func() {
|
||||
assert.NoError(t, reporter.Close(""))
|
||||
}()
|
||||
reporter.ResetSteps(5)
|
||||
|
||||
dataStep0 := map[string]interface{}{
|
||||
"stage": "Main",
|
||||
"stepNumber": 0,
|
||||
"raw_output": true,
|
||||
}
|
||||
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
|
||||
assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
|
||||
|
||||
assert.Equal(t, int64(3), reporter.state.Steps[0].LogLength)
|
||||
})
|
||||
}
|
||||
6
renovate.json5
Normal file
6
renovate.json5
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"local>gitea/renovate-config"
|
||||
]
|
||||
}
|
||||
@@ -6,14 +6,33 @@ fi
|
||||
|
||||
cd /data
|
||||
|
||||
RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-'.runner'}
|
||||
|
||||
CONFIG_ARG=""
|
||||
if [[ ! -z "${CONFIG_FILE}" ]]; then
|
||||
CONFIG_ARG="--config ${CONFIG_FILE}"
|
||||
fi
|
||||
EXTRA_ARGS=""
|
||||
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
|
||||
EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}"
|
||||
fi
|
||||
if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then
|
||||
EXTRA_ARGS="${EXTRA_ARGS} --ephemeral"
|
||||
fi
|
||||
RUN_ARGS=""
|
||||
if [[ ! -z "${GITEA_RUNNER_ONCE}" ]]; then
|
||||
RUN_ARGS="${RUN_ARGS} --once"
|
||||
fi
|
||||
|
||||
# In case no token is set, it's possible to read the token from a file, i.e. a Docker Secret
|
||||
if [[ -z "${GITEA_RUNNER_REGISTRATION_TOKEN}" ]] && [[ -f "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE}" ]]; then
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=$(cat "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE}")
|
||||
fi
|
||||
|
||||
# Use the same ENV variable names as https://github.com/vegardit/docker-gitea-act-runner
|
||||
test -f "$RUNNER_STATE_FILE" || echo "$RUNNER_STATE_FILE is missing or not a regular file"
|
||||
|
||||
if [[ ! -s .runner ]]; then
|
||||
if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
|
||||
try=$((try + 1))
|
||||
success=0
|
||||
|
||||
@@ -26,10 +45,7 @@ if [[ ! -s .runner ]]; then
|
||||
--instance "${GITEA_INSTANCE_URL}" \
|
||||
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
|
||||
--name "${GITEA_RUNNER_NAME:-`hostname`}" \
|
||||
--labels "${GITEA_RUNNER_LABELS}" \
|
||||
${CONFIG_ARG} --no-interactive > /tmp/reg.log 2>&1
|
||||
|
||||
cat /tmp/reg.log
|
||||
${CONFIG_ARG} ${EXTRA_ARGS} --no-interactive 2>&1 | tee /tmp/reg.log
|
||||
|
||||
cat /tmp/reg.log | grep 'Runner registered successfully' > /dev/null
|
||||
if [[ $? -eq 0 ]]; then
|
||||
@@ -41,5 +57,8 @@ if [[ ! -s .runner ]]; then
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Prevent reading the token from the act_runner process
|
||||
unset GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
unset GITEA_RUNNER_REGISTRATION_TOKEN_FILE
|
||||
|
||||
act_runner daemon ${CONFIG_ARG}
|
||||
exec act_runner daemon ${CONFIG_ARG} ${RUN_ARGS}
|
||||
3
scripts/s6/act_runner/finish
Executable file
3
scripts/s6/act_runner/finish
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-svscanctl -t /etc/s6
|
||||
5
scripts/s6/act_runner/run
Executable file
5
scripts/s6/act_runner/run
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
s6-svwait -U /etc/s6/docker
|
||||
|
||||
exec run.sh
|
||||
6
scripts/s6/docker/data/check
Executable file
6
scripts/s6/docker/data/check
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
echo "Waiting for Docker daemon to start..."
|
||||
exit 1
|
||||
fi
|
||||
4
scripts/s6/docker/finish
Executable file
4
scripts/s6/docker/finish
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-svscanctl -t /etc/s6
|
||||
|
||||
1
scripts/s6/docker/notification-fd
Normal file
1
scripts/s6/docker/notification-fd
Normal file
@@ -0,0 +1 @@
|
||||
3
|
||||
3
scripts/s6/docker/run
Executable file
3
scripts/s6/docker/run
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-notifyoncheck dockerd-entrypoint.sh
|
||||
Reference in New Issue
Block a user