mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-09 22:15:01 +02:00
Compare commits
313
Commits
b53c54f73d
..
v3.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47366f8f34 | ||
|
|
b7aeda6e7f | ||
|
|
aced51b4d5 | ||
|
|
34bfa19150 | ||
|
|
96d9f491db | ||
|
|
14ec00b66e | ||
|
|
68c6a5b4f1 | ||
|
|
0cd0e52a24 | ||
|
|
47d5b5ad03 | ||
|
|
2398d4a527 | ||
|
|
b7a3bf98bc | ||
|
|
da4037899a | ||
|
|
e4fe49dba4 | ||
|
|
41c72216bf | ||
|
|
3f7fd16ea1 | ||
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 | ||
|
|
fc0e03e5a9 | ||
|
|
333eb17d19 | ||
|
|
c3b39e0d99 | ||
|
|
78a74f78f8 | ||
|
|
8c519ce318 | ||
|
|
de43c84203 | ||
|
|
3c5ef1721a | ||
|
|
94ab020204 | ||
|
|
b4a64b97dd | ||
|
|
26f9fb12af | ||
|
|
c9c4957e38 | ||
|
|
b1a02cdd5d | ||
|
|
0fd8602ac3 | ||
|
|
6133d64270 | ||
|
|
c43cbe87ca | ||
|
|
7bec310002 | ||
|
|
8af385d147 | ||
|
|
46f22c78d2 | ||
|
|
068afc3996 | ||
|
|
0e8896c52a | ||
|
|
89467c9dd0 | ||
|
|
0c08b0f2da | ||
|
|
aa7a29a157 | ||
|
|
ad967330a8 | ||
|
|
60177008a5 | ||
|
|
58c5eb8d21 | ||
|
|
d6882b3df5 | ||
|
|
7e7e3ef1a6 | ||
|
|
16357a34b2 | ||
|
|
d53538ac38 | ||
|
|
554b3b7671 | ||
|
|
65756d60b3 | ||
|
|
be9b4502d6 | ||
|
|
1d74ae636a | ||
|
|
b12d02c25f | ||
|
|
f2e0cf9131 | ||
|
|
0ee4643d4a | ||
|
|
e774003c18 | ||
|
|
eeb479ea89 | ||
|
|
eba33e178d | ||
|
|
3396021e0f | ||
|
|
745b0ab6e4 | ||
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 | ||
|
|
40e021309a | ||
|
|
d3b3519dea | ||
|
|
6bdcb54828 | ||
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 | ||
|
|
33e6d1d8ff | ||
|
|
56979e6ab8 | ||
|
|
bf99e6a758 | ||
|
|
740a3d4db4 | ||
|
|
822af5029f | ||
|
|
526c46b485 | ||
|
|
355289bc54 | ||
|
|
e583b0706b | ||
|
|
8ad84cd96a | ||
|
|
0a2f28244d | ||
|
|
443b0e336c | ||
|
|
53c4db6a4b | ||
|
|
1073c8bfec | ||
|
|
ff7d9ca8d0 | ||
|
|
984b47c716 | ||
|
|
c749e52bb7 | ||
|
|
f17b6b9fc3 | ||
|
|
c7c4bd600a | ||
|
|
abec931d98 | ||
|
|
270ea41232 | ||
|
|
0b9f251b6a | ||
|
|
273f6b4247 | ||
|
|
47ee45412a | ||
|
|
38b69bb214 | ||
|
|
1c62c0635f | ||
|
|
0e0c54b272 | ||
|
|
d6fbe75721 | ||
|
|
b30204aa94 | ||
|
|
7b5ebe9618 | ||
|
|
4317662a38 | ||
|
|
2208e7ec63 | ||
|
|
fab9714f9a | ||
|
|
10475db58a | ||
|
|
9e738c203c | ||
|
|
6023928876 | ||
|
|
014ce438c1 | ||
|
|
cf7e29c10d | ||
|
|
8a99506fed | ||
|
|
5873b8b054 | ||
|
|
5464d33eef | ||
|
|
3c5f03ff8f | ||
|
|
880e9755d9 | ||
|
|
8d7cf48a6f | ||
|
|
f23605c614 | ||
|
|
00b7fec80f | ||
|
|
dda5841af8 | ||
|
|
32bed52686 | ||
|
|
a7e972d8de | ||
|
|
763b38ece3 | ||
|
|
a1f13cb970 | ||
|
|
1e3ab0c40a | ||
|
|
295eecb9af | ||
|
|
ef6ca957b5 | ||
|
|
8088df52b9 | ||
|
|
3ea7d39690 | ||
|
|
861d351845 | ||
|
|
cce8543d06 | ||
|
|
75643645f0 | ||
|
|
dff63b3ecc | ||
|
|
a5d9fe9651 | ||
|
|
d607f3b342 | ||
|
|
5e59402fb2 | ||
|
|
dfeb463904 | ||
|
|
594c9ade7c | ||
|
|
2a4d56c650 | ||
|
|
a22119cf88 | ||
|
|
b68ecf2580 | ||
|
|
d1434237c2 | ||
|
|
35c65e2b14 | ||
|
|
c45a4e6d32 | ||
|
|
68d9fc45c9 | ||
|
|
b1c873a66b | ||
|
|
1d6e7879c8 | ||
|
|
13dc9386fe | ||
|
|
8e6b3be96a | ||
|
|
e5e53c732e | ||
|
|
2516573592 | ||
|
|
35834bf817 | ||
|
|
11a5dc8936 | ||
|
|
f09fafcb0a | ||
|
|
801e5cf4d5 | ||
|
|
3f05040438 | ||
|
|
59d90bff26 | ||
|
|
5edc4ba550 | ||
|
|
547a0ff297 | ||
|
|
f2b4dbf05f | ||
|
|
bad4239d18 | ||
|
|
589db33e70 | ||
|
|
1032f857a1 | ||
|
|
e56b984c04 | ||
|
|
fa5334eb24 | ||
|
|
7c6f1261d4 | ||
|
|
fbd6316928 | ||
|
|
ade5b8202e | ||
|
|
a31f3962c0 | ||
|
|
04244fc3f7 | ||
|
|
cb58492678 | ||
|
|
9faadad0ce | ||
|
|
352096c5bf | ||
|
|
b5c50bb3ab | ||
|
|
8af9a2b47a | ||
|
|
fab2d6ae04 | ||
|
|
15dd63a839 | ||
|
|
9aafec169b | ||
|
|
f923badec7 | ||
|
|
48944e136c | ||
|
|
40dcee0991 | ||
|
|
f33e5a6245 | ||
|
|
f2d545565f | ||
|
|
90c1275f0e | ||
|
|
3232358e71 | ||
|
|
2e98baa34a | ||
|
|
505907eb2a | ||
|
|
9933ea0d92 | ||
|
|
5dd5436169 | ||
|
|
28740d7788 | ||
|
|
ddf9159a8f | ||
|
|
43e6958fa3 | ||
|
|
c0f19d9a26 | ||
|
|
495185446f | ||
|
|
3a07d231a0 | ||
|
|
5417d3ac67 | ||
|
|
f56fd693ee | ||
|
|
34f68b3c18 | ||
|
|
ac6e4b7517 | ||
|
|
91852faf93 | ||
|
|
39509e9ad0 | ||
|
|
9924aea786 | ||
|
|
65c232c4a5 | ||
|
|
5da4954b65 | ||
|
|
ec091ad269 | ||
|
|
1656206765 | ||
|
|
6cdf1e5788 | ||
|
|
ab381649da | ||
|
|
38e7e9e939 | ||
|
|
2ab806053c | ||
|
|
6a090f67e5 | ||
|
|
517d11c671 | ||
|
|
e1b1e81124 | ||
|
|
64876e3696 | ||
|
|
3fa1dba92b | ||
|
|
9725f60394 | ||
|
|
a79d81989f | ||
|
|
655f578563 | ||
|
|
0054a45d1b | ||
|
|
79a7577c15 | ||
|
|
a28ebf0a48 | ||
|
|
2b860ce371 | ||
|
|
3a9e7d18de | ||
|
|
b4edc952d9 | ||
|
|
f1213213d8 | ||
|
|
15045b4fc0 | ||
|
|
67918333fa | ||
|
|
c93462e19f | ||
|
|
f3264cac20 | ||
|
|
4699c3b689 | ||
|
|
22d91e3ac3 | ||
|
|
cdc6d4bc6a | ||
|
|
2069b04779 | ||
|
|
3813f40cba | ||
|
|
eb19987893 | ||
|
|
545802b97b | ||
|
|
515c2c429d | ||
|
|
a165e17878 | ||
|
|
56e103b4ba | ||
|
|
422cbdf446 | ||
|
|
8c56bd3aa5 | ||
|
|
a94498b482 | ||
|
|
fe76a035ad | ||
|
|
6ce5c93cc8 | ||
|
|
92b4d73376 | ||
|
|
183bb7af1b | ||
|
|
a72822b3f8 | ||
|
|
9283cfc9b1 | ||
|
|
27846050ae | ||
|
|
ed9b6643ca | ||
|
|
a94a01bff2 | ||
|
|
229dbaf153 | ||
|
|
a18648ee73 | ||
|
|
518d8c96f3 | ||
|
|
0c1f2edb99 | ||
|
|
721857e4a0 | ||
|
|
6b1010ad07 | ||
|
|
e12252a43a | ||
|
|
8609522aa4 | ||
|
|
6a876c4f99 | ||
|
|
de529139af | ||
|
|
d3a56cdb69 | ||
|
|
9bdddf18e0 | ||
|
|
ac1ba34518 | ||
|
|
5c4a96bcb7 | ||
|
|
62abf4fe11 | ||
|
|
cfedc518ca | ||
|
|
5e76853b55 | ||
|
|
2eb4de02ee | ||
|
|
342ad6a51a | ||
|
|
568f053723 | ||
|
|
8f12a6c947 | ||
|
|
83fb85f702 | ||
|
|
3daf313205 | ||
|
|
7c5400d75b | ||
|
|
929ea6df75 | ||
|
|
f6a8a0e643 | ||
|
|
556fd20aed | ||
|
|
a8298365fe | ||
|
|
1dda0aec69 | ||
|
|
49e204166d | ||
|
|
a36b003f7a | ||
|
|
0671d16694 | ||
|
|
881dbdb81b | ||
|
|
1252e551b8 | ||
|
|
c614d8b96c | ||
|
|
84b6649b8b | ||
|
|
dca7801682 | ||
|
|
4b99ed8916 | ||
|
|
e46ede1b17 | ||
|
|
1ba076d321 | ||
|
|
0efa2d5e63 | ||
|
|
0a37a03f2e | ||
|
|
88cce47022 | ||
|
|
7920109e89 | ||
|
|
4cacc14d22 | ||
|
|
c6b8548d35 | ||
|
|
64cae197a4 | ||
|
|
7fb84a54a8 | ||
|
|
70cc6c017b | ||
|
|
d7e9ea75fc | ||
|
|
b9c20dcaa4 | ||
|
|
97629ae8af | ||
|
|
b9a9812ad9 | ||
|
|
113c3e98fb | ||
|
|
7815eec33b | ||
|
|
c051090583 | ||
|
|
0fa1fe0310 |
@@ -1,6 +0,0 @@
|
|||||||
[codespell]
|
|
||||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
|
||||||
skip = .git*,go.sum,package-lock.json,*.min.*,.codespellrc,testdata,./pkg/runner/hashfiles/index.js
|
|
||||||
check-hidden = true
|
|
||||||
ignore-regex = .*Te\{0\}st.*
|
|
||||||
# ignore-words-list =
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Folders
|
||||||
|
_obj
|
||||||
|
_test
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
.idea
|
||||||
|
# Goland's output filename can not be set manually
|
||||||
|
/go_build_*
|
||||||
|
|
||||||
|
# MS VSCode
|
||||||
|
.vscode
|
||||||
|
__debug_bin*
|
||||||
|
|
||||||
|
# Architecture specific extensions/prefixes
|
||||||
|
*.[568vq]
|
||||||
|
[568vq].out
|
||||||
|
|
||||||
|
*.cgo1.go
|
||||||
|
*.cgo2.c
|
||||||
|
_cgo_defun.c
|
||||||
|
_cgo_gotypes.go
|
||||||
|
_cgo_export.*
|
||||||
|
|
||||||
|
_testmain.go
|
||||||
|
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
|
||||||
|
*coverage.out
|
||||||
|
coverage.all
|
||||||
|
coverage.txt
|
||||||
|
cpu.out
|
||||||
|
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
|
||||||
|
/gitea-runner
|
||||||
|
/debug
|
||||||
|
|
||||||
|
/bin
|
||||||
|
/dist
|
||||||
|
/.env
|
||||||
|
/.runner
|
||||||
|
/config.yaml
|
||||||
|
/Dockerfile
|
||||||
|
.DS_Store
|
||||||
@@ -12,5 +12,8 @@ insert_final_newline = true
|
|||||||
[*.{go}]
|
[*.{go}]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
|
[go.*]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
name: checks
|
|
||||||
on: [pull_request, workflow_dispatch]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
cancel-in-progress: true
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
ACT_OWNER: ${{ github.repository_owner }}
|
|
||||||
ACT_REPOSITORY: ${{ github.repository }}
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
NO_QEMU: 1
|
|
||||||
NO_EXTERNAL_IP: 1
|
|
||||||
DOOD: 1
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: golangci/[email protected]
|
|
||||||
with:
|
|
||||||
version: v2.1.6
|
|
||||||
- uses: megalinter/megalinter/flavors/[email protected]
|
|
||||||
env:
|
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VALIDATE_ALL_CODEBASE: false
|
|
||||||
GITHUB_STATUS_REPORTER: ${{ !env.ACT }}
|
|
||||||
GITHUB_COMMENT_REPORTER: ${{ !env.ACT }}
|
|
||||||
|
|
||||||
test-linux:
|
|
||||||
name: test-linux
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Cleanup Docker Engine
|
|
||||||
run: |
|
|
||||||
docker ps -a --format '{{ if eq (truncate .Names 4) "act-" }}
|
|
||||||
{{ .ID }}
|
|
||||||
{{end}}' | xargs -r docker rm -f || :
|
|
||||||
docker volume ls --format '{{ if eq (truncate .Name 4) "act-" }}
|
|
||||||
{{ .Name }}
|
|
||||||
{{ end }}' | xargs -r docker volume rm -f || :
|
|
||||||
docker images --format '{{ if eq (truncate .Repository 4) "act-" }}
|
|
||||||
{{ .ID }}
|
|
||||||
{{ end }}' | xargs -r docker rmi -f || :
|
|
||||||
docker images -q | xargs -r docker rmi || :
|
|
||||||
- name: Set up QEMU
|
|
||||||
if: '!env.NO_QEMU'
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: Install gotestfmt
|
|
||||||
run: go install github.com/gotesttools/gotestfmt/v2/cmd/[email protected]
|
|
||||||
# Regressions by Gitea Actions CI Migration
|
|
||||||
# GITHUB_REPOSITORY contains the server url
|
|
||||||
# ACTIONS_RUNTIME_URL provided to every step, act does not override
|
|
||||||
- name: Run Tests
|
|
||||||
run: |
|
|
||||||
unset ACTIONS_RUNTIME_URL
|
|
||||||
unset ACTIONS_RESULTS_URL
|
|
||||||
unset ACTIONS_RUNTIME_TOKEN
|
|
||||||
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY#${SERVER_URL%/}/}"
|
|
||||||
export ACT_REPOSITORY="${GITHUB_REPOSITORY#${SERVER_URL%/}/}"
|
|
||||||
export ACT_OWNER="${ACT_OWNER#${SERVER_URL%/}/}"
|
|
||||||
env
|
|
||||||
go test -json -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m ./... | gotestfmt -hide successful-packages,empty-packages 2>&1
|
|
||||||
env:
|
|
||||||
SERVER_URL: ${{ github.server_url }}
|
|
||||||
- name: Run act from cli
|
|
||||||
run: go run main.go -P ubuntu-latest=node:16-buster-slim -C ./pkg/runner/testdata/ -W ./basic/push.yml
|
|
||||||
- name: Run act from cli without docker support
|
|
||||||
run: go run -tags WITHOUT_DOCKER main.go -P ubuntu-latest=-self-hosted -C ./pkg/runner/testdata/ -W ./local-action-js/push.yml
|
|
||||||
|
|
||||||
snapshot:
|
|
||||||
name: snapshot
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: GoReleaser
|
|
||||||
id: goreleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: v2
|
|
||||||
args: release --snapshot --clean
|
|
||||||
- name: Setup Node
|
|
||||||
continue-on-error: true
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Install @actions/[email protected]
|
|
||||||
continue-on-error: true
|
|
||||||
run: npm install @actions/[email protected]
|
|
||||||
- name: Upload All
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// We do not use features depending on GITHUB_API_URL so we can hardcode it to avoid the GHES no support error
|
|
||||||
process.env["GITHUB_SERVER_URL"] = "https://github.com";
|
|
||||||
const {DefaultArtifactClient} = require('@actions/artifact');
|
|
||||||
const aartifact = new DefaultArtifactClient();
|
|
||||||
var artifacts = JSON.parse(process.env.ARTIFACTS);
|
|
||||||
for(var artifact of artifacts) {
|
|
||||||
if(artifact.type === "Binary") {
|
|
||||||
const {id, size} = await aartifact.uploadArtifact(
|
|
||||||
// name of the artifact
|
|
||||||
`${artifact.name}-${artifact.target}`,
|
|
||||||
// files to include (supports absolute and relative paths)
|
|
||||||
[artifact.path],
|
|
||||||
process.cwd(),
|
|
||||||
{
|
|
||||||
// optional: how long to retain the artifact
|
|
||||||
// if unspecified, defaults to repository/org retention settings (the limit of this value)
|
|
||||||
retentionDays: 10
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log(`Created artifact with id: ${id} (bytes: ${size}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env:
|
|
||||||
ARTIFACTS: ${{ steps.goreleaser.outputs.artifacts }}
|
|
||||||
- name: Chocolatey
|
|
||||||
uses: ./.github/actions/choco
|
|
||||||
with:
|
|
||||||
version: v0.0.0-pr
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: pr-title
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-pr-title:
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
- run: make lint-pr-title
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
@@ -17,14 +17,26 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded
|
||||||
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
|
- name: check R2 configuration
|
||||||
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
|
env:
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release --nightly
|
args: release --nightly
|
||||||
@@ -35,6 +47,10 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
GORELEASER_FORCE_TOKEN: "gitea"
|
GORELEASER_FORCE_TOKEN: "gitea"
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -43,43 +59,54 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
variant:
|
variant:
|
||||||
|
# The basic image is built from source and can target any arch the
|
||||||
|
# toolchain supports. The dind variants are limited to the arches the
|
||||||
|
# docker:dind base image publishes.
|
||||||
- target: basic
|
- target: basic
|
||||||
tag_suffix: ""
|
tag_suffix: ""
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||||
- target: dind
|
- target: dind
|
||||||
tag_suffix: "-dind"
|
tag_suffix: "-dind"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
- target: dind-rootless
|
- target: dind-rootless
|
||||||
tag_suffix: "-dind-rootless"
|
tag_suffix: "-dind-rootless"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Echo the tag
|
- name: Echo the tag
|
||||||
run: echo "${{ env.DOCKER_ORG }}/act_runner:nightly${{ matrix.variant.tag_suffix }}"
|
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
target: ${{ matrix.variant.target }}
|
target: ${{ matrix.variant.target }}
|
||||||
platforms: |
|
platforms: ${{ matrix.variant.platforms }}
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_ORG }}/act_runner:nightly${{ matrix.variant.tag_suffix }}
|
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
|
|||||||
@@ -9,21 +9,33 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
- uses: actions/setup-go@v6
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded
|
||||||
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
|
- name: check R2 configuration
|
||||||
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
|
env:
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v6
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
passphrase: ${{ secrets.PASSPHRASE }}
|
passphrase: ${{ secrets.PASSPHRASE }}
|
||||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release
|
args: release
|
||||||
@@ -34,11 +46,30 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
GORELEASER_FORCE_TOKEN: "gitea"
|
GORELEASER_FORCE_TOKEN: "gitea"
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||||
release-image:
|
release-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
variant:
|
||||||
|
# The basic image is built from source and can target any arch the
|
||||||
|
# toolchain supports. The dind variants are limited to the arches the
|
||||||
|
# docker:dind base image publishes.
|
||||||
|
- target: basic
|
||||||
|
tag_suffix: ""
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||||
|
- target: dind
|
||||||
|
tag_suffix: "-dind"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
- target: dind-rootless
|
||||||
|
tag_suffix: "-dind-rootless"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
@@ -46,66 +77,44 @@ jobs:
|
|||||||
DOCKER_LATEST: latest
|
DOCKER_LATEST: latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Get Meta
|
- name: "Docker meta"
|
||||||
id: meta
|
id: docker_meta
|
||||||
run: |
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
with:
|
||||||
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
|
images: |
|
||||||
|
${{ env.DOCKER_ORG }}/runner
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
flavor: |
|
||||||
|
latest=true
|
||||||
|
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
target: basic
|
target: ${{ matrix.variant.target }}
|
||||||
platforms: |
|
platforms: ${{ matrix.variant.platforms }}
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
build-args: |
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
VERSION=${{ steps.docker_meta.outputs.version }}
|
||||||
|
|
||||||
- name: Build and push dind
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
target: dind
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-dind
|
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}-dind
|
|
||||||
|
|
||||||
- name: Build and push dind-rootless
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
target: dind-rootless
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-dind-rootless
|
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}-dind-rootless
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
name: release
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- v*
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
# TODO use environment to scope secrets
|
|
||||||
name: release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: release --clean -f ./.goreleaser.yml -f ./.goreleaser.gitea.yml
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || github.token }}
|
|
||||||
- name: Winget
|
|
||||||
uses: vedantmgoyal2009/winget-releaser@v2
|
|
||||||
with:
|
|
||||||
identifier: nektos.act
|
|
||||||
installers-regex: '_Windows_\w+\.zip$'
|
|
||||||
token: ${{ secrets.WINGET_TOKEN }}
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ secrets.WINGET_TOKEN && '1' || '' }}
|
|
||||||
- name: Chocolatey
|
|
||||||
uses: ./.github/actions/choco
|
|
||||||
with:
|
|
||||||
version: ${{ github.ref }}
|
|
||||||
apiKey: ${{ secrets.CHOCO_APIKEY }}
|
|
||||||
push: true
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ secrets.CHOCO_APIKEY && '1' || '' }}
|
|
||||||
# TODO use ssh deployment key
|
|
||||||
- name: GitHub CLI extension
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const mainRef = (await github.rest.git.getRef({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: 'gh-act',
|
|
||||||
ref: 'heads/main',
|
|
||||||
})).data;
|
|
||||||
console.log(mainRef);
|
|
||||||
github.rest.git.createRef({
|
|
||||||
owner: 'nektos',
|
|
||||||
repo: 'gh-act',
|
|
||||||
ref: context.ref,
|
|
||||||
sha: mainRef.object.sha,
|
|
||||||
});
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ (secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN) && '1' || '' }}
|
|
||||||
@@ -1,20 +1,50 @@
|
|||||||
name: checks
|
name: checks
|
||||||
on:
|
on:
|
||||||
- push
|
push:
|
||||||
- pull_request
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: check and test
|
name: check and test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||||
|
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
||||||
|
# first. The path must be a literal: the `runner` context is unavailable in job-level
|
||||||
|
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
||||||
|
# to ~/.docker with the stale credentials.
|
||||||
|
DOCKER_CONFIG: /tmp/docker-noauth
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
- name: prepare anonymous docker config
|
||||||
|
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||||
|
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||||
|
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||||
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
|
- name: pre-pull test images
|
||||||
|
run: |
|
||||||
|
for img in node:24-bookworm-slim nginx:alpine; do
|
||||||
|
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
||||||
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
|
- name: checks
|
||||||
|
run: make checks
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
run: make test
|
run: make test
|
||||||
|
# Build the dind image and run the daemon-facing tests against the docker version it
|
||||||
|
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
|
||||||
|
# after `make test` so the images it needs are already present on the host daemon.
|
||||||
|
- name: test against dind image
|
||||||
|
run: make test-dind
|
||||||
|
- name: coverage report
|
||||||
|
run: |
|
||||||
|
make coverage-report
|
||||||
|
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: Use this template for reporting bugs/issues.
|
|
||||||
labels:
|
|
||||||
- 'kind/bug'
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out this bug report!
|
|
||||||
- type: textarea
|
|
||||||
id: act-debug
|
|
||||||
attributes:
|
|
||||||
label: Bug report info
|
|
||||||
render: plain text
|
|
||||||
description: |
|
|
||||||
Output of `act --bug-report`
|
|
||||||
placeholder: |
|
|
||||||
act --bug-report
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: act-command
|
|
||||||
attributes:
|
|
||||||
label: Command used with act
|
|
||||||
description: |
|
|
||||||
Please paste your whole command
|
|
||||||
placeholder: |
|
|
||||||
act -P ubuntu-latest=node:12 -v -d ...
|
|
||||||
render: sh
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: Describe issue
|
|
||||||
description: |
|
|
||||||
Also tell us what did you expect to happen?
|
|
||||||
placeholder: |
|
|
||||||
Describe issue
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: repo
|
|
||||||
attributes:
|
|
||||||
label: Link to GitHub repository
|
|
||||||
description: |
|
|
||||||
Provide link to GitHub repository, you can skip it if the repository is private or you don't have it on GitHub, otherwise please provide it as it might help us troubleshoot problem
|
|
||||||
placeholder: |
|
|
||||||
https://github.com/nektos/act
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: workflow
|
|
||||||
attributes:
|
|
||||||
label: Workflow content
|
|
||||||
description: |
|
|
||||||
Please paste your **whole** workflow here
|
|
||||||
placeholder: |
|
|
||||||
name: My workflow
|
|
||||||
on: ['push', 'schedule']
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
KEY: VAL
|
|
||||||
[...]
|
|
||||||
render: yml
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Relevant log output
|
|
||||||
description: |
|
|
||||||
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. Please verify that the log output doesn't contain any sensitive data.
|
|
||||||
render: sh
|
|
||||||
placeholder: |
|
|
||||||
Use `act -v` for verbose output
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: additional-info
|
|
||||||
attributes:
|
|
||||||
label: Additional information
|
|
||||||
placeholder: |
|
|
||||||
Additional information that doesn't fit elsewhere
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: true
|
|
||||||
contact_links:
|
|
||||||
- name: Start a discussion
|
|
||||||
url: https://github.com/actions-oss/act-cli/discussions/new
|
|
||||||
about: You can ask for help here!
|
|
||||||
- name: Want to contribute to act?
|
|
||||||
url: https://github.com/actions-oss/act-cli/blob/main/CONTRIBUTING.md
|
|
||||||
about: Be sure to read contributing guidelines!
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Feature request
|
|
||||||
description: Use this template for requesting a feature/enhancement.
|
|
||||||
labels:
|
|
||||||
- 'kind/feature-request'
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Please note that incompatibility with GitHub Actions should be opened as a bug report, not a new feature.
|
|
||||||
- type: input
|
|
||||||
id: act-version
|
|
||||||
attributes:
|
|
||||||
label: Act version
|
|
||||||
description: |
|
|
||||||
What version of `act` are you using? Version can be obtained via `act --version`
|
|
||||||
If you've built it from source, please provide commit hash
|
|
||||||
placeholder: |
|
|
||||||
act --version
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: feature
|
|
||||||
attributes:
|
|
||||||
label: Feature description
|
|
||||||
description: Describe feature that you would like to see
|
|
||||||
placeholder: ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM alpine:3.21
|
|
||||||
|
|
||||||
ARG CHOCOVERSION=1.1.0
|
|
||||||
|
|
||||||
RUN apk add --no-cache bash ca-certificates git \
|
|
||||||
&& apk --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community add mono mono-dev \
|
|
||||||
&& cert-sync /etc/ssl/certs/ca-certificates.crt \
|
|
||||||
&& wget "https://github.com/chocolatey/choco/archive/${CHOCOVERSION}.tar.gz" -O- | tar -xzf - \
|
|
||||||
&& cd choco-"${CHOCOVERSION}" \
|
|
||||||
&& chmod +x build.sh zip.sh \
|
|
||||||
&& ./build.sh -v \
|
|
||||||
&& mv ./code_drop/chocolatey/console /opt/chocolatey \
|
|
||||||
&& mkdir -p /opt/chocolatey/lib \
|
|
||||||
&& rm -rf /choco-"${CHOCOVERSION}" \
|
|
||||||
&& apk del mono-dev \
|
|
||||||
&& rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
ENV ChocolateyInstall=/opt/chocolatey
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
name: 'Chocolatey Packager'
|
|
||||||
description: 'Create the choco package and push it'
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version of package'
|
|
||||||
required: false
|
|
||||||
apiKey:
|
|
||||||
description: 'API Key for chocolately'
|
|
||||||
required: false
|
|
||||||
push:
|
|
||||||
description: 'Option for if package is going to be pushed'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
runs:
|
|
||||||
using: 'docker'
|
|
||||||
image: 'Dockerfile'
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
function choco {
|
|
||||||
mono /opt/chocolatey/choco.exe "$@" --allow-unofficial --nocolor
|
|
||||||
}
|
|
||||||
|
|
||||||
function get_version {
|
|
||||||
local version=${INPUT_VERSION:-$(git describe --tags)}
|
|
||||||
version=(${version//[!0-9.-]/})
|
|
||||||
local version_parts=(${version//-/ })
|
|
||||||
version=${version_parts[0]}
|
|
||||||
if [ ${#version_parts[@]} -gt 1 ]; then
|
|
||||||
version=${version_parts}.${version_parts[1]}
|
|
||||||
fi
|
|
||||||
echo "$version"
|
|
||||||
}
|
|
||||||
|
|
||||||
## Determine the version to pack
|
|
||||||
VERSION=$(get_version)
|
|
||||||
echo "Packing version ${VERSION} of act"
|
|
||||||
rm -f act-cli.*.nupkg
|
|
||||||
mkdir -p tools
|
|
||||||
cp LICENSE tools/LICENSE.txt
|
|
||||||
cp VERIFICATION tools/VERIFICATION.txt
|
|
||||||
cp dist/act-cli_windows_amd64*/act.exe tools/
|
|
||||||
choco pack act-cli.nuspec --version ${VERSION}
|
|
||||||
if [[ "$INPUT_PUSH" == "true" ]]; then
|
|
||||||
choco push act-cli.${VERSION}.nupkg --api-key ${INPUT_APIKEY} -s https://push.chocolatey.org/ --timeout 180
|
|
||||||
fi
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# To get started with Dependabot version updates, you'll need to specify which
|
|
||||||
# package ecosystems to update and where the package manifests are located.
|
|
||||||
# Please see the documentation for all configuration options:
|
|
||||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: 'github-actions'
|
|
||||||
directory: '/'
|
|
||||||
schedule:
|
|
||||||
interval: 'monthly'
|
|
||||||
groups:
|
|
||||||
dependencies:
|
|
||||||
patterns:
|
|
||||||
- '*'
|
|
||||||
- package-ecosystem: 'gomod'
|
|
||||||
directory: '/'
|
|
||||||
schedule:
|
|
||||||
interval: 'monthly'
|
|
||||||
groups:
|
|
||||||
dependencies:
|
|
||||||
patterns:
|
|
||||||
- '*'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
test-*.yml
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
name: checks
|
|
||||||
on: [pull_request, workflow_dispatch]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
cancel-in-progress: true
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
ACT_OWNER: ${{ github.repository_owner }}
|
|
||||||
ACT_REPOSITORY: ${{ github.repository }}
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: golangci/[email protected]
|
|
||||||
with:
|
|
||||||
version: v2.1.6
|
|
||||||
- uses: megalinter/megalinter/flavors/[email protected]
|
|
||||||
env:
|
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VALIDATE_ALL_CODEBASE: false
|
|
||||||
GITHUB_STATUS_REPORTER: ${{ !env.ACT }}
|
|
||||||
GITHUB_COMMENT_REPORTER: ${{ !env.ACT }}
|
|
||||||
|
|
||||||
test-linux:
|
|
||||||
name: test-linux
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: Install gotestfmt
|
|
||||||
run: go install github.com/gotesttools/gotestfmt/v2/cmd/[email protected]
|
|
||||||
- name: Run Tests
|
|
||||||
run: go test -json -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m ./... | gotestfmt -hide successful-packages,empty-packages 2>&1
|
|
||||||
- name: Run act from cli
|
|
||||||
run: go run main.go -P ubuntu-latest=node:16-buster-slim -C ./pkg/runner/testdata/ -W ./basic/push.yml
|
|
||||||
- name: Run act from cli without docker support
|
|
||||||
run: go run -tags WITHOUT_DOCKER main.go -P ubuntu-latest=-self-hosted -C ./pkg/runner/testdata/ -W ./local-action-js/push.yml
|
|
||||||
- name: Upload Codecov report
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: coverage.txt
|
|
||||||
fail_ci_if_error: true # optional (default = false)
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
test-host:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os:
|
|
||||||
- windows-latest
|
|
||||||
- macos-latest
|
|
||||||
name: test-host-${{matrix.os}}
|
|
||||||
runs-on: ${{matrix.os}}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- name: Install gotestfmt
|
|
||||||
run: go install github.com/gotesttools/gotestfmt/v2/cmd/[email protected]
|
|
||||||
- name: Run Tests
|
|
||||||
run: go test -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m -run ^TestRunEventHostEnvironment$ ./...
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
|
|
||||||
snapshot:
|
|
||||||
name: snapshot
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: GoReleaser
|
|
||||||
id: goreleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: v2
|
|
||||||
args: release --snapshot --clean
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Install @actions/artifact
|
|
||||||
run: npm install @actions/artifact
|
|
||||||
- name: Upload All
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const {DefaultArtifactClient} = require('@actions/artifact');
|
|
||||||
const aartifact = new DefaultArtifactClient();
|
|
||||||
var artifacts = JSON.parse(process.env.ARTIFACTS);
|
|
||||||
for(var artifact of artifacts) {
|
|
||||||
if(artifact.type === "Binary") {
|
|
||||||
const {id, size} = await aartifact.uploadArtifact(
|
|
||||||
// name of the artifact
|
|
||||||
`${artifact.name}-${artifact.target}`,
|
|
||||||
// files to include (supports absolute and relative paths)
|
|
||||||
[artifact.path],
|
|
||||||
process.cwd(),
|
|
||||||
{
|
|
||||||
// optional: how long to retain the artifact
|
|
||||||
// if unspecified, defaults to repository/org retention settings (the limit of this value)
|
|
||||||
retentionDays: 10
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log(`Created artifact with id: ${id} (bytes: ${size}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env:
|
|
||||||
ARTIFACTS: ${{ steps.goreleaser.outputs.artifacts }}
|
|
||||||
- name: Chocolatey
|
|
||||||
uses: ./.github/actions/choco
|
|
||||||
with:
|
|
||||||
version: v0.0.0-pr
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Codespell configuration is within .codespellrc
|
|
||||||
---
|
|
||||||
name: Codespell
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
codespell:
|
|
||||||
name: Check for spelling errors
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
- name: Codespell
|
|
||||||
uses: codespell-project/actions-codespell@v2
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: promote
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 2 1 * *'
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
if: vars.ENABLE_PROMOTE || github.event_name != 'schedule'
|
|
||||||
name: promote
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: master
|
|
||||||
token: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
|
|
||||||
- uses: fregante/setup-git-user@v2
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- run: make promote
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
name: release
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- v*
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
# TODO use environment to scope secrets
|
|
||||||
name: release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || github.token }}
|
|
||||||
- name: Winget
|
|
||||||
uses: vedantmgoyal2009/winget-releaser@v2
|
|
||||||
with:
|
|
||||||
identifier: nektos.act
|
|
||||||
installers-regex: '_Windows_\w+\.zip$'
|
|
||||||
token: ${{ secrets.WINGET_TOKEN }}
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ secrets.WINGET_TOKEN && '1' || '' }}
|
|
||||||
- name: Chocolatey
|
|
||||||
uses: ./.github/actions/choco
|
|
||||||
with:
|
|
||||||
version: ${{ github.ref }}
|
|
||||||
apiKey: ${{ secrets.CHOCO_APIKEY }}
|
|
||||||
push: true
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ secrets.CHOCO_APIKEY && '1' || '' }}
|
|
||||||
# TODO use ssh deployment key
|
|
||||||
- name: GitHub CLI extension
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const mainRef = (await github.rest.git.getRef({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: 'gh-act',
|
|
||||||
ref: 'heads/main',
|
|
||||||
})).data;
|
|
||||||
console.log(mainRef);
|
|
||||||
github.rest.git.createRef({
|
|
||||||
owner: 'nektos',
|
|
||||||
repo: 'gh-act',
|
|
||||||
ref: context.ref,
|
|
||||||
sha: mainRef.object.sha,
|
|
||||||
});
|
|
||||||
if: env.ENABLED
|
|
||||||
env:
|
|
||||||
ENABLED: ${{ (secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN) && '1' || '' }}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: 'Close stale issues'
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * *'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
name: Stale
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v10
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
stale-issue-message: 'Issue is stale and will be closed in 14 days unless there is new activity'
|
|
||||||
stale-pr-message: 'PR is stale and will be closed in 14 days unless there is new activity'
|
|
||||||
stale-issue-label: 'stale'
|
|
||||||
exempt-issue-labels: 'stale-exempt,kind/feature-request'
|
|
||||||
stale-pr-label: 'stale'
|
|
||||||
exempt-pr-labels: 'stale-exempt'
|
|
||||||
remove-stale-when-updated: 'True'
|
|
||||||
operations-per-run: 500
|
|
||||||
days-before-stale: 180
|
|
||||||
days-before-close: 14
|
|
||||||
+6
-3
@@ -1,8 +1,10 @@
|
|||||||
/act_runner
|
/gitea-runner
|
||||||
.env
|
.env
|
||||||
|
!/act/runner/testdata/secrets/.env
|
||||||
.runner
|
.runner
|
||||||
|
.runner.lock
|
||||||
coverage.txt
|
coverage.txt
|
||||||
/gitea-vet
|
.tmp/
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
|
||||||
# Jetbrains
|
# Jetbrains
|
||||||
@@ -11,4 +13,5 @@ coverage.txt
|
|||||||
.vscode
|
.vscode
|
||||||
__debug_bin
|
__debug_bin
|
||||||
# gorelease binary folder
|
# gorelease binary folder
|
||||||
dist
|
/dist
|
||||||
|
.DS_Store
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
b910a42edfab7a02b08a52ecef203fd419725642:pkg/container/testdata/docker-pull-options/config.json:generic-api-key:4
|
|
||||||
710a3ac94c3dc0eaf680d417c87f37f92b4887f4:pkg/container/docker_pull_test.go:generic-api-key:45
|
|
||||||
+70
-27
@@ -11,8 +11,10 @@ linters:
|
|||||||
- dupl
|
- dupl
|
||||||
- errcheck
|
- errcheck
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
- forcetypeassert
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
- gocritic
|
- gocritic
|
||||||
|
- goheader
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- mirror
|
- mirror
|
||||||
@@ -35,23 +37,57 @@ linters:
|
|||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
deny:
|
deny:
|
||||||
- pkg: github.com/pkg/errors
|
- pkg: golang.org/x/exp
|
||||||
desc: Please use "errors" package from standard library
|
desc: it's experimental and unreliable
|
||||||
- pkg: gotest.tools/v3
|
nolintlint:
|
||||||
desc: Please keep tests unified using only github.com/stretchr/testify
|
allow-unused: false
|
||||||
- pkg: log
|
require-explanation: true
|
||||||
desc: Please keep logging unified using only github.com/sirupsen/logrus
|
require-specific: true
|
||||||
gocritic:
|
gocritic:
|
||||||
|
enabled-checks:
|
||||||
|
- equalFold
|
||||||
disabled-checks:
|
disabled-checks:
|
||||||
- ifElseChain
|
- ifElseChain
|
||||||
gocyclo:
|
revive:
|
||||||
min-complexity: 20
|
severity: error
|
||||||
importas:
|
rules:
|
||||||
alias:
|
- name: blank-imports
|
||||||
- pkg: github.com/sirupsen/logrus
|
- name: constant-logical-expr
|
||||||
alias: log
|
- name: context-as-argument
|
||||||
- pkg: github.com/stretchr/testify/assert
|
- name: context-keys-type
|
||||||
alias: assert
|
- name: dot-imports
|
||||||
|
- name: empty-lines
|
||||||
|
- name: error-return
|
||||||
|
- name: error-strings
|
||||||
|
- name: exported
|
||||||
|
- name: identical-branches
|
||||||
|
- name: if-return
|
||||||
|
- name: increment-decrement
|
||||||
|
- name: modifies-value-receiver
|
||||||
|
- name: package-comments
|
||||||
|
- name: redefines-builtin-id
|
||||||
|
- name: superfluous-else
|
||||||
|
- name: time-naming
|
||||||
|
- name: unexported-return
|
||||||
|
- name: var-declaration
|
||||||
|
- name: var-naming
|
||||||
|
staticcheck:
|
||||||
|
checks:
|
||||||
|
- all
|
||||||
|
- -ST1005
|
||||||
|
usetesting:
|
||||||
|
os-temp-dir: true
|
||||||
|
perfsprint:
|
||||||
|
concat-loop: false
|
||||||
|
govet:
|
||||||
|
enable:
|
||||||
|
- nilness
|
||||||
|
- unusedwrite
|
||||||
|
goheader:
|
||||||
|
values:
|
||||||
|
regexp:
|
||||||
|
HEADER: 'Copyright \d{4} The Gitea Authors\. All rights reserved\.(\nCopyright [^\n]+)*\nSPDX-License-Identifier: MIT'
|
||||||
|
template: '{{ HEADER }}'
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
@@ -60,23 +96,30 @@ linters:
|
|||||||
- legacy
|
- legacy
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
rules:
|
||||||
- linters: [revive]
|
- linters:
|
||||||
text: avoid meaningless package names
|
- forbidigo
|
||||||
paths:
|
path: cmd
|
||||||
- report
|
- linters:
|
||||||
- third_party$
|
- forcetypeassert
|
||||||
- builtin$
|
path: _test\.go
|
||||||
- examples$
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- goimports
|
- gci
|
||||||
|
- gofumpt
|
||||||
|
settings:
|
||||||
|
gci:
|
||||||
|
custom-order: true
|
||||||
|
sections:
|
||||||
|
- standard
|
||||||
|
- prefix(gitea.com/gitea/runner)
|
||||||
|
- blank
|
||||||
|
- default
|
||||||
|
gofumpt:
|
||||||
|
extra-rules: true
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
paths:
|
run:
|
||||||
- report
|
timeout: 10m
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
gitea_urls:
|
|
||||||
api: https://gitea.com/api/v1/
|
|
||||||
download: https://gitea.com/
|
|
||||||
+35
-2
@@ -1,5 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
|
project_name: gitea-runner
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -63,7 +65,7 @@ builds:
|
|||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X gitea.com/gitea/act_runner/internal/pkg/ver.version={{ .Summary }}
|
- -s -w -X gitea.com/gitea/runner/internal/pkg/ver.version={{ .Summary }}
|
||||||
binary: >-
|
binary: >-
|
||||||
{{ .ProjectName }}-
|
{{ .ProjectName }}-
|
||||||
{{- .Version }}-
|
{{- .Version }}-
|
||||||
@@ -86,11 +88,42 @@ blobs:
|
|||||||
provider: s3
|
provider: s3
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
bucket: "{{ .Env.S3_BUCKET }}"
|
||||||
region: "{{ .Env.S3_REGION }}"
|
region: "{{ .Env.S3_REGION }}"
|
||||||
directory: "act_runner/{{.Version}}"
|
directory: "gitea-runner/{{.Version}}"
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- glob: ./**.sha256
|
||||||
|
|
||||||
|
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||||
|
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||||
|
# A second `blobs:` entry is impossible here since the blob pipe
|
||||||
|
# authenticates from the global AWS_* env with no per-entry
|
||||||
|
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||||
|
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||||
|
# publishers inherit almost nothing from the environment, hence the
|
||||||
|
# explicit R2_* forwarding below.
|
||||||
|
#
|
||||||
|
# This publisher fires 109 times for 73 distinct keys because
|
||||||
|
# goreleaser's release pipe already registers `release.extra_files`
|
||||||
|
# as UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||||
|
# appends this block's own extra_files with no de-duplication. It
|
||||||
|
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||||
|
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||||
|
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||||
|
# this publisher declares its own complete file set rather than
|
||||||
|
# implicitly depending on the `release:` block's globs.
|
||||||
|
publishers:
|
||||||
|
- name: cloudflare-r2
|
||||||
|
checksum: true
|
||||||
|
extra_files:
|
||||||
|
- glob: ./**.xz
|
||||||
|
- glob: ./**.sha256
|
||||||
|
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} gitea-runner/{{ .Version }}/{{ .ArtifactName }}
|
||||||
|
env:
|
||||||
|
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||||
|
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||||
|
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||||
|
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: binary
|
- format: binary
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
version: 2
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- go mod tidy
|
|
||||||
builds:
|
|
||||||
- env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
goos:
|
|
||||||
- darwin
|
|
||||||
- linux
|
|
||||||
- windows
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- '386'
|
|
||||||
- arm64
|
|
||||||
- arm
|
|
||||||
- riscv64
|
|
||||||
goarm:
|
|
||||||
- '6'
|
|
||||||
- '7'
|
|
||||||
ignore:
|
|
||||||
- goos: windows
|
|
||||||
goarm: '6'
|
|
||||||
binary: act
|
|
||||||
checksum:
|
|
||||||
name_template: 'checksums.txt'
|
|
||||||
archives:
|
|
||||||
- name_template: >-
|
|
||||||
{{ .ProjectName }}_
|
|
||||||
{{- title .Os }}_
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
|
||||||
{{- else if eq .Arch "386" }}i386
|
|
||||||
{{- else }}{{ .Arch }}{{ end }}
|
|
||||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats:
|
|
||||||
- zip
|
|
||||||
changelog:
|
|
||||||
groups:
|
|
||||||
- title: 'New Features'
|
|
||||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
|
||||||
order: 0
|
|
||||||
- title: 'Bug fixes'
|
|
||||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
|
||||||
order: 1
|
|
||||||
- title: 'Documentation updates'
|
|
||||||
regexp: "^.*docs[(\\w)]*:+.*$"
|
|
||||||
order: 2
|
|
||||||
- title: 'Other'
|
|
||||||
order: 999
|
|
||||||
release:
|
|
||||||
prerelease: auto
|
|
||||||
mode: append
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Default state for all rules
|
|
||||||
default: true
|
|
||||||
|
|
||||||
# MD013/line-length - Line length
|
|
||||||
MD013:
|
|
||||||
line_length: 1024
|
|
||||||
|
|
||||||
# MD033/no-inline-html - Inline HTML
|
|
||||||
MD033: false
|
|
||||||
|
|
||||||
# MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading
|
|
||||||
MD041: false
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
APPLY_FIXES: none
|
|
||||||
DISABLE:
|
|
||||||
- ACTION
|
|
||||||
- BASH
|
|
||||||
- COPYPASTE
|
|
||||||
- DOCKERFILE
|
|
||||||
- GO
|
|
||||||
- JAVASCRIPT
|
|
||||||
- SPELL
|
|
||||||
DISABLE_LINTERS:
|
|
||||||
- YAML_YAMLLINT
|
|
||||||
- MARKDOWN_MARKDOWN_TABLE_FORMATTER
|
|
||||||
- MARKDOWN_MARKDOWN_LINK_CHECK
|
|
||||||
- REPOSITORY_CHECKOV
|
|
||||||
- REPOSITORY_TRIVY
|
|
||||||
FILTER_REGEX_EXCLUDE: (.*testdata/*|install.sh|pkg/container/docker_cli.go|pkg/container/DOCKER_LICENSE|VERSION)
|
|
||||||
MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.yml
|
|
||||||
PARALLEL: false
|
|
||||||
PRINT_ALPACA: false
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
|
|
||||||
pull_request_rules:
|
|
||||||
- name: warn on conflicts
|
|
||||||
conditions:
|
|
||||||
- -draft
|
|
||||||
- -closed
|
|
||||||
- -merged
|
|
||||||
- conflict
|
|
||||||
actions:
|
|
||||||
comment:
|
|
||||||
message: '@{{author}} this pull request is now in conflict 😩'
|
|
||||||
label:
|
|
||||||
add:
|
|
||||||
- conflict
|
|
||||||
- name: remove conflict label if not needed
|
|
||||||
conditions:
|
|
||||||
- -conflict
|
|
||||||
actions:
|
|
||||||
label:
|
|
||||||
remove:
|
|
||||||
- conflict
|
|
||||||
- name: warn on needs-work
|
|
||||||
conditions:
|
|
||||||
- -draft
|
|
||||||
- -closed
|
|
||||||
- -merged
|
|
||||||
- or:
|
|
||||||
- check-failure=lint
|
|
||||||
- check-failure=test-linux
|
|
||||||
- check-failure=codecov/patch
|
|
||||||
- check-failure=codecov/project
|
|
||||||
- check-failure=snapshot
|
|
||||||
actions:
|
|
||||||
comment:
|
|
||||||
message: '@{{author}} this pull request has failed checks 🛠'
|
|
||||||
label:
|
|
||||||
add:
|
|
||||||
- needs-work
|
|
||||||
- name: remove needs-work label if not needed
|
|
||||||
conditions:
|
|
||||||
- check-success=lint
|
|
||||||
- check-success=test-linux
|
|
||||||
- check-success=codecov/patch
|
|
||||||
- check-success=codecov/project
|
|
||||||
- check-success=snapshot
|
|
||||||
actions:
|
|
||||||
label:
|
|
||||||
remove:
|
|
||||||
- needs-work
|
|
||||||
- name: Automatic maintainer assignment
|
|
||||||
conditions:
|
|
||||||
- '-approved-reviews-by=@nektos/act-maintainers'
|
|
||||||
- -draft
|
|
||||||
- -merged
|
|
||||||
- -closed
|
|
||||||
- -conflict
|
|
||||||
- check-success=lint
|
|
||||||
- check-success=test-linux
|
|
||||||
- check-success=codecov/patch
|
|
||||||
- check-success=codecov/project
|
|
||||||
- check-success=snapshot
|
|
||||||
actions:
|
|
||||||
request_reviews:
|
|
||||||
teams:
|
|
||||||
- '@nektos/act-maintainers'
|
|
||||||
- name: Automatic merge on approval
|
|
||||||
conditions: []
|
|
||||||
actions:
|
|
||||||
queue:
|
|
||||||
queue_rules:
|
|
||||||
- name: default
|
|
||||||
queue_conditions:
|
|
||||||
- '#changes-requested-reviews-by=0'
|
|
||||||
- or:
|
|
||||||
- 'approved-reviews-by=@nektos/act-committers'
|
|
||||||
- 'author~=^dependabot(|-preview)\[bot\]$'
|
|
||||||
- and:
|
|
||||||
- 'approved-reviews-by=@nektos/act-maintainers'
|
|
||||||
- '#approved-reviews-by>=2'
|
|
||||||
- and:
|
|
||||||
- 'author=@nektos/act-maintainers'
|
|
||||||
- 'approved-reviews-by=@nektos/act-maintainers'
|
|
||||||
- '#approved-reviews-by>=1'
|
|
||||||
- -draft
|
|
||||||
- -merged
|
|
||||||
- -closed
|
|
||||||
- check-success=lint
|
|
||||||
- check-success=test-linux
|
|
||||||
- check-success=codecov/patch
|
|
||||||
- check-success=codecov/project
|
|
||||||
- check-success=snapshot
|
|
||||||
merge_conditions:
|
|
||||||
- check-success=lint
|
|
||||||
- check-success=test-linux
|
|
||||||
- check-success=codecov/patch
|
|
||||||
- check-success=codecov/project
|
|
||||||
- check-success=snapshot
|
|
||||||
merge_method: squash
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
**/testdata
|
|
||||||
pkg/runner/res
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
overrides:
|
|
||||||
- files: '*.yml'
|
|
||||||
options:
|
|
||||||
singleQuote: true
|
|
||||||
- files: '*.json'
|
|
||||||
options:
|
|
||||||
singleQuote: false
|
|
||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"editorconfig.editorconfig",
|
|
||||||
"golang.go",
|
|
||||||
"davidanson.vscode-markdownlint",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"redhat.vscode-yaml"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"go.lintTool": "golangci-lint",
|
|
||||||
"go.lintFlags": ["--fix"],
|
|
||||||
"go.testTimeout": "300s",
|
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[markdown]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[yaml]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
- Never assume, verify before claiming
|
||||||
|
- Use `make help` to find available development targets
|
||||||
|
- PR descriptions: minimal, only what and why, no task lists or file listings
|
||||||
|
- Reference issues and PRs by full URL, not by number
|
||||||
|
- Use Conventional Commits for commit messages and PR titles, plus the `enhance` type for user-facing enhancements
|
||||||
|
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
|
||||||
|
- Attribute agent authorship on one trailing line in issue and pull request comments, never as a PR description section
|
||||||
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
|
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||||
|
- Add the current year into the copyright header of new `.go` files
|
||||||
|
- Ensure no trailing whitespace in edited files
|
||||||
|
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
||||||
|
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
||||||
|
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
|
||||||
|
- Run single go tests with `go test -run '^TestName$' ./modulepath/`. `make test` self-skips the integration tests without docker or network, `make test-dind` runs the daemon-facing tests against the built dind image
|
||||||
|
- Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation
|
||||||
|
- Wait on a deterministic condition rather than `sleep`
|
||||||
|
- Update the files under `docs/` when behavior documented there changes
|
||||||
@@ -1 +0,0 @@
|
|||||||
* @nektos/act-maintainers
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Contributing to Act
|
|
||||||
|
|
||||||
Help wanted! We'd love your contributions to Act. Please review the following guidelines before contributing. Also, feel free to propose changes to these guidelines by updating this file and submitting a pull request.
|
|
||||||
|
|
||||||
- [I have a question...](#questions)
|
|
||||||
- [I found a bug...](#bugs)
|
|
||||||
- [I have a feature request...](#features)
|
|
||||||
- [I have a contribution to share...](#process)
|
|
||||||
|
|
||||||
## <a id="questions"></a> Have a Question?
|
|
||||||
|
|
||||||
Please don't open a GitHub issue for questions about how to use `act`, as the goal is to use issues for managing bugs and feature requests. Issues that are related to general support will be closed and redirected to our gitter room.
|
|
||||||
|
|
||||||
For all support related questions, please ask the question in discussions: [actions-oss/act-cli](https://github.com/actions-oss/act-cli/discussions).
|
|
||||||
|
|
||||||
## <a id="bugs"></a> Found a Bug?
|
|
||||||
|
|
||||||
If you've identified a bug in `act`, please [submit an issue](#issue) to our GitHub repo: [actions-oss/act-cli](https://github.com/actions-oss/act-cli/issues/new). Please also feel free to submit a [Pull Request](#pr) with a fix for the bug!
|
|
||||||
|
|
||||||
## <a id="features"></a> Have a Feature Request?
|
|
||||||
|
|
||||||
All feature requests should start with [submitting an issue](#issue) documenting the user story and acceptance criteria. Again, feel free to submit a [Pull Request](#pr) with a proposed implementation of the feature.
|
|
||||||
|
|
||||||
## <a id="process"></a> Ready to Contribute
|
|
||||||
|
|
||||||
### <a id="issue"></a> Create an issue
|
|
||||||
|
|
||||||
Before submitting a new issue, please search the issues to make sure there isn't a similar issue doesn't already exist.
|
|
||||||
|
|
||||||
Assuming no existing issues exist, please ensure you include required information when submitting the issue to ensure we can quickly reproduce your issue.
|
|
||||||
|
|
||||||
We may have additional questions and will communicate through the GitHub issue, so please respond back to our questions to help reproduce and resolve the issue as quickly as possible.
|
|
||||||
|
|
||||||
New issues can be created with in our [GitHub repo](https://github.com/actions-oss/act-cli/issues/new).
|
|
||||||
|
|
||||||
### <a id="pr"></a>Pull Requests
|
|
||||||
|
|
||||||
Pull requests should target the `master` branch. Please also reference the issue from the description of the pull request using [special keyword syntax](https://help.github.com/articles/closing-issues-via-commit-messages/) to auto close the issue when the PR is merged. For example, include the phrase `fixes #14` in the PR description to have issue #14 auto close. Please send documentation updates for the [act user guide](https://actions-oss.github.io/act-docs/) to [actions-oss/act-docs](https://github.com/actions-oss/act-docs).
|
|
||||||
|
|
||||||
### <a id="style"></a> Styleguide
|
|
||||||
|
|
||||||
When submitting code, please make every effort to follow existing conventions and style in order to keep the code as readable as possible. Here are a few points to keep in mind:
|
|
||||||
|
|
||||||
- Please run `go fmt ./...` before committing to ensure code aligns with go standards.
|
|
||||||
- We use [`golangci-lint`](https://golangci-lint.run/) for linting Go code, run `golangci-lint run --fix` before submitting PR. Editors such as Visual Studio Code or JetBrains IntelliJ; with Go support plugin will offer `golangci-lint` automatically.
|
|
||||||
- There are additional linters and formatters for files such as Markdown documents or YAML/JSON:
|
|
||||||
- Please refer to the [Makefile](Makefile) or [`lint` job in our workflow](.github/workflows/checks.yml) to see how to those linters/formatters work.
|
|
||||||
- You can lint codebase by running `go run main.go -j lint --env RUN_LOCAL=true` or `act -j lint --env RUN_LOCAL=true`
|
|
||||||
- In `Makefile`, there are tools that require `npx` which is shipped with `nodejs`.
|
|
||||||
- Our `Makefile` exports `GITHUB_TOKEN` from `~/.config/github/token`, you have been warned.
|
|
||||||
- You can run `make pr` to cleanup dependencies, format/lint code and run tests.
|
|
||||||
- All dependencies must be defined in the `go.mod` file.
|
|
||||||
- Advanced IDEs and code editors (like VSCode) will take care of that, but to be sure, run `go mod tidy` to validate dependencies.
|
|
||||||
- For details on the approved style, check out [Effective Go](https://golang.org/doc/effective_go.html).
|
|
||||||
- Before running tests, please be aware that they are multi-architecture so for them to not fail, you need to run `docker run --privileged --rm tonistiigi/binfmt --install amd64,arm64` before ([more info available in #765](https://github.com/nektos/act/issues/765)).
|
|
||||||
|
|
||||||
Also, consider the original design principles:
|
|
||||||
|
|
||||||
- **Polyglot** - There will be no prescribed language or framework for developing the microservices. The only requirement will be that the service will be run inside a container and exposed via an HTTP endpoint.
|
|
||||||
- **Cloud Provider** - At this point, the tool will assume AWS for the cloud provider and will not be written in a cloud agnostic manner. However, this does not preclude refactoring to add support for other providers at a later time.
|
|
||||||
- **Declarative** - All resource administration will be handled in a declarative vs. imperative manner. A file will be used to declared the desired state of the resources and the tool will simply assert the actual state matches the desired state. The tool will accomplish this by generating CloudFormation templates.
|
|
||||||
- **Stateless** - The tool will not maintain its own state. Rather, it will rely on the CloudFormation stacks to determine the state of the platform.
|
|
||||||
- **Secure** - All security will be managed by AWS IAM credentials. No additional authentication or authorization mechanisms will be introduced.
|
|
||||||
|
|
||||||
### License
|
|
||||||
|
|
||||||
By contributing your code, you agree to license your contribution under the terms of the [MIT License](LICENSE).
|
|
||||||
|
|
||||||
All files are released with the MIT license.
|
|
||||||
+27
-11
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM golang:1.26-alpine3.23 AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
@@ -9,19 +9,24 @@ RUN apk add --no-cache make git
|
|||||||
ARG GOPROXY
|
ARG GOPROXY
|
||||||
ENV GOPROXY=${GOPROXY:-}
|
ENV GOPROXY=${GOPROXY:-}
|
||||||
|
|
||||||
COPY . /opt/src/act_runner
|
COPY . /opt/src/runner
|
||||||
WORKDIR /opt/src/act_runner
|
WORKDIR /opt/src/runner
|
||||||
|
|
||||||
RUN make clean && make build
|
RUN make clean && make build
|
||||||
|
|
||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:28-dind AS dind
|
FROM docker:29.6.2-dind AS dind
|
||||||
|
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
ARG VERSION=dev
|
||||||
|
|
||||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
|
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
COPY scripts/s6 /etc/s6
|
COPY scripts/s6 /etc/s6
|
||||||
|
|
||||||
@@ -32,12 +37,17 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:28-dind-rootless AS dind-rootless
|
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
COPY scripts/s6 /etc/s6
|
COPY scripts/s6 /etc/s6
|
||||||
|
|
||||||
@@ -53,10 +63,16 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### BASIC VARIANT
|
### BASIC VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM alpine AS basic
|
FROM alpine:3.24 AS basic
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache tini bash git tzdata
|
RUN apk add --no-cache tini bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := act_runner
|
EXECUTABLE := gitea-runner
|
||||||
GOFMT ?= gofumpt -l
|
|
||||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||||
GO ?= go
|
GO ?= go
|
||||||
SHASUM ?= shasum -a 256
|
SHASUM ?= shasum -a 256
|
||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.26.x
|
XGO_VERSION := go-1.26.x
|
||||||
GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/[email protected]0
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]5 # renovate: datasource=go
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
WINDOWS_ARCHS ?= windows/amd64
|
WINDOWS_ARCHS ?= windows/amd64
|
||||||
GO_FMT_FILES := $(shell find . -type f -name "*.go" ! -name "generated.*")
|
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|
||||||
DOCKER_IMAGE ?= gitea/act_runner
|
DOCKER_IMAGE ?= gitea/runner
|
||||||
DOCKER_TAG ?= nightly
|
DOCKER_TAG ?= nightly
|
||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]0.1
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]2.2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||||
|
|
||||||
ifneq ($(shell uname), Darwin)
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
EXTLDFLAGS = -extldflags "-static" $(null)
|
|
||||||
else
|
STATIC ?=
|
||||||
EXTLDFLAGS =
|
EXTLDFLAGS ?=
|
||||||
|
ifneq ($(STATIC),)
|
||||||
|
EXTLDFLAGS = -extldflags "-static"
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(HAS_GO), GO)
|
ifeq ($(HAS_GO), GO)
|
||||||
@@ -40,12 +40,15 @@ endif
|
|||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else ifeq ($(OS), Windows)
|
else ifeq ($(OS), Windows)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else
|
else
|
||||||
GOFLAGS := -v
|
GOFLAGS := -v
|
||||||
EXECUTABLE ?= $(EXECUTABLE)
|
EXECUTABLE ?= $(EXECUTABLE)
|
||||||
|
GO_ENV_WINDOWS := GOOS=windows
|
||||||
endif
|
endif
|
||||||
|
|
||||||
STORED_VERSION_FILE := VERSION
|
STORED_VERSION_FILE := VERSION
|
||||||
@@ -68,19 +71,19 @@ else
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
GO_PACKAGES_TO_VET ?= $(filter-out gitea.com/gitea/act_runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
|
|
||||||
|
|
||||||
|
|
||||||
TAGS ?=
|
TAGS ?=
|
||||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
LDFLAGS ?= -X "gitea.com/gitea/runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
fmt:
|
.PHONY: help
|
||||||
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
help: Makefile ## print Makefile help information.
|
||||||
$(GO) install mvdan.cc/gofumpt@latest; \
|
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile
|
||||||
fi
|
|
||||||
$(GOFMT) -w $(GO_FMT_FILES)
|
.PHONY: fmt
|
||||||
|
fmt: ## format the Go code
|
||||||
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
|
||||||
|
|
||||||
.PHONY: go-check
|
.PHONY: go-check
|
||||||
go-check:
|
go-check:
|
||||||
@@ -88,100 +91,118 @@ go-check:
|
|||||||
$(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' ')))
|
$(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' ')))
|
||||||
$(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');))
|
$(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');))
|
||||||
@if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \
|
@if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \
|
||||||
echo "Act Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
echo "Gitea Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check:
|
fmt-check: fmt
|
||||||
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@diff=$$(git diff --color=always -- '*.go'); \
|
||||||
$(GO) install mvdan.cc/gofumpt@latest; \
|
|
||||||
fi
|
|
||||||
@diff=$$($(GOFMT) -d $(GO_FMT_FILES)); \
|
|
||||||
if [ -n "$$diff" ]; then \
|
if [ -n "$$diff" ]; then \
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
echo "Please run 'make fmt' and commit the result:"; \
|
||||||
echo "$${diff}"; \
|
printf "%s" "$${diff}"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi;
|
fi
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
$(GO) install $(GOVULNCHECK_PACKAGE)
|
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \
|
||||||
|
$(GO) install $(GXZ_PACKAGE) & \
|
||||||
|
$(GO) install $(XGO_PACKAGE) & \
|
||||||
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
.PHONY: checks
|
||||||
|
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go vet
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
.PHONY: lint-go
|
.PHONY: lint-go
|
||||||
lint-go: ## lint go files
|
lint-go: ## lint go files
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||||
|
|
||||||
|
.PHONY: lint-go-windows
|
||||||
|
lint-go-windows: ## lint Windows go files
|
||||||
|
$(GO) install $(GOLANGCI_LINT_PACKAGE)
|
||||||
|
$(GO_ENV_WINDOWS) golangci-lint run
|
||||||
|
|
||||||
.PHONY: lint-go-fix
|
.PHONY: lint-go-fix
|
||||||
lint-go-fix: ## lint go files and fix issues
|
lint-go-fix: ## lint go files and fix issues
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||||
|
|
||||||
|
.PHONY: lint-pr-title
|
||||||
|
lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||||
|
@node ./tools/lint-pr-title.ts
|
||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check: deps-tools
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy:
|
tidy: ## run go mod tidy
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: tidy
|
||||||
@diff=$$(git diff -- go.mod go.sum); \
|
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
||||||
if [ -n "$$diff" ]; then \
|
if [ -n "$$diff" ]; then \
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
echo "Please run 'make tidy' and commit the result:"; \
|
||||||
echo "$${diff}"; \
|
printf "%s" "$${diff}"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test: fmt-check security-check
|
.PHONY: test
|
||||||
@$(GO) test -race -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
|
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: vet
|
.PHONY: coverage-report
|
||||||
vet:
|
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||||
@echo "Running go vet..."
|
@mkdir -p .tmp
|
||||||
@$(GO) build code.gitea.io/gitea-vet
|
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
|
||||||
@$(GO) vet -vettool=gitea-vet $(GO_PACKAGES_TO_VET)
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
install: $(GOFILES)
|
.PHONY: test-dind
|
||||||
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
|
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||||
|
@./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
build: go-check $(EXECUTABLE)
|
.PHONY: install
|
||||||
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: go-check $(EXECUTABLE) ## build the runner binary
|
||||||
|
|
||||||
$(EXECUTABLE): $(GOFILES)
|
$(EXECUTABLE): $(GOFILES)
|
||||||
$(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@
|
$(GO) build -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||||
|
|
||||||
.PHONY: deps-backend
|
.PHONY: deps-backend
|
||||||
deps-backend:
|
deps-backend: ## install backend dependencies
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
$(GO) install $(GXZ_PAGAGE)
|
|
||||||
$(GO) install $(XGO_PACKAGE)
|
|
||||||
|
|
||||||
.PHONY: release
|
.PHONY: release
|
||||||
release: release-windows release-linux release-darwin release-copy release-compress release-check
|
release: release-windows release-linux release-darwin release-copy release-compress release-check ## build release artifacts
|
||||||
|
|
||||||
$(DIST_DIRS):
|
$(DIST_DIRS):
|
||||||
mkdir -p $(DIST_DIRS)
|
mkdir -p $(DIST_DIRS)
|
||||||
|
|
||||||
.PHONY: release-windows
|
.PHONY: release-windows
|
||||||
release-windows: | $(DIST_DIRS)
|
release-windows: | $(DIST_DIRS)
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||||
ifeq ($(CI),true)
|
ifeq ($(CI),true)
|
||||||
cp -r /build/* $(DIST)/binaries/
|
cp -r /build/* $(DIST)/binaries/
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: release-linux
|
.PHONY: release-linux
|
||||||
release-linux: | $(DIST_DIRS)
|
release-linux: | $(DIST_DIRS)
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||||
ifeq ($(CI),true)
|
ifeq ($(CI),true)
|
||||||
cp -r /build/* $(DIST)/binaries/
|
cp -r /build/* $(DIST)/binaries/
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: release-darwin
|
.PHONY: release-darwin
|
||||||
release-darwin: | $(DIST_DIRS)
|
release-darwin: | $(DIST_DIRS)
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '$(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w $(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
||||||
ifeq ($(CI),true)
|
ifeq ($(CI),true)
|
||||||
cp -r /build/* $(DIST)/binaries/
|
cp -r /build/* $(DIST)/binaries/
|
||||||
endif
|
endif
|
||||||
@@ -196,18 +217,20 @@ release-check: | $(DIST_DIRS)
|
|||||||
|
|
||||||
.PHONY: release-compress
|
.PHONY: release-compress
|
||||||
release-compress: | $(DIST_DIRS)
|
release-compress: | $(DIST_DIRS)
|
||||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PAGAGE) -k -9 $${file}; done;
|
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done;
|
||||||
|
|
||||||
.PHONY: docker
|
.PHONY: docker
|
||||||
docker:
|
docker: ## build the docker image
|
||||||
if ! docker buildx version >/dev/null 2>&1; then \
|
if ! docker buildx version >/dev/null 2>&1; then \
|
||||||
ARG_DISABLE_CONTENT_TRUST=--disable-content-trust=false; \
|
ARG_DISABLE_CONTENT_TRUST=--disable-content-trust=false; \
|
||||||
fi; \
|
fi; \
|
||||||
docker build $${ARG_DISABLE_CONTENT_TRUST} -t $(DOCKER_REF) .
|
docker build $${ARG_DISABLE_CONTENT_TRUST} -t $(DOCKER_REF) .
|
||||||
|
|
||||||
clean:
|
.PHONY: clean
|
||||||
|
clean: ## delete binary and coverage files
|
||||||
$(GO) clean -x -i ./...
|
$(GO) clean -x -i ./...
|
||||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
|
||||||
|
|
||||||
version:
|
.PHONY: version
|
||||||
|
version: ## print the version
|
||||||
@echo $(VERSION)
|
@echo $(VERSION)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# act runner
|
# Gitea Runner
|
||||||
|
|
||||||
Act runner is a runner for Gitea based on [Gitea fork](https://gitea.com/gitea/act) of [act](https://github.com/nektos/act).
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,7 +8,7 @@ Docker Engine Community version is required for docker mode. To install Docker C
|
|||||||
|
|
||||||
### Download pre-built binary
|
### Download pre-built binary
|
||||||
|
|
||||||
Visit [here](https://dl.gitea.com/act_runner/) and download the right version for your platform.
|
Visit [here](https://dl.gitea.com/gitea-runner/) and download the right version for your platform.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -26,8 +24,8 @@ make docker
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it:
|
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[actions]
|
[actions]
|
||||||
ENABLED=true
|
ENABLED=true
|
||||||
@@ -36,7 +34,7 @@ ENABLED=true
|
|||||||
### Register
|
### Register
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner register
|
./gitea-runner register
|
||||||
```
|
```
|
||||||
|
|
||||||
And you will be asked to input:
|
And you will be asked to input:
|
||||||
@@ -68,7 +66,7 @@ INFO Runner registered successfully.
|
|||||||
You can also register with command line arguments.
|
You can also register with command line arguments.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
./gitea-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.
|
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
|
||||||
@@ -76,32 +74,252 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
|||||||
### Run
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner daemon
|
./gitea-runner daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run with docker
|
### Run with docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/act_runner:nightly
|
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||||
|
|
||||||
|
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
||||||
|
|
||||||
|
### Image flavours
|
||||||
|
|
||||||
|
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||||
|
|
||||||
|
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||||
|
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||||
|
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||||
|
|
||||||
|
#### `latest` — basic
|
||||||
|
|
||||||
|
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||||
|
|
||||||
|
#### `latest-dind` — Docker-in-Docker
|
||||||
|
|
||||||
|
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||||
|
--name my_runner gitea/runner:latest-dind
|
||||||
|
```
|
||||||
|
|
||||||
|
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||||
|
|
||||||
|
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||||
|
|
||||||
|
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||||
|
|
||||||
|
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
||||||
|
|
||||||
|
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
You can also configure the runner with a configuration file.
|
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||||
The configuration file is a YAML file, you can generate a sample configuration file with `./act_runner generate-config`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner generate-config > config.yaml
|
./gitea-runner generate-config > config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
You can specify the configuration file path with `-c`/`--config` argument.
|
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner -c config.yaml register # register with config file
|
./gitea-runner -c config.yaml register
|
||||||
./act_runner -c config.yaml daemon # run with config file
|
./gitea-runner -c config.yaml daemon
|
||||||
|
./gitea-runner -c config.yaml cache-server
|
||||||
```
|
```
|
||||||
|
|
||||||
You can read the latest version of the configuration file online at [config.example.yaml](internal/pkg/config/config.example.yaml).
|
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
|
||||||
|
|
||||||
|
#### Without a config file
|
||||||
|
|
||||||
|
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
||||||
|
|
||||||
|
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
Labels decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job.
|
||||||
|
|
||||||
|
A label is written as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<name>[:<schema>[:<args>]]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Part | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||||
|
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||||
|
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||||
|
|
||||||
|
Two schemas are supported:
|
||||||
|
|
||||||
|
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||||
|
|
||||||
|
```text
|
||||||
|
macos:host
|
||||||
|
```
|
||||||
|
|
||||||
|
So with the labels
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||||
|
```
|
||||||
|
|
||||||
|
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||||
|
|
||||||
|
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||||
|
|
||||||
|
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||||
|
|
||||||
|
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||||
|
|
||||||
|
#### Registration vs config labels
|
||||||
|
|
||||||
|
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
||||||
|
|
||||||
|
The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup.
|
||||||
|
|
||||||
|
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||||
|
|
||||||
|
#### Proxy
|
||||||
|
|
||||||
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
http_proxy=http://proxy.example:3128
|
||||||
|
https_proxy=http://proxy.example:3128
|
||||||
|
no_proxy=gitea.internal,.example.local
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner uses them for its own requests and gives them to every job, in lower and upper case.
|
||||||
|
|
||||||
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
|
- the cache server
|
||||||
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
|
- the job's service containers
|
||||||
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
|
|
||||||
|
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||||
|
|
||||||
|
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||||
|
|
||||||
|
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||||
|
|
||||||
|
Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network.
|
||||||
|
|
||||||
|
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||||
|
|
||||||
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
|
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||||
|
|
||||||
|
**Cache service v2**
|
||||||
|
|
||||||
|
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
v2: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
||||||
|
|
||||||
|
**Shared cache across multiple runners**
|
||||||
|
|
||||||
|
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||||
|
|
||||||
|
1. Create a config file for the cache server host:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "replace-with-a-strong-random-secret"
|
||||||
|
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-runner -c cache-server-config.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
|
3. On every runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||||
|
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||||
|
|
||||||
|
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||||
|
|
||||||
|
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
||||||
|
|
||||||
|
#### Official Docker image
|
||||||
|
|
||||||
|
Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image entrypoint supports optional variables such as `CONFIG_FILE` (passed through as `-c`), `GITEA_RUNNER_LABELS`, `GITEA_RUNNER_EPHEMERAL`, `GITEA_RUNNER_ONCE`, `GITEA_RUNNER_NAME`, `GITEA_MAX_REG_ATTEMPTS`, `RUNNER_STATE_FILE`, and `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`. See [scripts/run.sh](scripts/run.sh) for exact behavior.
|
||||||
|
|
||||||
|
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
||||||
|
|
||||||
|
While the runner is idle it cleans up after earlier jobs:
|
||||||
|
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||||
|
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||||
|
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||||
|
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||||
|
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||||
|
|
||||||
|
#### Post-task script (`runner.post_task_script`)
|
||||||
|
|
||||||
|
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
|
||||||
|
|
||||||
|
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
|
||||||
|
|
||||||
|
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
|
||||||
|
|
||||||
|
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||||
|
|
||||||
|
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||||
|
|
||||||
|
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||||
|
|
||||||
|
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||||
|
|
||||||
|
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||||
|
|
||||||
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
VERIFICATION
|
|
||||||
Verification is intended to assist the Chocolatey moderators and community
|
|
||||||
in verifying that this package's contents are trustworthy.
|
|
||||||
|
|
||||||
Checksums: https://github.com/nektos/act/releases, in the checksums.txt file
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Do not remove this test for UTF-8: if “Ω” doesn’t appear as greek uppercase omega letter enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. -->
|
|
||||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
|
||||||
<metadata>
|
|
||||||
<id>act-cli</id>
|
|
||||||
<version>0.0.0</version>
|
|
||||||
<packageSourceUrl>https://github.com/nektos/act</packageSourceUrl>
|
|
||||||
<owners>nektos</owners>
|
|
||||||
<title>act (GitHub Actions CLI)</title>
|
|
||||||
<authors>nektos</authors>
|
|
||||||
<projectUrl>https://github.com/nektos/act</projectUrl>
|
|
||||||
<iconUrl>https://raw.githubusercontent.com/wiki/nektos/act/img/logo-150.png</iconUrl>
|
|
||||||
<copyright>Nektos</copyright>
|
|
||||||
<licenseUrl>https://raw.githubusercontent.com/nektos/act/master/LICENSE</licenseUrl>
|
|
||||||
<requireLicenseAcceptance>true</requireLicenseAcceptance>
|
|
||||||
<projectSourceUrl>https://github.com/nektos/act</projectSourceUrl>
|
|
||||||
<docsUrl>https://raw.githubusercontent.com/nektos/act/master/README.md</docsUrl>
|
|
||||||
<bugTrackerUrl>https://github.com/nektos/act/issues</bugTrackerUrl>
|
|
||||||
<tags>act github-actions actions golang ci devops</tags>
|
|
||||||
<summary>Run your GitHub Actions locally 🚀</summary>
|
|
||||||
<description>Run your GitHub Actions locally 🚀</description>
|
|
||||||
</metadata>
|
|
||||||
<files>
|
|
||||||
<file src="tools/**" target="tools" />
|
|
||||||
</files>
|
|
||||||
</package>
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
The MIT License (MIT)
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2016 David Frank
|
Copyright (c) 2022 The Gitea Authors
|
||||||
|
Copyright (c) 2019
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -19,4 +20,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package artifactcache provides a cache handler for the runner.
|
// Package artifactcache provides a cache handler for the runner.
|
||||||
//
|
//
|
||||||
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/timshannon/bolthold"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiPath = "/_apis/artifactcache"
|
||||||
|
internalPath = "/_internal"
|
||||||
|
|
||||||
|
// artifactURLTTL bounds how long a signed artifactLocation URL stays valid.
|
||||||
|
// Short enough that a leaked URL is near-worthless; long enough to let the
|
||||||
|
// @actions/cache client download a big blob that was returned from /cache.
|
||||||
|
artifactURLTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type credKey struct{}
|
||||||
|
|
||||||
|
// JobCredential ties a per-job bearer token (ACTIONS_RUNTIME_TOKEN) to the
|
||||||
|
// repository that owns it. Every cache entry is stamped with Repo on
|
||||||
|
// reserve/commit and checked on read/write so one repo can never observe or
|
||||||
|
// poison another repo's cache, even from inside a container that reaches the
|
||||||
|
// cache server over the docker bridge network.
|
||||||
|
type JobCredential struct {
|
||||||
|
Repo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// credEntry holds a registered job's credential along with an active
|
||||||
|
// registration count. RegisterJob is reference-counted so that if two tasks
|
||||||
|
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
||||||
|
// after a crash before the old registration is revoked — the first task's
|
||||||
|
// revoker does not cut the second task's auth out from under it.
|
||||||
|
type credEntry struct {
|
||||||
|
cred JobCredential
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
dir string
|
||||||
|
storage *Storage
|
||||||
|
router *httprouter.Router
|
||||||
|
listener net.Listener
|
||||||
|
port int
|
||||||
|
server *http.Server
|
||||||
|
logger logrus.FieldLogger
|
||||||
|
|
||||||
|
gcing atomic.Bool
|
||||||
|
gcAt time.Time
|
||||||
|
|
||||||
|
outboundIP string
|
||||||
|
|
||||||
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
|
// runner can use these endpoints to pre-register per-job
|
||||||
|
// ACTIONS_RUNTIME_TOKENs against this server, enabling the same
|
||||||
|
// per-job auth and repo scoping as the embedded handler over the
|
||||||
|
// network. Empty disables the control-plane entirely.
|
||||||
|
internalSecret string
|
||||||
|
|
||||||
|
// secret signs short-lived artifact download URLs. The @actions/cache
|
||||||
|
// toolkit does not send Authorization on the download request, so blob
|
||||||
|
// GETs authenticate via a per-URL HMAC signature with expiry rather than
|
||||||
|
// via the bearer token used for management endpoints.
|
||||||
|
secret []byte
|
||||||
|
|
||||||
|
credMu sync.RWMutex
|
||||||
|
creds map[string]*credEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
||||||
|
//
|
||||||
|
// internalSecret, when non-empty, enables a control-plane API at
|
||||||
|
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||||
|
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
||||||
|
// embedded in-process handler leaves it empty and registers tokens via the
|
||||||
|
// in-process RegisterJob method directly.
|
||||||
|
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
||||||
|
h := &Handler{
|
||||||
|
creds: make(map[string]*credEntry),
|
||||||
|
internalSecret: internalSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
discard := logrus.New()
|
||||||
|
discard.Out = io.Discard
|
||||||
|
logger = discard
|
||||||
|
}
|
||||||
|
logger = logger.WithField("module", "artifactcache")
|
||||||
|
h.logger = logger
|
||||||
|
|
||||||
|
if dir == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir = filepath.Join(home, ".cache", "actcache")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.dir = dir
|
||||||
|
|
||||||
|
storage, err := NewStorage(filepath.Join(dir, "cache"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.storage = storage
|
||||||
|
|
||||||
|
if outboundIP != "" {
|
||||||
|
h.outboundIP = outboundIP
|
||||||
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
|
return nil, errors.New("unable to determine outbound IP address")
|
||||||
|
} else {
|
||||||
|
h.outboundIP = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := loadOrCreateSecret(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.secret = secret
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
|
||||||
|
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
|
||||||
|
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
|
||||||
|
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
|
||||||
|
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||||
|
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||||
|
// does not attach Authorization when downloading archiveLocation.
|
||||||
|
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
||||||
|
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||||
|
// cache API can authenticate them. Always wired so the routes exist; the
|
||||||
|
// handlers themselves 401 when internalSecret is unset.
|
||||||
|
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||||
|
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||||
|
h.registerV2Routes(router)
|
||||||
|
|
||||||
|
h.router = router
|
||||||
|
|
||||||
|
h.gcCache()
|
||||||
|
|
||||||
|
// Listen on all interfaces. Binding to outboundIP only would give no real
|
||||||
|
// security benefit (it is the LAN/internet-facing address either way) and
|
||||||
|
// can break Docker Desktop variants where the host's outbound IP is not
|
||||||
|
// routable from inside the container network. Authentication is enforced
|
||||||
|
// by the bearer middleware and per-repo scoping, not by reachability.
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
listener.Close()
|
||||||
|
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||||
|
}
|
||||||
|
h.port = addr.Port
|
||||||
|
server := &http.Server{
|
||||||
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
||||||
|
logger.Errorf("http serve: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
h.listener = listener
|
||||||
|
h.server = server
|
||||||
|
|
||||||
|
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.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||||
|
// the given repository and returns a function that removes it. The runner
|
||||||
|
// calls this at job start and defers the returned func so that the credential
|
||||||
|
// is only accepted while the job is running.
|
||||||
|
//
|
||||||
|
// Registrations are reference-counted: if a token is already registered, the
|
||||||
|
// existing repo is kept and the refcount is incremented. The entry is
|
||||||
|
// removed only when every revoker returned by RegisterJob has been called.
|
||||||
|
// This keeps a stray re-registration from silently revoking a live job.
|
||||||
|
func (h *Handler) RegisterJob(token, repo string) func() {
|
||||||
|
if h == nil || token == "" {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
h.credMu.Lock()
|
||||||
|
if existing, ok := h.creds[token]; ok {
|
||||||
|
existing.refs++
|
||||||
|
} else {
|
||||||
|
h.creds[token] = &credEntry{
|
||||||
|
cred: JobCredential{Repo: repo},
|
||||||
|
refs: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.credMu.Unlock()
|
||||||
|
return func() {
|
||||||
|
h.credMu.Lock()
|
||||||
|
if entry, ok := h.creds[token]; ok {
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs <= 0 {
|
||||||
|
delete(h.creds, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.credMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeJob explicitly revokes one registration of token, mirroring one call
|
||||||
|
// of the closure returned by RegisterJob. Used by the control-plane endpoint
|
||||||
|
// so a remote runner can revoke without holding the closure.
|
||||||
|
func (h *Handler) RevokeJob(token string) {
|
||||||
|
if h == nil || token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.credMu.Lock()
|
||||||
|
if entry, ok := h.creds[token]; ok {
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs <= 0 {
|
||||||
|
delete(h.creds, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.credMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) lookupCredential(token string) (JobCredential, bool) {
|
||||||
|
h.credMu.RLock()
|
||||||
|
entry, ok := h.creds[token]
|
||||||
|
h.credMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return JobCredential{}, false
|
||||||
|
}
|
||||||
|
return entry.cred, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrCreateSecret returns the 32-byte HMAC signing key for artifact URLs,
|
||||||
|
// persisted in dir/.secret so signed URLs handed out before a restart stay
|
||||||
|
// valid across the restart and so the standalone cache-server can be pointed
|
||||||
|
// at by config.Cache.ExternalServer without the URL rotating.
|
||||||
|
func loadOrCreateSecret(dir string) ([]byte, error) {
|
||||||
|
path := filepath.Join(dir, ".secret")
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if secret, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(secret) >= 32 {
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("read cache secret: %w", err)
|
||||||
|
}
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(secret); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate cache secret: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(hex.EncodeToString(secret)), 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("write cache secret: %w", err)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Close() error {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var retErr error
|
||||||
|
if h.server != nil {
|
||||||
|
err := h.server.Close()
|
||||||
|
if err != nil {
|
||||||
|
retErr = err
|
||||||
|
}
|
||||||
|
h.server = nil
|
||||||
|
}
|
||||||
|
if h.listener != nil {
|
||||||
|
err := h.listener.Close()
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
retErr = err
|
||||||
|
}
|
||||||
|
h.listener = nil
|
||||||
|
}
|
||||||
|
return retErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) openDB() (*bolthold.Store, error) {
|
||||||
|
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
|
||||||
|
Encoder: json.Marshal,
|
||||||
|
Decoder: json.Unmarshal,
|
||||||
|
Options: &bbolt.Options{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
||||||
|
FreelistType: bbolt.DefaultOptions.FreelistType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /_apis/artifactcache/cache
|
||||||
|
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||||
|
version := r.URL.Query().Get("version")
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache == nil {
|
||||||
|
h.responseJSON(w, r, 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, 200, map[string]any{
|
||||||
|
"result": "hit",
|
||||||
|
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
|
"cacheKey": cache.Key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
||||||
|
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
||||||
|
// entry is dropped on the way out.
|
||||||
|
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||||
|
cache, err := findCache(db, repo, keys, version)
|
||||||
|
if err != nil || cache == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ok, err := h.storage.Exist(cache.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
_ = db.Delete(cache.ID, cache)
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /_apis/artifactcache/caches
|
||||||
|
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
api := &Request{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := api.ToCache()
|
||||||
|
cache.Repo = cred.Repo
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
cache.CreatedAt = now
|
||||||
|
cache.UsedAt = now
|
||||||
|
if err := insertCache(db, cache); err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, 200, map[string]any{
|
||||||
|
"cacheId": cache.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /_apis/artifactcache/caches/:id
|
||||||
|
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &Cache{}
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Get(id, cache); err != nil {
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.Repo != cred.Repo {
|
||||||
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.Complete {
|
||||||
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
start, _, err := parseContentRange(r.Header.Get("Content-Range"))
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.touchCache(uint64(id), false)
|
||||||
|
h.responseJSON(w, r, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /_apis/artifactcache/caches/:id
|
||||||
|
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &Cache{}
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Get(id, cache); err != nil {
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.Repo != cred.Repo {
|
||||||
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.Complete {
|
||||||
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
if err := h.commitCache(cache); err != nil {
|
||||||
|
h.responseJSON(w, r, 500, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
|
||||||
|
// have closed its store first: Commit concatenates the whole archive and would otherwise
|
||||||
|
// hold bolt's exclusive file lock for the duration.
|
||||||
|
func (h *Handler) commitCache(cache *Cache) error {
|
||||||
|
written, err := h.storage.Commit(cache.ID, cache.Size)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||||
|
cache.Size = written
|
||||||
|
cache.Complete = true
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.Update(cache.ID, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /_apis/artifactcache/artifacts/:id
|
||||||
|
// Authenticated via signed URL (see signedAuth), not bearer, because the
|
||||||
|
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||||
|
// Repository scoping is already enforced at find() time; the signature binds
|
||||||
|
// the URL to the specific cache ID and an expiry.
|
||||||
|
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.touchCache(uint64(id), false)
|
||||||
|
h.storage.Serve(w, r, uint64(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /_apis/artifactcache/clean
|
||||||
|
func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
h.responseJSON(w, r, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
||||||
|
// registered jobs. A match attaches the job's JobCredential to the request
|
||||||
|
// context; a miss returns 401 before the handler body runs.
|
||||||
|
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
|
token := bearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred, ok := h.lookupCredential(token)
|
||||||
|
if !ok {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
|
handler(w, r.WithContext(ctx), params)
|
||||||
|
go h.gcCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
|
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, 400, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expStr := r.URL.Query().Get("exp")
|
||||||
|
sig := r.URL.Query().Get("sig")
|
||||||
|
if expStr == "" || sig == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing signature"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("invalid expiry"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().Unix() > exp {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := h.computeSignature(purpose, id, exp)
|
||||||
|
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(w, r, params)
|
||||||
|
go h.gcCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalAuth gates the control-plane endpoints. The bearer must
|
||||||
|
// constant-time-equal the configured internalSecret. If the secret is empty,
|
||||||
|
// the control-plane is disabled and every request gets 404 — which matches
|
||||||
|
// the upstream nektos/act behavior of "the route does not exist".
|
||||||
|
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
if h.internalSecret == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := bearerToken(r)
|
||||||
|
if token == "" || !hmac.Equal([]byte(token), []byte(h.internalSecret)) {
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(w, r, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalRegisterBody struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalRevokeBody struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /_internal/register
|
||||||
|
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
var body internalRegisterBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Token == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.RegisterJob(body.Token, body.Repo)
|
||||||
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /_internal/revoke
|
||||||
|
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
var body internalRevokeBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Token == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.RevokeJob(body.Token)
|
||||||
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
|
||||||
|
return auth[len(prefix):]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func credFromContext(ctx context.Context) JobCredential {
|
||||||
|
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok {
|
||||||
|
return cred
|
||||||
|
}
|
||||||
|
return JobCredential{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
|
||||||
|
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
|
||||||
|
// other way round. Downloads use the empty purpose, the message v1 has always signed.
|
||||||
|
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
||||||
|
mac := hmac.New(sha256.New, h.secret)
|
||||||
|
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||||
|
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
||||||
|
expUnix := exp.Unix()
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||||
|
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||||
|
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||||
|
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if not found, return (nil, nil) instead of an error.
|
||||||
|
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||||
|
cache := &Cache{}
|
||||||
|
for _, prefix := range keys {
|
||||||
|
// if a key in the list matches exactly, don't return partial matches
|
||||||
|
exact, err := findExactCache(db, repo, prefix, version, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exact != nil {
|
||||||
|
return exact, nil
|
||||||
|
}
|
||||||
|
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||||
|
re, err := regexp.Compile(prefixPattern)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.FindOne(cache,
|
||||||
|
bolthold.Where("Repo").Eq(repo).
|
||||||
|
And("Key").RegExp(re).
|
||||||
|
And("Version").Eq(version).
|
||||||
|
And("Complete").Eq(true).
|
||||||
|
SortBy("CreatedAt").Reverse()); err != nil {
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
|
||||||
|
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
|
||||||
|
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
|
||||||
|
//
|
||||||
|
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
||||||
|
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||||
|
// touches UsedAt on every part.
|
||||||
|
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
|
||||||
|
sortBy := "UsedAt"
|
||||||
|
if complete {
|
||||||
|
sortBy = "CreatedAt"
|
||||||
|
}
|
||||||
|
cache := &Cache{}
|
||||||
|
err := db.FindOne(cache,
|
||||||
|
bolthold.Where("Repo").Eq(repo).
|
||||||
|
And("Key").Eq(key).
|
||||||
|
And("Version").Eq(version).
|
||||||
|
And("Complete").Eq(complete).
|
||||||
|
SortBy(sortBy).Reverse())
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||||
|
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||||
|
return fmt.Errorf("insert cache: %w", err)
|
||||||
|
}
|
||||||
|
// write back id to db
|
||||||
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
|
return fmt.Errorf("write back id to db: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||||
|
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
|
||||||
|
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
|
||||||
|
// other jobs restore no longer matching its recorded size. An entry missing from the store is
|
||||||
|
// accepted, since the signature proves the id was handed out.
|
||||||
|
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache := &Cache{}
|
||||||
|
if err := db.Get(id, cache); err != nil {
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if requireIncomplete && cache.Complete {
|
||||||
|
return fmt.Errorf("cache %d: already complete", id)
|
||||||
|
}
|
||||||
|
cache.UsedAt = time.Now().Unix()
|
||||||
|
return db.Update(cache.ID, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keepUsed = 30 * 24 * time.Hour
|
||||||
|
keepUnused = 7 * 24 * time.Hour
|
||||||
|
keepTemp = 5 * time.Minute
|
||||||
|
keepOld = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) gcCache() {
|
||||||
|
if h.gcing.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.gcing.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.gcing.Store(false)
|
||||||
|
|
||||||
|
if time.Since(h.gcAt) < time.Hour {
|
||||||
|
h.logger.Debugf("skip gc: %v", h.gcAt.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.gcAt = time.Now()
|
||||||
|
h.logger.Debugf("gc: %v", h.gcAt.String())
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Remove the caches which are not completed for a while, they are most likely to be broken.
|
||||||
|
var caches []*Cache
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
|
||||||
|
And("Complete").Eq(false),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches which have not been used recently.
|
||||||
|
caches = caches[:0]
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches which are too old.
|
||||||
|
caches = caches[:0]
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches with the same key and version within the same
|
||||||
|
// repository, keep the latest one. Aggregation must include Repo so two
|
||||||
|
// repos that happen to share a (key, version) do not evict each other —
|
||||||
|
// otherwise per-repo scoping holds for reads but one repo can age
|
||||||
|
// another out after keepOld.
|
||||||
|
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
||||||
|
if results, err := db.FindAggregate(
|
||||||
|
&Cache{},
|
||||||
|
bolthold.Where("Complete").Eq(true),
|
||||||
|
"Repo", "Key", "Version",
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find aggregate caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Count() <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Sort("CreatedAt")
|
||||||
|
caches = caches[:0]
|
||||||
|
result.Reduction(&caches)
|
||||||
|
for _, cache := range caches[:len(caches)-1] {
|
||||||
|
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
|
||||||
|
// Keep it since it has been used recently, even if it's old.
|
||||||
|
// Or it could break downloading in process.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
var data []byte
|
||||||
|
if len(v) == 0 || v[0] == nil {
|
||||||
|
data, _ = json.Marshal(struct{}{})
|
||||||
|
} else if err, ok := v[0].(error); ok {
|
||||||
|
h.logger.Errorf("%v %v: %v", r.Method, r.URL.Path, err)
|
||||||
|
data, _ = json.Marshal(map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
data, _ = json.Marshal(v[0])
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The cache service v2 API. A client on this version talks twirp to
|
||||||
|
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||||
|
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||||
|
// Both API versions are served from the same store, so a repository keeps its cache
|
||||||
|
// when a workflow moves between action versions.
|
||||||
|
const (
|
||||||
|
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||||
|
|
||||||
|
// blobPath authenticates by signature, because the client uploads without an
|
||||||
|
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||||
|
blobPath = apiPath + "/blobs"
|
||||||
|
|
||||||
|
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||||
|
blobUploadPurpose = "upload:"
|
||||||
|
|
||||||
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
|
// twirpInternal is the only error code that is not the client's fault.
|
||||||
|
twirpInternal = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
|
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||||
|
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||||
|
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||||
|
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||||
|
// the upload.
|
||||||
|
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Key == "" || req.Version == "" {
|
||||||
|
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||||
|
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||||
|
// existing and silently never saved.
|
||||||
|
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if existing != nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
cache := &Cache{
|
||||||
|
Repo: cred.Repo,
|
||||||
|
Key: req.Key,
|
||||||
|
Version: req.Version,
|
||||||
|
Size: -1, // the size is only known at finalize time
|
||||||
|
CreatedAt: now,
|
||||||
|
UsedAt: now,
|
||||||
|
}
|
||||||
|
if err := insertCache(db, cache); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache == nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Close() // commitCache needs the store closed
|
||||||
|
|
||||||
|
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||||
|
if err := h.commitCache(cache); err != nil {
|
||||||
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
// int64 fields travel as strings in the proto JSON mapping.
|
||||||
|
"entryId": strconv.FormatUint(cache.ID, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache == nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
|
"matchedKey": cache.Key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||||
|
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||||
|
// in order.
|
||||||
|
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.touchCache(id, true); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
switch strings.ToLower(query.Get("comp")) {
|
||||||
|
case "block":
|
||||||
|
blockID := query.Get("blockid")
|
||||||
|
if blockID == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||||
|
case "blocklist":
|
||||||
|
var list struct{ Latest []string }
|
||||||
|
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.storage.OrderBlocks(id, list.Latest)
|
||||||
|
default:
|
||||||
|
err = h.storage.Write(id, 0, r.Body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||||
|
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||||
|
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||||
|
// instead of a parse error.
|
||||||
|
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||||
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if code == twirpInternal {
|
||||||
|
status = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||||
|
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||||
|
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||||
|
type (
|
||||||
|
v2CreateRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
v2FinalizeRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SizeBytes json.Number `json:"size_bytes"`
|
||||||
|
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
v2DownloadRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
RestoreKeys []string `json:"restore_keys"`
|
||||||
|
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d v2DownloadRequest) keys() []string {
|
||||||
|
restoreKeys := d.RestoreKeys
|
||||||
|
if len(restoreKeys) == 0 {
|
||||||
|
restoreKeys = d.RestoreKeysCamel
|
||||||
|
}
|
||||||
|
return append([]string{d.Key}, restoreKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||||
|
var req T
|
||||||
|
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v2Call posts a twirp request to the cache service and returns the decoded response.
|
||||||
|
// Field names are the proto ones, which is what the toolkit's client sends.
|
||||||
|
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
got := map[string]any{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
|
||||||
|
func putBlob(t *testing.T, url string, content []byte) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func getURL(t *testing.T, url string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestHandler(t *testing.T) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
handler.RegisterJob(testToken, testRepo)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||||
|
// with the upload URL it used.
|
||||||
|
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||||
|
require.Equal(t, true, created["ok"])
|
||||||
|
uploadURL, _ = created["signedUploadUrl"].(string)
|
||||||
|
require.NotEmpty(t, uploadURL)
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||||
|
|
||||||
|
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": key, "version": version,
|
||||||
|
"size_bytes": strconv.Itoa(len(content)),
|
||||||
|
}), uploadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
|
||||||
|
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||||
|
// or to replace a finalized entry.
|
||||||
|
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||||
|
|
||||||
|
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
assert.NotEmpty(t, finalized["entryId"])
|
||||||
|
|
||||||
|
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||||
|
// and it is an upload URL only: nothing reads a blob back through it.
|
||||||
|
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
|
||||||
|
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "deps-v1", got["matchedKey"])
|
||||||
|
downloadURL, _ := got["signedDownloadUrl"].(string)
|
||||||
|
require.NotEmpty(t, downloadURL)
|
||||||
|
assert.Equal(t, content, getURL(t, downloadURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||||
|
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||||
|
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
|
uploadURL, _ := created["signedUploadUrl"].(string)
|
||||||
|
require.NotEmpty(t, uploadURL)
|
||||||
|
|
||||||
|
blocks := map[string][]byte{}
|
||||||
|
var order []string
|
||||||
|
for i, part := range []string{"hello ", "world", "!"} {
|
||||||
|
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||||
|
blocks[blockID] = []byte(part)
|
||||||
|
order = append(order, blockID)
|
||||||
|
}
|
||||||
|
// Upload in an order that is not the block list order.
|
||||||
|
for _, blockID := range []string{order[2], order[0], order[1]} {
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var list bytes.Buffer
|
||||||
|
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range order {
|
||||||
|
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
list.WriteString(`</BlockList>`)
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
|
||||||
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
|
})
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||||
|
require.Equal(t, true, saved["ok"])
|
||||||
|
|
||||||
|
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The toolkit serialises with the proto field names; the camelCase spellings of the same
|
||||||
|
// proto JSON mapping are accepted alongside them.
|
||||||
|
for _, field := range []string{"restore_keys", "restoreKeys"} {
|
||||||
|
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||||
|
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||||
|
})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "deps-abc", got["matchedKey"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||||
|
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, false, again["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// A key that is only a prefix of an existing one is a different entry, so the
|
||||||
|
// reservation check must be exact and not a restore-key prefix match, or the shorter
|
||||||
|
// key would be reported as existing and silently never saved.
|
||||||
|
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||||
|
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||||
|
})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The size the client declares is what Commit validates the assembled archive against.
|
||||||
|
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||||
|
})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both API versions are served from one store, so an entry written through v2 is a hit for
|
||||||
|
// a v1 client asking for the same key and version.
|
||||||
|
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
|
||||||
|
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
got := map[string]any{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||||
|
assert.NotEmpty(t, got["archiveLocation"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||||
|
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||||
|
handler.RegisterJob("other-runtime-token", "other/repo")
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||||
|
|
||||||
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
@@ -25,6 +29,7 @@ func (c *Request) ToCache() *Cache {
|
|||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||||
|
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||||
Key string `json:"key" boltholdIndex:"Key"`
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
Version string `json:"version" boltholdIndex:"Version"`
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
@@ -33,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||||
name := s.tempName(id, offset)
|
return s.writeFile(s.tempName(id, offset), reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -47,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||||
|
return s.writeFile(s.blockName(id, blockID), reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||||
|
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||||
|
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||||
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
for i, blockID := range blockIDs {
|
||||||
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = os.RemoveAll(s.tempDir(id))
|
_ = os.RemoveAll(s.tempDir(id))
|
||||||
@@ -61,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
|||||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
written, err := assemble(name, tempNames)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// If size is less than 0, it means the size is unknown.
|
||||||
|
// We can't check the size of the file, just skip the check.
|
||||||
|
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||||
|
if size >= 0 && written != size {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||||
|
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||||
|
func assemble(name string, tempNames []string) (int64, error) {
|
||||||
|
if len(tempNames) == 1 {
|
||||||
|
info, err := os.Stat(tempNames[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Size(), os.Rename(tempNames[0], name)
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.Create(name)
|
file, err := os.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -80,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
|||||||
}
|
}
|
||||||
written += n
|
written += n
|
||||||
}
|
}
|
||||||
|
|
||||||
// If size is less than 0, it means the size is unknown.
|
|
||||||
// We can't check the size of the file, just skip the check.
|
|
||||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
|
||||||
if size >= 0 && written != size {
|
|
||||||
_ = file.Close()
|
|
||||||
_ = os.Remove(name)
|
|
||||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
|||||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||||
|
// Commit's name-ordered concatenation.
|
||||||
|
const blockFilePrefix = "block-"
|
||||||
|
|
||||||
|
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||||
|
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||||
|
// path element.
|
||||||
|
sum := sha256.Sum256([]byte(blockID))
|
||||||
|
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||||
dir := s.tempDir(id)
|
dir := s.tempDir(id)
|
||||||
files, err := os.ReadDir(dir)
|
files, err := os.ReadDir(dir)
|
||||||
@@ -123,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
|||||||
}
|
}
|
||||||
var names []string
|
var names []string
|
||||||
for _, v := range files {
|
for _, v := range files {
|
||||||
if !v.IsDir() {
|
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||||
names = append(names, filepath.Join(dir, v.Name()))
|
names = append(names, filepath.Join(dir, v.Name()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,9 +17,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
"github.com/julienschmidt/httprouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileContainerResourceURL struct {
|
type FileContainerResourceURL struct {
|
||||||
@@ -55,8 +59,7 @@ type WriteFS interface {
|
|||||||
OpenAppendable(name string) (WritableFile, error)
|
OpenAppendable(name string) (WritableFile, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type readWriteFSImpl struct {
|
type readWriteFSImpl struct{}
|
||||||
}
|
|
||||||
|
|
||||||
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
||||||
return os.Open(name)
|
return os.Open(name)
|
||||||
@@ -74,7 +77,6 @@ func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,7 @@ func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
|||||||
|
|
||||||
var gzipExtension = ".gz__"
|
var gzipExtension = ".gz__"
|
||||||
|
|
||||||
func safeResolve(baseDir string, relPath string) string {
|
func safeResolve(baseDir, relPath string) string {
|
||||||
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +129,6 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|||||||
}
|
}
|
||||||
return fsys.OpenWritable(safePath)
|
return fsys.OpenWritable(safePath)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -135,11 +136,11 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|||||||
|
|
||||||
writer, ok := file.(io.Writer)
|
writer, ok := file.(io.Writer)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic(errors.New("file is not writable"))
|
panic(errors.New("File is not writable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Body == nil {
|
if req.Body == nil {
|
||||||
panic(errors.New("no body given"))
|
panic(errors.New("No body given"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(writer, req.Body)
|
_, err = io.Copy(writer, req.Body)
|
||||||
@@ -160,7 +161,7 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
|
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
json, err := json.Marshal(ResponseMessage{
|
json, err := json.Marshal(ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
@@ -214,7 +215,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
var files []ContainerItem
|
var files []ContainerItem
|
||||||
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, _ error) error {
|
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
rel, err := filepath.Rel(safePath, path)
|
rel, err := filepath.Rel(safePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,7 +254,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/artifact/*path", func(w http.ResponseWriter, _ *http.Request, params httprouter.Params) {
|
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
path := params.ByName("path")[1:]
|
path := params.ByName("path")[1:]
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
safePath := safeResolve(baseDir, path)
|
||||||
@@ -275,7 +276,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Serve(ctx context.Context, artifactPath string, addr string, port string) context.CancelFunc {
|
func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelFunc {
|
||||||
serverContext, cancel := context.WithCancel(ctx)
|
serverContext, cancel := context.WithCancel(ctx)
|
||||||
logger := common.Logger(serverContext)
|
logger := common.Logger(serverContext)
|
||||||
|
|
||||||
@@ -289,7 +290,6 @@ func Serve(ctx context.Context, artifactPath string, addr string, port string) c
|
|||||||
fsys := readWriteFSImpl{}
|
fsys := readWriteFSImpl{}
|
||||||
uploads(router, artifactPath, fsys)
|
uploads(router, artifactPath, fsys)
|
||||||
downloads(router, artifactPath, fsys)
|
downloads(router, artifactPath, fsys)
|
||||||
RoutesV4(router, artifactPath, fsys, fsys)
|
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||||
@@ -310,7 +310,7 @@ func Serve(ctx context.Context, artifactPath string, addr string, port string) c
|
|||||||
<-serverContext.Done()
|
<-serverContext.Done()
|
||||||
|
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
logger.Errorf("failed shutdown gracefully - force shutdown: %v", err)
|
logger.Errorf("Failed shutdown gracefully - force shutdown: %v", err)
|
||||||
server.Close()
|
server.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writableMapFile struct {
|
||||||
|
fstest.MapFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Write(data []byte) (int, error) {
|
||||||
|
f.Data = data
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeMapFS struct {
|
||||||
|
fstest.MapFS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewArtifactUploadPrepare(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := FileContainerResourceURL{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlob(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeArtifactUpload(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPatch, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifacts(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/file.txt": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := NamedFileContainerResourceURLResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(1, response.Count)
|
||||||
|
assert.Equal("file.txt", response.Value[0].Name)
|
||||||
|
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifactContainer(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/download/1?itemPath=some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ContainerItemResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(response.Value, 1)
|
||||||
|
assert.Equal("some/file", response.Value[0].Path)
|
||||||
|
assert.Equal("file", response.Value[0].ItemType)
|
||||||
|
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFile(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/1/some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
||||||
|
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
||||||
|
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
||||||
|
// and reachable everywhere, including when the CI job is itself a container.
|
||||||
|
func TestArtifactFlow(t *testing.T) {
|
||||||
|
artifactPath := t.TempDir()
|
||||||
|
|
||||||
|
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
|
||||||
|
// picks a free port and Close() tears the server down synchronously — avoiding both the
|
||||||
|
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
|
||||||
|
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
|
||||||
|
router := httprouter.New()
|
||||||
|
fsys := readWriteFSImpl{}
|
||||||
|
uploads(router, artifactPath, fsys)
|
||||||
|
downloads(router, artifactPath, fsys)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
baseURL := server.URL
|
||||||
|
client := server.Client()
|
||||||
|
client.Timeout = 5 * time.Second
|
||||||
|
|
||||||
|
// request performs one HTTP call and returns the status and body. The default transport adds
|
||||||
|
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
||||||
|
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(method, rawURL, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
maps.Copy(req.Header, header)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp.StatusCode, data
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("upload-and-download", func(t *testing.T) {
|
||||||
|
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
||||||
|
|
||||||
|
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var prep FileContainerResourceURL
|
||||||
|
require.NoError(t, json.Unmarshal(data, &prep))
|
||||||
|
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var msg ResponseMessage
|
||||||
|
require.NoError(t, json.Unmarshal(data, &msg))
|
||||||
|
require.Equal(t, "success", msg.Message)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var list NamedFileContainerResourceURLResponse
|
||||||
|
require.NoError(t, json.Unmarshal(data, &list))
|
||||||
|
require.Equal(t, 1, list.Count)
|
||||||
|
require.Equal(t, "my-artifact", list.Value[0].Name)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var items ContainerItemResponse
|
||||||
|
require.NoError(t, json.Unmarshal(data, &items))
|
||||||
|
require.Len(t, items.Value, 1)
|
||||||
|
require.Equal(t, "file", items.Value[0].ItemType)
|
||||||
|
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.Equal(t, content, string(data))
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, content, string(stored))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||||
|
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
_, err := gz.Write([]byte(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, gz.Close())
|
||||||
|
|
||||||
|
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
||||||
|
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
// stored compressed, with the server's gzip marker suffix
|
||||||
|
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var items ContainerItemResponse
|
||||||
|
require.NoError(t, json.Unmarshal(data, &items))
|
||||||
|
require.Len(t, items.Value, 1)
|
||||||
|
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.Equal(t, content, string(data))
|
||||||
|
})
|
||||||
|
|
||||||
|
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
|
||||||
|
// blob cannot be written outside the artifact root.
|
||||||
|
t.Run("GHSL-2023-004", func(t *testing.T) {
|
||||||
|
const runID, content = "3", "contained\n"
|
||||||
|
|
||||||
|
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
|
||||||
|
strings.NewReader(content), nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, content, string(stored))
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
|
||||||
|
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.Equal(t, content, string(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
baseDir := "/foo/bar"
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"simple": {input: "baz", want: "/foo/bar/baz"},
|
||||||
|
"nested": {input: "baz/blue", want: "/foo/bar/baz/blue"},
|
||||||
|
"dots in middle": {input: "baz/../../blue", want: "/foo/bar/blue"},
|
||||||
|
"leading dots": {input: "../../parent", want: "/foo/bar/parent"},
|
||||||
|
"root path": {input: "/root", want: "/foo/bar/root"},
|
||||||
|
"root": {input: "/", want: "/foo/bar"},
|
||||||
|
"empty": {input: "", want: "/foo/bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
||||||
|
fsys := readWriteFSImpl{}
|
||||||
|
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||||
|
|
||||||
|
w, err := fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("first"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
w, err = fsys.OpenAppendable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("-second"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err := os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "first-second", string(got))
|
||||||
|
|
||||||
|
w, err = fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("replaced"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err = os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "replaced", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||||
|
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||||
|
require.NotNil(t, cancel)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/2/../../some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||||
listNames := make([]string, 0)
|
listNames := make([]string, 0)
|
||||||
@@ -42,7 +48,7 @@ func cartN(a ...[]any) [][]any {
|
|||||||
for j, n := range n {
|
for j, n := range n {
|
||||||
pi[j] = a[j][n]
|
pi[j] = a[j][n]
|
||||||
}
|
}
|
||||||
for j := len(n) - 1; j >= 0; j-- {
|
for j := range slices.Backward(n) {
|
||||||
n[j]++
|
n[j]++
|
||||||
if n[j] < len(a[j]) {
|
if n[j] < len(a[j]) {
|
||||||
break
|
break
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDryrunContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if Dryrun(ctx) {
|
||||||
|
t.Fatal("plain context should not be dryrun")
|
||||||
|
}
|
||||||
|
if !Dryrun(WithDryrun(ctx, true)) {
|
||||||
|
t.Fatal("WithDryrun(true) should set dryrun")
|
||||||
|
}
|
||||||
|
if Dryrun(WithDryrun(ctx, false)) {
|
||||||
|
t.Fatal("WithDryrun(false) should clear dryrun")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobErrorContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
err := errors.New("job failed")
|
||||||
|
|
||||||
|
SetJobError(ctx, err)
|
||||||
|
if got := JobError(ctx); got != nil {
|
||||||
|
t.Fatalf("JobError without container = %v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = WithJobErrorContainer(ctx)
|
||||||
|
SetJobError(ctx, err)
|
||||||
|
if got := JobError(ctx); !errors.Is(got, err) {
|
||||||
|
t.Fatalf("JobError = %v, want %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerAndHookContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if Logger(ctx) != logrus.StandardLogger() {
|
||||||
|
t.Fatal("plain context should use standard logger")
|
||||||
|
}
|
||||||
|
if LoggerHook(ctx) != nil {
|
||||||
|
t.Fatal("plain context should not have a logger hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx = WithLogger(ctx, logger)
|
||||||
|
if Logger(ctx) != logger {
|
||||||
|
t.Fatal("WithLogger should set logger")
|
||||||
|
}
|
||||||
|
|
||||||
|
hook := testHook{}
|
||||||
|
ctx = WithLoggerHook(ctx, hook)
|
||||||
|
if LoggerHook(ctx) != hook {
|
||||||
|
t.Fatal("WithLoggerHook should set hook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testHook struct{}
|
||||||
|
|
||||||
|
func (testHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testHook) Fire(*logrus.Entry) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,31 +1,17 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Warning that implements `error` but safe to ignore
|
|
||||||
type Warning struct {
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error the contract for error
|
|
||||||
func (w Warning) Error() string {
|
|
||||||
return w.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warningf create a warning
|
|
||||||
func Warningf(format string, args ...any) Warning {
|
|
||||||
w := Warning{
|
|
||||||
Message: fmt.Sprintf(format, args...),
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// Executor define contract for the steps of a workflow
|
// Executor define contract for the steps of a workflow
|
||||||
type Executor func(ctx context.Context) error
|
type Executor func(ctx context.Context) error
|
||||||
|
|
||||||
@@ -53,7 +39,7 @@ func NewDebugExecutor(format string, args ...any) Executor {
|
|||||||
// NewPipelineExecutor creates a new executor from a series of other executors
|
// NewPipelineExecutor creates a new executor from a series of other executors
|
||||||
func NewPipelineExecutor(executors ...Executor) Executor {
|
func NewPipelineExecutor(executors ...Executor) Executor {
|
||||||
if len(executors) == 0 {
|
if len(executors) == 0 {
|
||||||
return func(_ context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +55,7 @@ func NewPipelineExecutor(executors ...Executor) Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewConditionalExecutor creates a new executor based on conditions
|
// NewConditionalExecutor creates a new executor based on conditions
|
||||||
func NewConditionalExecutor(conditional Conditional, trueExecutor Executor, falseExecutor Executor) Executor {
|
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if conditional(ctx) {
|
if conditional(ctx) {
|
||||||
if trueExecutor != nil {
|
if trueExecutor != nil {
|
||||||
@@ -86,13 +72,19 @@ func NewConditionalExecutor(conditional Conditional, trueExecutor Executor, fals
|
|||||||
|
|
||||||
// NewErrorExecutor creates a new executor that always errors out
|
// NewErrorExecutor creates a new executor that always errors out
|
||||||
func NewErrorExecutor(err error) Executor {
|
func NewErrorExecutor(err error) Executor {
|
||||||
return func(_ context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewParallelExecutor creates a new executor from a parallel of other executors
|
// NewParallelExecutor creates a new executor from a parallel of other executors
|
||||||
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||||
|
if len(executors) == 0 {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
work := make(chan Executor, len(executors))
|
work := make(chan Executor, len(executors))
|
||||||
errs := make(chan error, len(executors))
|
errs := make(chan error, len(executors))
|
||||||
@@ -102,12 +94,30 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
parallel = 1
|
parallel = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
||||||
|
|
||||||
for i := 0; i < parallel; i++ {
|
for i := 0; i < parallel; i++ {
|
||||||
go func(work <-chan Executor, errs chan<- error) {
|
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
||||||
|
log.Debugf("Worker %d started", workerID)
|
||||||
|
taskCount := 0
|
||||||
for executor := range work {
|
for executor := range work {
|
||||||
errs <- executor(ctx)
|
taskCount++
|
||||||
|
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||||
|
// Recover from panics in executors to avoid crashing the worker
|
||||||
|
// goroutine which would leave the runner process hung.
|
||||||
|
// https://gitea.com/gitea/runner/issues/371
|
||||||
|
errs <- func() (err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return executor(ctx)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}(work, errs)
|
log.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
||||||
|
}(i, work, errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range executors {
|
for i := range executors {
|
||||||
@@ -131,42 +141,11 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFieldExecutor(name string, value any, exec Executor) Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return exec(WithLogger(ctx, Logger(ctx).WithField(name, value)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then runs another executor if this executor succeeds
|
|
||||||
func (e Executor) ThenError(then func(ctx context.Context, err error) error) Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
err := e(ctx)
|
|
||||||
if err != nil {
|
|
||||||
switch err.(type) {
|
|
||||||
case Warning:
|
|
||||||
Logger(ctx).Warning(err.Error())
|
|
||||||
default:
|
|
||||||
return then(ctx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
return then(ctx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then runs another executor if this executor succeeds
|
// Then runs another executor if this executor succeeds
|
||||||
func (e Executor) Then(then Executor) Executor {
|
func (e Executor) Then(then Executor) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
err := e(ctx)
|
if err := e(ctx); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
switch err.(type) {
|
|
||||||
case Warning:
|
|
||||||
Logger(ctx).Warning(err.Error())
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -175,25 +154,6 @@ func (e Executor) Then(then Executor) Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then runs another executor if this executor succeeds
|
|
||||||
func (e Executor) OnError(then Executor) Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
err := e(ctx)
|
|
||||||
if err != nil {
|
|
||||||
switch err.(type) {
|
|
||||||
case Warning:
|
|
||||||
Logger(ctx).Warning(err.Error())
|
|
||||||
default:
|
|
||||||
return errors.Join(err, then(ctx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only runs this executor if conditional is true
|
// If only runs this executor if conditional is true
|
||||||
func (e Executor) If(conditional Conditional) Executor {
|
func (e Executor) If(conditional Conditional) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -216,22 +176,20 @@ func (e Executor) IfNot(conditional Conditional) Executor {
|
|||||||
|
|
||||||
// IfBool only runs this executor if conditional is true
|
// IfBool only runs this executor if conditional is true
|
||||||
func (e Executor) IfBool(conditional bool) Executor {
|
func (e Executor) IfBool(conditional bool) Executor {
|
||||||
return e.If(func(_ context.Context) bool {
|
return e.If(func(ctx context.Context) bool {
|
||||||
return conditional
|
return conditional
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finally adds an executor to run after other executor
|
// Finally adds an executor to run after other executor
|
||||||
func (e Executor) Finally(finally Executor) Executor {
|
func (e Executor) Finally(finally Executor) Executor {
|
||||||
return func(ctx context.Context) (err error) {
|
return func(ctx context.Context) error {
|
||||||
defer func() {
|
err := e(ctx)
|
||||||
err2 := finally(ctx)
|
err2 := finally(ctx)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
err = fmt.Errorf("error occurred running finally: %v (original error: %v)", err2, err)
|
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
|
||||||
}
|
}
|
||||||
}()
|
return err
|
||||||
err = e(ctx)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWorkflow(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// empty
|
||||||
|
emptyWorkflow := NewPipelineExecutor()
|
||||||
|
assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// error case
|
||||||
|
errorWorkflow := NewErrorExecutor(errors.New("test error"))
|
||||||
|
assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// multiple success case
|
||||||
|
runcount := 0
|
||||||
|
successWorkflow := NewPipelineExecutor(
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
runcount++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
runcount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(2, runcount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewConditionalExecutor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
trueCount := 0
|
||||||
|
falseCount := 0
|
||||||
|
|
||||||
|
err := NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return false
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(0, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
|
||||||
|
err = NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return true
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(1, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||||
|
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||||
|
// find the gate already open so the last one still finishes with no partner left.
|
||||||
|
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
|
||||||
|
var counted, active, peak atomic.Int32
|
||||||
|
var once sync.Once
|
||||||
|
reached := make(chan struct{})
|
||||||
|
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
counted.Add(1)
|
||||||
|
running := active.Add(1)
|
||||||
|
for {
|
||||||
|
seen := peak.Load()
|
||||||
|
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if running >= wantActive {
|
||||||
|
once.Do(func() { close(reached) })
|
||||||
|
}
|
||||||
|
<-reached
|
||||||
|
active.Add(-1)
|
||||||
|
return nil
|
||||||
|
}, &counted, &peak
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||||
|
|
||||||
|
// parallelism below 1 falls back to a single worker
|
||||||
|
exec, count, maxActive = concurrencyProbe(1)
|
||||||
|
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||||
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, NewParallelExecutor(2)(ctx))
|
||||||
|
|
||||||
|
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := NewParallelExecutor(2)(canceledCtx)
|
||||||
|
assert.ErrorIs(err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count++
|
||||||
|
return errors.New("fake error")
|
||||||
|
})
|
||||||
|
err := NewParallelExecutor(1, errorWorkflow)(ctx)
|
||||||
|
assert.Equal(1, count)
|
||||||
|
assert.ErrorIs(context.Canceled, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
errExpected := errors.New("fake error")
|
||||||
|
|
||||||
|
var count atomic.Int32
|
||||||
|
successWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count.Add(1)
|
||||||
|
return errExpected
|
||||||
|
})
|
||||||
|
err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx)
|
||||||
|
assert.Equal(int32(3), count.Load())
|
||||||
|
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
||||||
|
var successCount atomic.Int32
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range executors {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
if i == 2 {
|
||||||
|
return errors.New("fake error")
|
||||||
|
}
|
||||||
|
successCount.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
||||||
|
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var calls []string
|
||||||
|
record := func(name string) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
calls = append(calls, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
|
||||||
|
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
|
||||||
|
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
|
||||||
|
require.NoError(t, record("if-bool").IfBool(true)(ctx))
|
||||||
|
require.NoError(t, record("main").Finally(record("finally"))(ctx))
|
||||||
|
|
||||||
|
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
|
||||||
|
if !reflect.DeepEqual(calls, want) {
|
||||||
|
t.Fatalf("calls = %v, want %v", calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||||
|
mainErr := errors.New("main failed")
|
||||||
|
finalErr := errors.New("cleanup failed")
|
||||||
|
|
||||||
|
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||||
|
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionalNot(t *testing.T) {
|
||||||
|
cond := Conditional(func(context.Context) bool { return false })
|
||||||
|
if !cond.Not()(context.Background()) {
|
||||||
|
t.Fatal("inverted conditional should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
|
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||||
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||||
|
|
||||||
|
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||||
|
|
||||||
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
|
ErrNoRepo = errors.New("unable to find git repo")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||||
|
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
||||||
|
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||||
|
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||||
|
func AcquireCloneLock(dir string) func() {
|
||||||
|
return cloneLocks.Lock(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
err error
|
||||||
|
commit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Commit() string {
|
||||||
|
return e.commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
||||||
|
// concurrent use of the same repository (even read access decodes packfiles into shared
|
||||||
|
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
||||||
|
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
||||||
|
// uncontended in production.
|
||||||
|
var goGitMu sync.Mutex
|
||||||
|
|
||||||
|
// FindGitRevision get the current git revision
|
||||||
|
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||||
|
goGitMu.Lock()
|
||||||
|
defer goGitMu.Unlock()
|
||||||
|
return findGitRevision(ctx, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
gitDir, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
head, err := gitDir.Reference(plumbing.HEAD, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if head.Hash().IsZero() {
|
||||||
|
return "", "", errors.New("HEAD sha1 could not be resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := head.Hash().String()
|
||||||
|
|
||||||
|
logger.Debugf("Found revision: %s", hash)
|
||||||
|
return hash[:7], strings.TrimSpace(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindGitRef get the current git ref
|
||||||
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||||
|
goGitMu.Lock()
|
||||||
|
defer goGitMu.Unlock()
|
||||||
|
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
logger.Debugf("Loading revision from git directory")
|
||||||
|
_, ref, err := findGitRevision(ctx, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("HEAD points to '%s'", ref)
|
||||||
|
|
||||||
|
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
||||||
|
refTag := ""
|
||||||
|
refBranch := ""
|
||||||
|
repo, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := repo.References()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the reference that matches the revision's has
|
||||||
|
err = iter.ForEach(func(r *plumbing.Reference) error {
|
||||||
|
/* tags and branches will have the same hash
|
||||||
|
* when a user checks out a tag, it is not mentioned explicitly
|
||||||
|
* in the go-git package, we must identify the revision
|
||||||
|
* then check if any tag matches that revision,
|
||||||
|
* if so then we checked out a tag
|
||||||
|
* else we look for branches and if matches,
|
||||||
|
* it means we checked out a branch
|
||||||
|
*
|
||||||
|
* If a branches matches first we must continue and check all tags (all references)
|
||||||
|
* in case we match with a tag later in the interation
|
||||||
|
*/
|
||||||
|
if r.Hash().String() == ref {
|
||||||
|
if r.Name().IsTag() {
|
||||||
|
refTag = r.Name().String()
|
||||||
|
}
|
||||||
|
if r.Name().IsBranch() {
|
||||||
|
refBranch = r.Name().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we found what we where looking for
|
||||||
|
if refTag != "" && refBranch != "" {
|
||||||
|
return storer.ErrStop
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// order matters here see above comment.
|
||||||
|
if refTag != "" {
|
||||||
|
return refTag, nil
|
||||||
|
}
|
||||||
|
if refBranch != "" {
|
||||||
|
return refBranch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindGithubRepo get the repo
|
||||||
|
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||||
|
goGitMu.Lock()
|
||||||
|
defer goGitMu.Unlock()
|
||||||
|
if remoteName == "" {
|
||||||
|
remoteName = "origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := findGitRemoteURL(ctx, file, remoteName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, slug, err := findGitSlug(url, githubInstance)
|
||||||
|
return slug, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||||
|
repo, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := repo.Remote(remoteName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remote.Config().URLs) < 1 {
|
||||||
|
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote.Config().URLs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGitSlug(url, githubInstance string) (string, string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "CodeCommit", matches[2], nil
|
||||||
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "CodeCommit", matches[2], nil
|
||||||
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if githubInstance != "github.com" {
|
||||||
|
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||||
|
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
||||||
|
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
|
type NewGitCloneExecutorInput struct {
|
||||||
|
URL string
|
||||||
|
Ref string
|
||||||
|
Dir string
|
||||||
|
Token string
|
||||||
|
OfflineMode bool
|
||||||
|
|
||||||
|
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
|
||||||
|
// 0 for full clone.
|
||||||
|
Depth int
|
||||||
|
|
||||||
|
// Quiet drops the informational clone line to debug level, for callers that log their own
|
||||||
|
// download summary (the setup section's action report).
|
||||||
|
Quiet bool
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
InsecureSkipTLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
||||||
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||||
|
r, err := git.PlainOpen(input.Dir)
|
||||||
|
if err == nil {
|
||||||
|
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||||
|
remote, err := r.Remote("origin")
|
||||||
|
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||||
|
// Reuse existing clone
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
|
} else if len(remote.Config().URLs) == 0 {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter io.Writer
|
||||||
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
|
if entry, ok := logger.(*log.Entry); ok {
|
||||||
|
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||||
|
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||||
|
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||||
|
} else {
|
||||||
|
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||||
|
progressWriter = os.Stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneOptions := git.CloneOptions{
|
||||||
|
URL: input.URL,
|
||||||
|
Progress: progressWriter,
|
||||||
|
|
||||||
|
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||||
|
}
|
||||||
|
if input.Token != "" {
|
||||||
|
cloneOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||||
|
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
||||||
|
fetchOptions.Force = true
|
||||||
|
pullOptions.Force = true
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
auth := &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: token,
|
||||||
|
}
|
||||||
|
fetchOptions.Auth = auth
|
||||||
|
pullOptions.Auth = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchOptions, pullOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
if input.Quiet {
|
||||||
|
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
} else {
|
||||||
|
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
}
|
||||||
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
|
defer AcquireCloneLock(input.Dir)()
|
||||||
|
|
||||||
|
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||||
|
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
|
// fetch latest changes
|
||||||
|
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||||
|
|
||||||
|
if input.InsecureSkipTLS { // For Gitea
|
||||||
|
fetchOptions.InsecureSkipTLS = true
|
||||||
|
pullOptions.InsecureSkipTLS = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
|
shallow := isShallow(r)
|
||||||
|
if shallow {
|
||||||
|
fetchOptions.Depth = 1
|
||||||
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOfflineMode {
|
||||||
|
err = r.Fetch(&fetchOptions)
|
||||||
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash *plumbing.Hash
|
||||||
|
rev := plumbing.Revision(input.Ref)
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
|
||||||
|
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||||
|
return &Error{
|
||||||
|
err: ErrShortRef,
|
||||||
|
commit: hash.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point we need to know if it's a tag or a branch
|
||||||
|
// And the easiest way to do it is duck typing
|
||||||
|
//
|
||||||
|
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
||||||
|
// it was a sha
|
||||||
|
refType := "tag"
|
||||||
|
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
||||||
|
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
||||||
|
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||||
|
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||||
|
refType = "sha"
|
||||||
|
rev = plumbing.Revision(input.Ref)
|
||||||
|
} else {
|
||||||
|
refType = "branch"
|
||||||
|
rev = plumbing.Revision(rName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var w *git.Worktree
|
||||||
|
if w, err = r.Worktree(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
||||||
|
// using a branch or tag ref, not a sha
|
||||||
|
//
|
||||||
|
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
||||||
|
// we try and pull down any changes
|
||||||
|
if hash.String() != input.Ref && refType == "branch" {
|
||||||
|
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
||||||
|
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||||
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
|
Branch: sourceRef,
|
||||||
|
Force: true,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reusedMsg := ""
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !isOfflineMode && !shallow:
|
||||||
|
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||||
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
|
}
|
||||||
|
case isOfflineMode && reused:
|
||||||
|
reusedMsg = " (reused in offline mode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
|
if hash.String() != input.Ref && refType == "branch" {
|
||||||
|
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
|
Hash: *hash,
|
||||||
|
Force: true,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = w.Reset(&git.ResetOptions{
|
||||||
|
Mode: git.HardReset,
|
||||||
|
Commit: *hash,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||||
|
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||||
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
|
if input.Depth > 0 {
|
||||||
|
for _, refName := range []plumbing.ReferenceName{
|
||||||
|
plumbing.NewBranchReferenceName(input.Ref),
|
||||||
|
plumbing.NewTagReferenceName(input.Ref),
|
||||||
|
} {
|
||||||
|
shallowOpts := opts
|
||||||
|
shallowOpts.Depth = input.Depth
|
||||||
|
shallowOpts.SingleBranch = true
|
||||||
|
shallowOpts.ReferenceName = refName
|
||||||
|
shallowOpts.Tags = git.NoTags
|
||||||
|
|
||||||
|
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
|
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||||
|
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
|
func isShallow(r *git.Repository) bool {
|
||||||
|
shallows, err := r.Storer.Shallow()
|
||||||
|
return err == nil && len(shallows) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||||
|
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||||
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindGitSlug(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
slugTests := []struct {
|
||||||
|
url string // input
|
||||||
|
provider string // expected result
|
||||||
|
slug string // expected result
|
||||||
|
}{
|
||||||
|
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
|
||||||
|
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
|
||||||
|
{"[email protected]:nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"[email protected]:nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"git+ssh://[email protected]/owner/repo.git", "GitHub", "owner/repo"},
|
||||||
|
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range slugTests {
|
||||||
|
provider, slug, err := findGitSlug(tt.url, "github.com")
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(tt.provider, provider)
|
||||||
|
assert.Equal(tt.slug, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorWrapsCommitAndCause(t *testing.T) {
|
||||||
|
err := &Error{err: ErrShortRef, commit: "abc123"}
|
||||||
|
require.Equal(t, ErrShortRef.Error(), err.Error())
|
||||||
|
require.ErrorIs(t, err, ErrShortRef)
|
||||||
|
require.Equal(t, "abc123", err.Commit())
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanGitHooks(dir string) error {
|
||||||
|
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||||
|
files, err := os.ReadDir(hooksDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relName := filepath.Join(hooksDir, f.Name())
|
||||||
|
if err := os.Remove(relName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
basedir := t.TempDir()
|
||||||
|
err := gitCmd("init", basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
err = cleanGitHooks(basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
|
||||||
|
remoteURL = "[email protected]/AwesomeOwner/MyAwesomeRepo.git"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
||||||
|
basedir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", basedir))
|
||||||
|
require.NoError(t, cleanGitHooks(basedir))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "[email protected]:team/project.git"))
|
||||||
|
|
||||||
|
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "owner/repo", slug)
|
||||||
|
|
||||||
|
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "team/project", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitFindRef(t *testing.T) {
|
||||||
|
basedir := t.TempDir()
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Prepare func(t *testing.T, dir string)
|
||||||
|
Assert func(t *testing.T, ref string, err error)
|
||||||
|
}{
|
||||||
|
"new_repo": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.Error(t, err)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"new_repo_with_commit": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/master", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/tags/v1.2.3", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_same_as_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/tags/v1.4.2", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_not_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/master", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_another_branch": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/mybranch", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := filepath.Join(basedir, name)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
||||||
|
require.NoError(t, cleanGitHooks(dir))
|
||||||
|
tt.Prepare(t, dir)
|
||||||
|
ref, err := FindGitRef(context.Background(), dir)
|
||||||
|
tt.Assert(t, ref, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutor(t *testing.T) {
|
||||||
|
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
|
||||||
|
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
|
||||||
|
// cloning actions/checkout and anchore/scan-action over the network.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||||
|
|
||||||
|
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
fullSha := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Err error
|
||||||
|
Ref string
|
||||||
|
}{
|
||||||
|
"tag": {
|
||||||
|
Err: nil,
|
||||||
|
Ref: "v2",
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
Err: nil,
|
||||||
|
Ref: "act-fails",
|
||||||
|
},
|
||||||
|
"sha": {
|
||||||
|
Err: nil,
|
||||||
|
Ref: fullSha,
|
||||||
|
},
|
||||||
|
"short-sha": {
|
||||||
|
Err: &Error{ErrShortRef, fullSha},
|
||||||
|
Ref: fullSha[:7],
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: tt.Ref,
|
||||||
|
Dir: t.TempDir(),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := clone(context.Background())
|
||||||
|
if tt.Err != nil {
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.Err, err)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
|
createRemote := func(message string) string {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
return remoteDir
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRemoteDir := createRemote("old-action")
|
||||||
|
newRemoteDir := createRemote("new-action")
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: oldRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||||
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: newRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newRemoteDir, originURL)
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
||||||
|
|
||||||
|
_, err = os.Stat(markerPath)
|
||||||
|
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||||
|
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||||
|
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||||
|
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||||
|
|
||||||
|
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
|
||||||
|
// We need a working clone to push commits from.
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Create a feature branch (simulates refs/pull/N/head).
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
|
||||||
|
|
||||||
|
// First clone via the executor — should succeed and cache the repo.
|
||||||
|
cloneDir := t.TempDir()
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cloneDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, clone(context.Background()))
|
||||||
|
|
||||||
|
// Now force-push the feature branch to a non-fast-forward commit (simulates
|
||||||
|
// a PR rebase). This makes refs/heads/feature non-fast-forward.
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
|
||||||
|
|
||||||
|
// Also advance main so we can verify the clone picks up the new commit.
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||||
|
|
||||||
|
// Second clone to the same directory — before the fix this returned ErrForceNeeded
|
||||||
|
// and left the working tree at the old commit.
|
||||||
|
err := clone(context.Background())
|
||||||
|
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
|
||||||
|
|
||||||
|
// Verify the working tree was actually updated to the latest main commit.
|
||||||
|
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||||
|
// Build a local "remote" with a single commit on main.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Prime the cache with an online clone of main.
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(context.Background()))
|
||||||
|
|
||||||
|
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
||||||
|
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
||||||
|
// finds no local refs/heads/<ref>.
|
||||||
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
OfflineMode: true,
|
||||||
|
})(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unresolvable cached ref returns error", func(t *testing.T) {
|
||||||
|
// The ref was never cached; offline mode cannot resolve it and must return an error.
|
||||||
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "never-fetched",
|
||||||
|
Dir: cacheDir,
|
||||||
|
OfflineMode: true,
|
||||||
|
})(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
||||||
|
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
logger.SetLevel(log.InfoLevel)
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: t.TempDir(),
|
||||||
|
Quiet: quiet,
|
||||||
|
})(ctx))
|
||||||
|
|
||||||
|
var cloneLines int
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
if strings.HasPrefix(entry.Message, "git clone ") {
|
||||||
|
cloneLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quiet {
|
||||||
|
assert.Zero(t, cloneLines)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, cloneLines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
for _, m := range []string{"c1", "c2", "c3"} {
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
|
}
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||||
|
|
||||||
|
t.Run("branch is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tag is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||||
|
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||||
|
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
|
// Advance main on the remote, then reuse the existing shallow clone.
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevCount(t *testing.T, dir string) int {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitHeadSubject(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitCmd(args ...string) error {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
// Inject a deterministic identity and ignore the host's global/system config so commits
|
||||||
|
// succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS
|
||||||
|
// unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig.
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=Unit Test",
|
||||||
|
"[email protected]",
|
||||||
|
"GIT_COMMITTER_NAME=Unit Test",
|
||||||
|
"[email protected]",
|
||||||
|
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||||
|
"GIT_CONFIG_SYSTEM=/dev/null",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
|
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok {
|
||||||
|
return fmt.Errorf("Exit error %d", waitStatus.ExitStatus())
|
||||||
|
}
|
||||||
|
return exitError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcquireCloneLock(t *testing.T) {
|
||||||
|
t.Run("same directory serializes", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
unlock1 := AcquireCloneLock(dir)
|
||||||
|
|
||||||
|
secondAcquired := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
unlock := AcquireCloneLock(dir)
|
||||||
|
close(secondAcquired)
|
||||||
|
unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-secondAcquired:
|
||||||
|
t.Fatal("second acquire should block while first holds the lock")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock1()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-secondAcquired:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("second acquire should proceed after first releases the lock")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("different directories do not block", func(t *testing.T) {
|
||||||
|
dirA := t.TempDir()
|
||||||
|
dirB := t.TempDir()
|
||||||
|
|
||||||
|
unlockA := AcquireCloneLock(dirA)
|
||||||
|
defer unlockA()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
unlock := AcquireCloneLock(dirB)
|
||||||
|
unlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("acquire on a different directory must not block")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jobErrorContextKey string
|
||||||
|
|
||||||
|
const jobErrorContextKeyVal = jobErrorContextKey("job.error")
|
||||||
|
|
||||||
|
// JobError returns the job error for current context if any
|
||||||
|
func JobError(ctx context.Context) error {
|
||||||
|
val := ctx.Value(jobErrorContextKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if container, ok := val.(map[string]error); ok {
|
||||||
|
return container["error"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJobError(ctx context.Context, err error) {
|
||||||
|
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
|
||||||
|
container["error"] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||||
|
func WithJobErrorContainer(ctx context.Context) context.Context {
|
||||||
|
container := map[string]error{}
|
||||||
|
return context.WithValue(ctx, jobErrorContextKeyVal, container)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineHandler is a callback function for handling a line
|
||||||
|
type LineHandler func(line string) bool
|
||||||
|
|
||||||
|
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||||
|
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||||
|
// final line (when it is not newline-terminated) is not lost.
|
||||||
|
type Flusher interface {
|
||||||
|
Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineWriter struct {
|
||||||
|
buffer bytes.Buffer
|
||||||
|
handlers []LineHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLineWriter creates a new instance of a line writer
|
||||||
|
func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||||
|
w := new(lineWriter)
|
||||||
|
w.handlers = handlers
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||||
|
// callers can flush an io.Writer without knowing its concrete type.
|
||||||
|
func FlushWriter(w io.Writer) {
|
||||||
|
if f, ok := w.(Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||||
|
pBuf := bytes.NewBuffer(p)
|
||||||
|
written := 0
|
||||||
|
for {
|
||||||
|
line, err := pBuf.ReadString('\n')
|
||||||
|
w, _ := lw.buffer.WriteString(line)
|
||||||
|
written += w
|
||||||
|
if err == nil {
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
|
} else if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||||
|
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||||
|
// no-ops.
|
||||||
|
func (lw *lineWriter) Flush() {
|
||||||
|
if lw.buffer.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *lineWriter) handleLine(line string) {
|
||||||
|
for _, h := range lw.handlers {
|
||||||
|
ok := h(line)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLineWriter(t *testing.T) {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
lineHandler := func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lineWriter := NewLineWriter(lineHandler)
|
||||||
|
|
||||||
|
assert := assert.New(t)
|
||||||
|
write := func(s string) {
|
||||||
|
n, err := lineWriter.Write([]byte(s))
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(len(s), n, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
write("hello")
|
||||||
|
write(" ")
|
||||||
|
write("world!!\nextra")
|
||||||
|
write(" line\n and another\nlast")
|
||||||
|
write(" line\n")
|
||||||
|
write("no newline here...")
|
||||||
|
|
||||||
|
assert.Len(lines, 4)
|
||||||
|
assert.Equal("hello world!!\n", lines[0])
|
||||||
|
assert.Equal("extra line\n", lines[1])
|
||||||
|
assert.Equal(" and another\n", lines[2])
|
||||||
|
assert.Equal("last line\n", lines[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLineWriterFlush(t *testing.T) {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
lineHandler := func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lineWriter := NewLineWriter(lineHandler)
|
||||||
|
|
||||||
|
assert := assert.New(t)
|
||||||
|
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
|
||||||
|
|
||||||
|
// Only the newline-terminated line is emitted before flushing.
|
||||||
|
assert.Equal([]string{"complete line\n"}, lines)
|
||||||
|
|
||||||
|
// Flushing emits the buffered, not-yet-terminated trailing line.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||||
|
|
||||||
|
// Flushing again is a no-op: nothing is buffered.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Len(lines, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
|
||||||
|
// FlushWriter must be a safe no-op for writers that do not buffer lines.
|
||||||
|
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loggerContextKey string
|
||||||
|
|
||||||
|
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
||||||
|
|
||||||
|
// Logger returns the appropriate logger for current context
|
||||||
|
func Logger(ctx context.Context) logrus.FieldLogger {
|
||||||
|
val := ctx.Value(loggerContextKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if logger, ok := val.(logrus.FieldLogger); ok {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logrus.StandardLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogger adds a value to the context for the logger
|
||||||
|
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
|
||||||
|
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
type loggerHookKey string
|
||||||
|
|
||||||
|
const loggerHookKeyVal = loggerHookKey("logrus.Hook")
|
||||||
|
|
||||||
|
// LoggerHook returns the appropriate logger hook for current context
|
||||||
|
// the hook affects job logger, not global logger
|
||||||
|
func LoggerHook(ctx context.Context) logrus.Hook {
|
||||||
|
val := ctx.Value(loggerHookKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if hook, ok := val.(logrus.Hook); ok {
|
||||||
|
return hook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLoggerHook adds a value to the context for the logger hook
|
||||||
|
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
||||||
|
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,7 +19,9 @@ func GetOutboundIP() net.IP {
|
|||||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||||
|
return addr.IP
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
|
type ExitCodeError int
|
||||||
|
|
||||||
|
func (e ExitCodeError) Error() string {
|
||||||
|
return fmt.Sprintf("Process completed with exit code %d.", int(e))
|
||||||
|
}
|
||||||
|
|
||||||
// NewContainerInput the input for the New function
|
// NewContainerInput the input for the New function
|
||||||
type NewContainerInput struct {
|
type NewContainerInput struct {
|
||||||
Image string
|
Image string
|
||||||
@@ -32,21 +43,27 @@ type NewContainerInput struct {
|
|||||||
NetworkAliases []string
|
NetworkAliases []string
|
||||||
ExposedPorts nat.PortSet
|
ExposedPorts nat.PortSet
|
||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
|
|
||||||
|
// Gitea specific
|
||||||
|
AutoRemove bool
|
||||||
|
ValidVolumes []string
|
||||||
|
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileEntry is a file to copy to a container
|
// FileEntry is a file to copy to a container
|
||||||
type FileEntry struct {
|
type FileEntry struct {
|
||||||
Name string
|
Name string
|
||||||
Mode uint32
|
Mode int64
|
||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container for managing docker run containers
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd []string, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
|
ConnectToNetwork(name string) common.Executor
|
||||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||||
CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor
|
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||||
Pull(forcePull bool) common.Executor
|
Pull(forcePull bool) common.Executor
|
||||||
Start(attach bool) common.Executor
|
Start(attach bool) common.Executor
|
||||||
@@ -56,7 +73,6 @@ type Container interface {
|
|||||||
Remove() common.Executor
|
Remove() common.Executor
|
||||||
Close() common.Executor
|
Close() common.Executor
|
||||||
ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer)
|
ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer)
|
||||||
GetHealth(ctx context.Context) Health
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
|
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
|
||||||
@@ -66,6 +82,14 @@ type NewDockerBuildExecutorInput struct {
|
|||||||
BuildContext io.Reader
|
BuildContext io.Reader
|
||||||
ImageTag string
|
ImageTag string
|
||||||
Platform string
|
Platform string
|
||||||
|
BuildArgs map[string]*string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||||
|
type NewDockerNetworkCreateExecutorInput struct {
|
||||||
|
EnableIPv4 *bool
|
||||||
|
EnableIPv6 *bool
|
||||||
|
RunnerUUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||||
@@ -76,21 +100,3 @@ type NewDockerPullExecutorInput struct {
|
|||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Health int
|
|
||||||
|
|
||||||
const (
|
|
||||||
HealthStarting Health = iota
|
|
||||||
HealthHealthy
|
|
||||||
HealthUnHealthy
|
|
||||||
)
|
|
||||||
|
|
||||||
var containerAllocateTerminal bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
containerAllocateTerminal = term.IsTerminal(int(os.Stdout.Fd()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetContainerAllocateTerminal(val bool) {
|
|
||||||
containerAllocateTerminal = val
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/moby/moby/api/types/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
||||||
|
// file is malformed; use config.Load to route errors through the logger.
|
||||||
|
cfg, err := config.Load(config.Dir())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
|
return registry.AuthConfig{}, err
|
||||||
|
}
|
||||||
|
registryKey := registryAuthConfigKey("docker.io")
|
||||||
|
if image != "" {
|
||||||
|
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
||||||
|
logger.Warnf("Could not normalize image reference: %v", refErr)
|
||||||
|
} else {
|
||||||
|
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authConfig, err := cfg.GetAuthConfig(registryKey)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||||
|
return registry.AuthConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.AuthConfig(authConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
cfg, err := config.Load(config.Dir())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
creds, err := cfg.GetAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||||
|
for k, v := range creds {
|
||||||
|
authConfigs[k] = registry.AuthConfig(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return authConfigs
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryAuthConfigKey(domainName string) string {
|
||||||
|
if domainName == "docker.io" || domainName == "index.docker.io" {
|
||||||
|
return "https://index.docker.io/v1/"
|
||||||
|
}
|
||||||
|
return domainName
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/build"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"github.com/moby/go-archive"
|
|
||||||
|
|
||||||
|
"github.com/moby/go-archive"
|
||||||
|
"github.com/moby/go-archive/compression"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
"github.com/moby/patternmatcher"
|
"github.com/moby/patternmatcher"
|
||||||
"github.com/moby/patternmatcher/ignorefile"
|
"github.com/moby/patternmatcher/ignorefile"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDockerBuildExecutor function to create a run executor for the container
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
@@ -23,9 +27,9 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
if input.Platform != "" {
|
if input.Platform != "" {
|
||||||
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
|
logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
|
logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
|
||||||
}
|
}
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -40,12 +44,19 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
logger.Debugf("Building image from '%v'", input.ContextDir)
|
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||||
|
|
||||||
tags := []string{input.ImageTag}
|
tags := []string{input.ImageTag}
|
||||||
options := build.ImageBuildOptions{
|
options := client.ImageBuildOptions{
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
Platform: input.Platform,
|
|
||||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||||
Dockerfile: input.Dockerfile,
|
Dockerfile: input.Dockerfile,
|
||||||
|
BuildArgs: input.BuildArgs,
|
||||||
|
}
|
||||||
|
platform, err := parsePlatform(input.Platform)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if platform != nil {
|
||||||
|
options.Platforms = []specs.Platform{*platform}
|
||||||
}
|
}
|
||||||
var buildContext io.ReadCloser
|
var buildContext io.ReadCloser
|
||||||
if input.BuildContext != nil {
|
if input.BuildContext != nil {
|
||||||
@@ -62,11 +73,15 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
|
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
|
||||||
resp, err := cli.ImageBuild(ctx, buildContext, options)
|
resp, err := cli.ImageBuild(ctx, buildContext, options)
|
||||||
|
|
||||||
err = errors.Join(err, logDockerResponse(logger, resp.Body, err != nil))
|
err = logDockerResponse(logger, resp.Body, err != nil)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func createBuildContext(ctx context.Context, contextDir string, relDockerfile string) (io.ReadCloser, error) {
|
|
||||||
|
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
||||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||||
|
|
||||||
// And canonicalize dockerfile name to a platform-independent one
|
// And canonicalize dockerfile name to a platform-independent one
|
||||||
@@ -93,16 +108,15 @@ func createBuildContext(ctx context.Context, contextDir string, relDockerfile st
|
|||||||
// removed. The daemon will remove them for us, if needed, after it
|
// removed. The daemon will remove them for us, if needed, after it
|
||||||
// parses the Dockerfile. Ignore errors here, as they will have been
|
// parses the Dockerfile. Ignore errors here, as they will have been
|
||||||
// caught by validateContextDirectory above.
|
// caught by validateContextDirectory above.
|
||||||
var includes = []string{"."}
|
includes := []string{"."}
|
||||||
keepThem1, _ := patternmatcher.Matches(".dockerignore", excludes)
|
keepThem1, _ := patternmatcher.Matches(".dockerignore", excludes)
|
||||||
keepThem2, _ := patternmatcher.Matches(relDockerfile, excludes)
|
keepThem2, _ := patternmatcher.Matches(relDockerfile, excludes)
|
||||||
if keepThem1 || keepThem2 {
|
if keepThem1 || keepThem2 {
|
||||||
includes = append(includes, ".dockerignore", relDockerfile)
|
includes = append(includes, ".dockerignore", relDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
compression := archive.Uncompressed
|
|
||||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||||
Compression: compression,
|
Compression: compression.None,
|
||||||
ExcludePatterns: excludes,
|
ExcludePatterns: excludes,
|
||||||
IncludeFiles: includes,
|
IncludeFiles: includes,
|
||||||
})
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,47 @@
|
|||||||
// This file is exact copy of https://github.com/docker/cli/blob/9ac8584acfd501c3f4da0e845e3a40ed15c85041/cli/command/container/opts_test.go with:
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * commented out case 'invalid-mixed-network-types' in test TestParseNetworkConfig
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
// See DOCKER_LICENSE for the full license text.
|
// See DOCKER_LICENSE for the full license text.
|
||||||
//
|
//
|
||||||
|
|
||||||
//nolint:whitespace,depguard,dupl,gocritic
|
//nolint:gocritic // verbatim copy from docker/cli tests
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
networktypes "github.com/docker/docker/api/types/network"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/docker/go-connections/nat"
|
networktypes "github.com/moby/moby/api/types/network"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
is "gotest.tools/v3/assert/cmp"
|
is "gotest.tools/v3/assert/cmp"
|
||||||
"gotest.tools/v3/skip"
|
"gotest.tools/v3/skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func mustParseMAC(s string) networktypes.HardwareAddr {
|
||||||
|
mac, err := net.ParseMAC(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return networktypes.HardwareAddr(mac)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateAttach(t *testing.T) {
|
func TestValidateAttach(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"stdin",
|
"stdin",
|
||||||
@@ -58,12 +71,12 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network
|
|||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
// TODO: fix tests to accept ContainerConfig
|
// TODO(dnephin): fix tests to accept ContainerConfig; see https://github.com/moby/moby/pull/31621
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
containerCfg, err := parse(flags, copts, runtime.GOOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
return containerConfig.Config, containerConfig.HostConfig, containerConfig.NetworkingConfig, err
|
return containerCfg.Config, containerCfg.HostConfig, containerCfg.NetworkingConfig, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||||
@@ -76,20 +89,81 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
|
|
||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
return config, hostConfig, networkingConfig
|
return config, hostConfig, nwConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunLinks(t *testing.T) {
|
func TestParseRunLinks(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
tests := []struct {
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
name string
|
||||||
|
input string
|
||||||
|
expHostConfigLinks []string
|
||||||
|
expNetConfigLinks map[string][]string
|
||||||
|
}{
|
||||||
|
// Default bridge - legacy links ...
|
||||||
|
{
|
||||||
|
name: "default/onelink",
|
||||||
|
input: "--link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/twolinks",
|
||||||
|
input: "--link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bridge/onelink",
|
||||||
|
input: "--network bridge --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
// expNetConfigLinks - no EndpointsConfig is created for a single named network with no options set.
|
||||||
|
// See the "For backward compatibility" comment in parseNetworkOpts().
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/nolinks",
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
|
||||||
|
// User-defined bridge - links become DNS aliases ...
|
||||||
|
{
|
||||||
|
name: "userdefnet/onelink",
|
||||||
|
input: "--network userdefnet --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/twolinks",
|
||||||
|
input: "--network userdefnet --link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/nolinks",
|
||||||
|
input: "--network userdefnet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Link options are applied to the first network (and there's no "advanced syntax"
|
||||||
|
// link key, like "--network name=userdefnet,link=a:b").
|
||||||
|
name: "links apply to the first network",
|
||||||
|
input: "--network userdefnet --link a:b --network bar --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}, "bar": nil},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
for _, tc := range tests {
|
||||||
}
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
||||||
|
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
||||||
|
for netName, expLinks := range tc.expNetConfigLinks {
|
||||||
|
nc, ok := netConfig.EndpointsConfig[netName]
|
||||||
|
assert.Assert(t, ok)
|
||||||
|
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +260,12 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
|||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||||
require.Error(t, flags.Parse(tc.args), tc.error)
|
assert.Error(t, flags.Parse(tc.args), tc.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocyclo
|
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
|
||||||
func TestParseWithVolumes(t *testing.T) {
|
|
||||||
|
|
||||||
// A single volume
|
// A single volume
|
||||||
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||||
@@ -261,14 +333,13 @@ func TestParseWithVolumes(t *testing.T) {
|
|||||||
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
|
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupPlatformVolume takes two arrays of volume specs - a Unix style
|
// setupPlatformVolume takes two arrays of volume specs - a Unix style
|
||||||
// spec and a Windows style spec. Depending on the platform being unit tested,
|
// spec and a Windows style spec. Depending on the platform being unit tested,
|
||||||
// it returns one of them, along with a volume string that would be passed
|
// it returns one of them, along with a volume string that would be passed
|
||||||
// on the docker CLI (e.g. -v /bar -v /foo).
|
// on the docker CLI (e.g. -v /bar -v /foo).
|
||||||
func setupPlatformVolume(u []string, w []string) ([]string, string) {
|
func setupPlatformVolume(u, w []string) ([]string, string) {
|
||||||
var a []string
|
var a []string
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
a = w
|
a = w
|
||||||
@@ -291,7 +362,7 @@ func compareRandomizedStrings(a, b, c, d string) error {
|
|||||||
if a == d && b == c {
|
if a == d && b == c {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Errorf("strings don't match")
|
return errors.New("strings don't match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple parse with MacAddress validation
|
// Simple parse with MacAddress validation
|
||||||
@@ -301,9 +372,11 @@ func TestParseWithMacAddress(t *testing.T) {
|
|||||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||||
}
|
}
|
||||||
config, hostConfig, _ := mustParse(t, validMacAddress)
|
_, hostConfig, nwConfig := mustParse(t, validMacAddress)
|
||||||
t.Logf("MacAddress: %+v\n", hostConfig)
|
defaultNw := hostConfig.NetworkMode.NetworkName()
|
||||||
assert.Equal(t, "92:d0:c6:0a:29:33", config.MacAddress) //nolint:staticcheck
|
if nwConfig.EndpointsConfig[defaultNw].MacAddress.String() != "92:d0:c6:0a:29:33" {
|
||||||
|
t.Fatalf("Expected the default endpoint to have the MacAddress '92:d0:c6:0a:29:33' set, got '%v'", nwConfig.EndpointsConfig[defaultNw].MacAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||||
@@ -374,116 +447,239 @@ func TestParseHostnameDomainname(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithExpose(t *testing.T) {
|
func TestParseWithExpose(t *testing.T) {
|
||||||
invalids := map[string]string{
|
t.Run("invalid", func(t *testing.T) {
|
||||||
":": "invalid port format for --expose: :",
|
tests := map[string]string{
|
||||||
"8080:9090": "invalid port format for --expose: 8080:9090",
|
":": `invalid range format for --expose: invalid start port ':': invalid syntax`,
|
||||||
"/tcp": "invalid range format for --expose: /tcp, error: empty string specified for ports",
|
"8080:9090": `invalid range format for --expose: invalid start port '8080:9090': invalid syntax`,
|
||||||
"/udp": "invalid range format for --expose: /udp, error: empty string specified for ports",
|
"/tcp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"/udp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"NaN-NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
|
"8080-NaN/tcp": `invalid range format for --expose: invalid end port 'NaN': invalid syntax`,
|
||||||
}
|
"1234567890-8080/tcp": `invalid range format for --expose: invalid start port '1234567890': value out of range`,
|
||||||
valids := map[string][]nat.Port{
|
|
||||||
"8080/tcp": {"8080/tcp"},
|
|
||||||
"8080/udp": {"8080/udp"},
|
|
||||||
"8080/ncp": {"8080/ncp"},
|
|
||||||
"8080-8080/udp": {"8080/udp"},
|
|
||||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
|
||||||
}
|
|
||||||
for expose, expectedError := range invalids {
|
|
||||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
|
||||||
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
|
|
||||||
}
|
}
|
||||||
}
|
for expose, expectedError := range tests {
|
||||||
for expose, exposedPorts := range valids {
|
t.Run(expose, func(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
if err != nil {
|
assert.Error(t, err, expectedError)
|
||||||
t.Fatal(err)
|
})
|
||||||
}
|
}
|
||||||
if len(config.ExposedPorts) != len(exposedPorts) {
|
})
|
||||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
tests := map[string][]networktypes.Port{
|
||||||
|
"8080/tcp": {networktypes.MustParsePort("8080/tcp")},
|
||||||
|
"8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080/ncp": {networktypes.MustParsePort("8080/ncp")},
|
||||||
|
"8080-8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080-8082/tcp": {networktypes.MustParsePort("8080/tcp"), networktypes.MustParsePort("8081/tcp"), networktypes.MustParsePort("8082/tcp")},
|
||||||
}
|
}
|
||||||
for _, port := range exposedPorts {
|
for expose, exposedPorts := range tests {
|
||||||
if _, ok := config.ExposedPorts[port]; !ok {
|
t.Run(expose, func(t *testing.T) {
|
||||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
}
|
assert.NilError(t, err)
|
||||||
|
for _, port := range exposedPorts {
|
||||||
|
_, ok := config.ExposedPorts[port]
|
||||||
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
// Merge with actual published port
|
|
||||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
t.Run("merge with published", func(t *testing.T) {
|
||||||
if err != nil {
|
// Merge with actual published port
|
||||||
t.Fatal(err)
|
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||||
}
|
assert.NilError(t, err)
|
||||||
if len(config.ExposedPorts) != 2 {
|
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
||||||
t.Fatalf("Expected 2 exposed ports, got %v", config.ExposedPorts)
|
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||||
}
|
for _, port := range ports {
|
||||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
_, ok := config.ExposedPorts[port]
|
||||||
for _, port := range ports {
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
if _, ok := config.ExposedPorts[port]; !ok {
|
|
||||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDevice(t *testing.T) {
|
func TestParseDevice(t *testing.T) {
|
||||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||||
valids := map[string]container.DeviceMapping{
|
testCases := []struct {
|
||||||
"/dev/snd": {
|
devices []string
|
||||||
PathOnHost: "/dev/snd",
|
deviceMapping *container.DeviceMapping
|
||||||
PathInContainer: "/dev/snd",
|
deviceRequests []container.DeviceRequest
|
||||||
CgroupPermissions: "rwm",
|
}{
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:rw": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:rw"},
|
||||||
PathInContainer: "/dev/snd",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rw",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:/something": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:/something"},
|
||||||
PathInContainer: "/something",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rwm",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:/something:rw": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:/something:rw"},
|
||||||
PathInContainer: "/something",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rw",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name"},
|
||||||
|
deviceMapping: nil,
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name", "/dev/snd:/something:rw"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
for device, deviceMapping := range valids {
|
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--device=%v", device), "img", "cmd"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(hostconfig.Devices) != 1 {
|
|
||||||
t.Fatalf("Expected 1 devices, got %v", hostconfig.Devices)
|
|
||||||
}
|
|
||||||
if hostconfig.Devices[0] != deviceMapping {
|
|
||||||
t.Fatalf("Expected %v, got %v", deviceMapping, hostconfig.Devices)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(fmt.Sprintf("%s", tc.devices), func(t *testing.T) {
|
||||||
|
var args []string
|
||||||
|
for _, d := range tc.devices {
|
||||||
|
args = append(args, fmt.Sprintf("--device=%v", d))
|
||||||
|
}
|
||||||
|
args = append(args, "img", "cmd")
|
||||||
|
|
||||||
|
_, hostconfig, _, err := parseRun(args)
|
||||||
|
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
if tc.deviceMapping != nil {
|
||||||
|
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
||||||
|
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Check(t, is.Len(hostconfig.Devices, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDeviceByServerOS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
device string
|
||||||
|
serverOS string
|
||||||
|
want container.DeviceMapping
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "linux source only",
|
||||||
|
device: "/dev/snd",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source and mode",
|
||||||
|
device: "/dev/snd:rw",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source target and mode",
|
||||||
|
device: "/dev/snd:/container/snd:m",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/container/snd",
|
||||||
|
CgroupPermissions: "m",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows passes value through",
|
||||||
|
device: `class/GUID`,
|
||||||
|
serverOS: "windows",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: `class/GUID`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid server OS",
|
||||||
|
device: "/dev/snd",
|
||||||
|
serverOS: "plan9",
|
||||||
|
wantErr: "unknown server OS: plan9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many linux fields",
|
||||||
|
device: "/dev/snd:/container/snd:rw:extra",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseDevice(tc.device, tc.serverOS)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
assert.Error(t, err, tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseNetworkConfig(t *testing.T) {
|
func TestParseNetworkConfig(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
flags []string
|
flags []string
|
||||||
expected map[string]*networktypes.EndpointSettings
|
expected map[string]*networktypes.EndpointSettings
|
||||||
expectedCfg container.HostConfig
|
expectedHostCfg container.HostConfig
|
||||||
expectedErr string
|
expectedErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single-network-legacy",
|
name: "single-network-legacy",
|
||||||
flags: []string{"--network", "net1"},
|
flags: []string{"--network", "net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced",
|
name: "single-network-advanced",
|
||||||
flags: []string{"--network", "name=net1"},
|
flags: []string{"--network", "name=net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-legacy-with-options",
|
name: "single-network-legacy-with-options",
|
||||||
@@ -501,15 +697,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: "172.20.88.22",
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-network-advanced-mixed",
|
name: "multiple-network-advanced-mixed",
|
||||||
@@ -525,14 +721,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"--network-alias", "web2",
|
"--network-alias", "web2",
|
||||||
"--network", "net2",
|
"--network", "net2",
|
||||||
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
||||||
|
"--network", "name=net4,mac-address=02:32:1c:23:00:04,link-local-ip=169.254.169.254",
|
||||||
},
|
},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{"field1": "value1"},
|
DriverOpts: map[string]string{"field1": "value1"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: "172.20.88.22",
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
@@ -541,17 +738,23 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"net3": {
|
"net3": {
|
||||||
DriverOpts: map[string]string{"field3": "value3"},
|
DriverOpts: map[string]string{"field3": "value3"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: "172.20.88.22",
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web3"},
|
Aliases: []string{"web3"},
|
||||||
},
|
},
|
||||||
|
"net4": {
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.169.254")},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced-with-options",
|
name: "single-network-advanced-with-options",
|
||||||
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822"},
|
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822,mac-address=02:32:1c:23:00:04"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{
|
DriverOpts: map[string]string{
|
||||||
@@ -559,19 +762,31 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"field2": "value2",
|
"field2": "value2",
|
||||||
},
|
},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: "172.20.88.22",
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-networks",
|
name: "multiple-networks",
|
||||||
flags: []string{"--network", "net1", "--network", "name=net2"},
|
flags: []string{"--network", "net1", "--network", "name=net2"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "advanced-options-with-standalone-mac-address-flag",
|
||||||
|
flags: []string{"--network=name=net1,alias=foobar", "--mac-address", "52:0f:f3:dc:50:10"},
|
||||||
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
|
"net1": {
|
||||||
|
Aliases: []string{"foobar"},
|
||||||
|
MacAddress: mustParseMAC("52:0f:f3:dc:50:10"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "conflict-network",
|
name: "conflict-network",
|
||||||
@@ -593,13 +808,26 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
||||||
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
||||||
},
|
},
|
||||||
// case is skipped as it fails w/o any change
|
{
|
||||||
//
|
name: "invalid-mixed-network-types",
|
||||||
//{
|
flags: []string{"--network", "name=host", "--network", "net1"},
|
||||||
// name: "invalid-mixed-network-types",
|
expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
||||||
// flags: []string{"--network", "name=host", "--network", "net1"},
|
},
|
||||||
// expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
{
|
||||||
//},
|
name: "conflict-options-link-local-ip",
|
||||||
|
flags: []string{"--network", "name=net1,link-local-ip=169.254.169.254", "--link-local-ip", "169.254.10.8"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflict-options-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=02:32:1c:23:00:04", "--mac-address", "02:32:1c:23:00:04"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --mac-address and per-network MAC address`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=foobar"},
|
||||||
|
expectedErr: "foobar is not a valid mac address",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -607,13 +835,13 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
||||||
|
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
require.Error(t, err, tc.expectedErr)
|
assert.Error(t, err, tc.expectedErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
||||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
|
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +862,7 @@ func TestParseModes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uts ko
|
// uts ko
|
||||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled
|
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||||
|
|
||||||
// uts ok
|
// uts ok
|
||||||
@@ -662,43 +890,84 @@ func TestRunFlagsParseShmSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicy(t *testing.T) {
|
func TestParseRestartPolicy(t *testing.T) {
|
||||||
invalids := map[string]string{
|
tests := []struct {
|
||||||
"always:2:3": "invalid restart policy format: maximum retry count must be an integer",
|
input string
|
||||||
"on-failure:invalid": "invalid restart policy format: maximum retry count must be an integer",
|
expected container.RestartPolicy
|
||||||
}
|
expectedErr string
|
||||||
valids := map[string]container.RestartPolicy{
|
}{
|
||||||
"": {},
|
{
|
||||||
"always": {
|
input: "",
|
||||||
Name: "always",
|
|
||||||
MaximumRetryCount: 0,
|
|
||||||
},
|
},
|
||||||
"on-failure:1": {
|
{
|
||||||
Name: "on-failure",
|
input: "no",
|
||||||
MaximumRetryCount: 1,
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyDisabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ":1",
|
||||||
|
expectedErr: "invalid restart policy format: no policy provided before colon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always:2:3",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:1",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyOnFailure,
|
||||||
|
MaximumRetryCount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyUnlessStopped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unknown / invalid combinations: validation is handled by the daemon>
|
||||||
|
{
|
||||||
|
input: "anything:123",
|
||||||
|
expected: container.RestartPolicy{Name: "anything", MaximumRetryCount: 123},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "negative:-123",
|
||||||
|
expected: container.RestartPolicy{Name: "negative", MaximumRetryCount: -123},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for restart, expectedError := range invalids {
|
for _, tc := range tests {
|
||||||
if _, _, _, err := parseRun([]string{"--restart=" + restart, "img", "cmd"}); err == nil || err.Error() != expectedError {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
t.Fatalf("Expected an error with message '%v' for %v, got %v", expectedError, restart, err)
|
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||||
}
|
if tc.expectedErr != "" {
|
||||||
}
|
assert.Check(t, is.Error(err, tc.expectedErr))
|
||||||
for restart, expected := range valids {
|
assert.Check(t, is.Nil(hostConfig))
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--restart=%v", restart), "img", "cmd"})
|
} else {
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
||||||
}
|
}
|
||||||
if hostconfig.RestartPolicy != expected {
|
})
|
||||||
t.Fatalf("Expected %v, got %v", expected, hostconfig.RestartPolicy)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
expected := "conflicting options: --restart and --rm"
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled
|
const expected = "conflicting options: cannot specify both --restart and --rm"
|
||||||
if err == nil || err.Error() != expected {
|
assert.Check(t, is.Error(err, expected))
|
||||||
t.Fatalf("Expected error %v, but got none", expected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHealth(t *testing.T) {
|
func TestParseHealth(t *testing.T) {
|
||||||
@@ -734,8 +1003,8 @@ func TestParseHealth(t *testing.T) {
|
|||||||
checkError("--no-healthcheck conflicts with --health-* options",
|
checkError("--no-healthcheck conflicts with --health-* options",
|
||||||
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
||||||
|
|
||||||
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "img", "cmd")
|
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd")
|
||||||
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second {
|
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second {
|
||||||
t.Fatalf("--health-*: got %#v", health)
|
t.Fatalf("--health-*: got %#v", health)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -755,14 +1024,14 @@ func TestParseLoggingOpts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvfileVariables(t *testing.T) {
|
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--env-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--env-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// env ko
|
// env ko
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// env ok
|
// env ok
|
||||||
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
||||||
@@ -798,7 +1067,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UTF16 with BOM
|
// UTF16 with BOM
|
||||||
e := "invalid env file"
|
e := "invalid utf8 bytes at line"
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||||
}
|
}
|
||||||
@@ -808,14 +1077,14 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseLabelfileVariables(t *testing.T) {
|
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--label-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--label-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// label ko
|
// label ko
|
||||||
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// label ok
|
// label ok
|
||||||
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
||||||
@@ -836,12 +1105,8 @@ func TestParseLabelfileVariables(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseEntryPoint(t *testing.T) {
|
func TestParseEntryPoint(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
||||||
}
|
|
||||||
if len(config.Entrypoint) != 1 && config.Entrypoint[0] != "anything" {
|
|
||||||
t.Fatalf("Expected entrypoint 'anything', got %v", config.Entrypoint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDevice(t *testing.T) {
|
func TestValidateDevice(t *testing.T) {
|
||||||
@@ -888,14 +1153,90 @@ func TestValidateDevice(t *testing.T) {
|
|||||||
for path, expectedError := range invalid {
|
for path, expectedError := range invalid {
|
||||||
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
||||||
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
||||||
} else {
|
} else if err.Error() != expectedError {
|
||||||
if err.Error() != expectedError {
|
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
||||||
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeviceByServerOS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
serverOS string
|
||||||
|
want string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "linux preserves three-field container path",
|
||||||
|
value: "/host:/container/../device:rw",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: "/host:/container/../device:rw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source path can be relative when target is absolute",
|
||||||
|
value: "relative-host:/container/device",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: "relative-host:/container/device",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows defers validation",
|
||||||
|
value: `class/GUID`,
|
||||||
|
serverOS: "windows",
|
||||||
|
want: `class/GUID`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux rejects bad mode",
|
||||||
|
value: "/host:/container:ro",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantError: "bad mode specified: ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux target must be absolute",
|
||||||
|
value: "/host:relative",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantError: "relative is not an absolute path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown server OS",
|
||||||
|
value: "/dev/snd",
|
||||||
|
serverOS: "plan9",
|
||||||
|
wantError: "unknown server OS: plan9",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := validateDevice(tc.value, tc.serverOS)
|
||||||
|
if tc.wantError != "" {
|
||||||
|
assert.Error(t, err, tc.wantError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||||
|
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, "c 1:3 rwm")
|
||||||
|
|
||||||
|
_, err = validateDeviceCgroupRule("invalid")
|
||||||
|
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
||||||
|
|
||||||
|
if invalidParameter(nil) != nil {
|
||||||
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
|
}
|
||||||
|
cause := errors.New("bad input")
|
||||||
|
err = invalidParameter(cause)
|
||||||
|
var invalid interface{ InvalidParameter() }
|
||||||
|
assert.Assert(t, errors.As(err, &invalid))
|
||||||
|
assert.Assert(t, errors.Is(err, cause))
|
||||||
|
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
doc string
|
doc string
|
||||||
@@ -967,7 +1308,6 @@ func TestConvertToStandardNotation(t *testing.T) {
|
|||||||
|
|
||||||
for key, ports := range valid {
|
for key, ports := range valid {
|
||||||
convertedPorts, err := convertToStandardNotation(ports)
|
convertedPorts, err := convertToStandardNotation(ports)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pullPolicyAlways = "always"
|
||||||
|
pullPolicyMissing = "missing"
|
||||||
|
pullPolicyNever = "never"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
|
||||||
|
|
||||||
|
// createFlags are the flags docker/cli registers on the `create` and `run` commands
|
||||||
|
// instead of in addFlags, so they are not part of containerOptions.
|
||||||
|
type createFlags struct {
|
||||||
|
platform string
|
||||||
|
pull string
|
||||||
|
name string
|
||||||
|
useAPISocket bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
|
||||||
|
cf := new(createFlags)
|
||||||
|
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
|
||||||
|
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
|
||||||
|
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
|
||||||
|
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
|
||||||
|
// Accepted without effect: pull progress is only logged at debug level, and docker
|
||||||
|
// no longer implements content trust.
|
||||||
|
flags.BoolP("quiet", "q", false, "Suppress the pull output")
|
||||||
|
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContainerOptions parses a container options string. The flags are returned even
|
||||||
|
// on error, holding whatever was read before the failure.
|
||||||
|
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
|
||||||
|
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
copts := addFlags(flags)
|
||||||
|
cf := registerCreateFlags(flags)
|
||||||
|
|
||||||
|
args, err := shellquote.Split(options)
|
||||||
|
if err != nil {
|
||||||
|
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags, copts, cf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFlagsFromOptions reads the create-level flags that have to be known before the
|
||||||
|
// container is created. Malformed options keep the defaults here and are reported by
|
||||||
|
// mergeContainerConfigs at create time.
|
||||||
|
func createFlagsFromOptions(options string) *createFlags {
|
||||||
|
_, _, cf, _ := parseContainerOptions(options)
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cf *createFlags) validate() error {
|
||||||
|
if !slices.Contains(pullPolicies, cf.pull) {
|
||||||
|
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.useAPISocket {
|
||||||
|
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateFlagsFromOptions(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
options string
|
||||||
|
platform string
|
||||||
|
pull string
|
||||||
|
}{
|
||||||
|
{"", "", pullPolicyMissing},
|
||||||
|
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
|
||||||
|
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
|
||||||
|
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
|
||||||
|
} {
|
||||||
|
t.Run(tc.options, func(t *testing.T) {
|
||||||
|
cf := createFlagsFromOptions(tc.options)
|
||||||
|
assert.Equal(t, tc.platform, cf.platform)
|
||||||
|
assert.Equal(t, tc.pull, cf.pull)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFlagsValidate(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
options string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"--quiet --disable-content-trust --name mine", ""},
|
||||||
|
{"--pull sometimes", `invalid --pull option "sometimes"`},
|
||||||
|
{"--use-api-socket", "--use-api-socket is not supported"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.options, func(t *testing.T) {
|
||||||
|
err := createFlagsFromOptions(tc.options).validate()
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, err, tc.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||||
|
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||||
|
cr, ok := NewContainer(input).(*containerReference)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "linux/arm64", input.Platform)
|
||||||
|
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||||
|
|
||||||
|
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||||
|
NewContainer(kept)
|
||||||
|
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
@@ -7,12 +11,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName string, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -35,7 +39,7 @@ func ImageExistsLocally(ctx context.Context, imageName string, platform string)
|
|||||||
|
|
||||||
// RemoveImage removes image from local store, the function is used to run different
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
// container image architectures
|
// container image architectures
|
||||||
func RemoveImage(ctx context.Context, imageName string, force bool, pruneChildren bool) (bool, error) {
|
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -49,7 +53,7 @@ func RemoveImage(ctx context.Context, imageName string, force bool, pruneChildre
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, image.RemoveOptions{
|
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
||||||
Force: force,
|
Force: force,
|
||||||
PruneChildren: pruneChildren,
|
PruneChildren: pruneChildren,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||||
|
// network or emulation since there is nothing to run) and returns its tag, removing it after
|
||||||
|
// the test.
|
||||||
|
func buildScratchImage(t *testing.T, platform string) string {
|
||||||
|
t.Helper()
|
||||||
|
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
||||||
|
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
||||||
|
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
||||||
|
// Force BuildKit: it records the requested architecture in the image config for a
|
||||||
|
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
||||||
|
// host arch, which would break the per-platform existence assertions below.
|
||||||
|
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// a non-existent image is reported absent
|
||||||
|
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.False(t, missing)
|
||||||
|
|
||||||
|
// Build tiny images for two architectures locally so per-platform existence can be checked
|
||||||
|
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
||||||
|
amd64Ref := buildScratchImage(t, "linux/amd64")
|
||||||
|
arm64Ref := buildScratchImage(t, "linux/arm64")
|
||||||
|
|
||||||
|
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.True(t, amd64Exists)
|
||||||
|
|
||||||
|
// a non-host architecture image is detected for its own architecture
|
||||||
|
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.True(t, arm64Exists)
|
||||||
|
|
||||||
|
// a present image is reported absent for a different platform
|
||||||
|
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.False(t, wrongPlatform)
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
@@ -22,8 +26,6 @@ type dockerMessage struct {
|
|||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const logPrefix = " \U0001F433 "
|
|
||||||
|
|
||||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
||||||
if dockerResponse == nil {
|
if dockerResponse == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -55,7 +57,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
|
|
||||||
if msg.ErrorDetail.Message != "" {
|
if msg.ErrorDetail.Message != "" {
|
||||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||||
return errors.New(msg.Error)
|
return errors.New(msg.ErrorDetail.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Status != "" {
|
if msg.Status != "" {
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
networkCreateAttempts = 3
|
||||||
|
networkCreateRetryDelay = time.Second
|
||||||
|
|
||||||
|
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||||
|
// those of another runner sharing the daemon
|
||||||
|
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||||
|
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||||
|
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||||
|
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||||
|
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||||
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// the emptiness check, not the label, is what keeps a live job of another process
|
||||||
|
// sharing this registration safe
|
||||||
|
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
// Only create the network if it doesn't exist
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
if n.Name == name {
|
||||||
|
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range networkCreateAttempts {
|
||||||
|
if i > 0 {
|
||||||
|
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||||
|
Driver: "bridge",
|
||||||
|
Scope: "local",
|
||||||
|
EnableIPv4: opts.EnableIPv4,
|
||||||
|
EnableIPv6: opts.EnableIPv6,
|
||||||
|
Labels: runnerLabels(opts.RunnerUUID),
|
||||||
|
}); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isAddressPoolExhausted(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runnerLabels(runnerUUID string) map[string]string {
|
||||||
|
if runnerUUID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||||
|
// malformed request, so the message is the only discriminator.
|
||||||
|
func isAddressPoolExhausted(err error) bool {
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||||
|
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
// Make sure that all network of the specified name are removed
|
||||||
|
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
var errs []error
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
if n.Name == name {
|
||||||
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// it holds a subnet out of the daemon's pool until something reclaims it
|
||||||
|
if len(result.Network.Containers) != 0 {
|
||||||
|
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
|
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||||
|
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||||
|
// test on its own, since testify has no expectation to match it against.
|
||||||
|
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
|
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||||
|
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
|
{Network: network.Network{ID: "orphan"}},
|
||||||
|
{Network: network.Network{ID: "busy"}},
|
||||||
|
{Network: network.Network{ID: "starting"}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||||
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||||
|
|
||||||
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
|
||||||
|
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
|
||||||
|
// string is rejected explicitly so it cannot silently fall through to the daemon's
|
||||||
|
// default architecture.
|
||||||
|
func parsePlatform(platform string) (*specs.Platform, error) {
|
||||||
|
if platform == "" {
|
||||||
|
return nil, nil //nolint:nilnil // no platform constraint requested
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(platform, "/")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
|
||||||
|
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &specs.Platform{
|
||||||
|
OS: strings.ToLower(parts[0]),
|
||||||
|
Architecture: strings.ToLower(parts[1]),
|
||||||
|
}
|
||||||
|
if len(parts) == 3 {
|
||||||
|
spec.Variant = strings.ToLower(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePlatform(t *testing.T) {
|
||||||
|
t.Run("empty input returns nil platform without error", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("os/arch", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("linux/amd64")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "amd64", got.Architecture)
|
||||||
|
assert.Empty(t, got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("os/arch/variant", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("linux/arm/v7")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "arm", got.Architecture)
|
||||||
|
assert.Equal(t, "v7", got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("input is lowercased", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("Linux/AMD64/V8")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "amd64", got.Architecture)
|
||||||
|
assert.Equal(t, "v8", got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, bad := range []string{
|
||||||
|
"amd64",
|
||||||
|
"linux",
|
||||||
|
"linux/",
|
||||||
|
"/amd64",
|
||||||
|
"/",
|
||||||
|
"//",
|
||||||
|
"linux/arm/",
|
||||||
|
"linux/arm/v7/extra",
|
||||||
|
} {
|
||||||
|
t.Run("rejects "+bad, func(t *testing.T) {
|
||||||
|
got, err := parsePlatform(bad)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/moby/moby/api/pkg/authconfig"
|
||||||
|
"github.com/moby/moby/api/types/registry"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
logger.Debugf("docker pull %v", input.Image)
|
||||||
|
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip the pull when the image is already here: either none was forced, or a digest
|
||||||
|
// pins the content so a forced pull could only fetch the same bytes again
|
||||||
|
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||||
|
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||||
|
logger.Debugf("Image exists? %v", imageExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||||
|
}
|
||||||
|
if imageExists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageRef := cleanImage(ctx, input.Image)
|
||||||
|
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||||
|
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
imagePullOptions, err := getImagePullOptions(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// the daemon reports a failure that happens after the first progress line in the
|
||||||
|
// stream rather than on the call itself, so both have to be checked
|
||||||
|
pullOnce := func(opts client.ImagePullOptions) error {
|
||||||
|
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||||
|
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return streamErr
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pullOnce(imagePullOptions)
|
||||||
|
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||||
|
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||||
|
imagePullOptions.RegistryAuth = ""
|
||||||
|
err = pullOnce(imagePullOptions)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// a registry that is down should not fail a job whose image is already here
|
||||||
|
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||||
|
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||||
|
imagePullOptions := client.ImagePullOptions{}
|
||||||
|
platform, err := parsePlatform(input.Platform)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
if platform != nil {
|
||||||
|
imagePullOptions.Platforms = []specs.Platform{*platform}
|
||||||
|
}
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
if input.Username != "" && input.Password != "" {
|
||||||
|
logger.Debugf("using authentication for docker pull")
|
||||||
|
|
||||||
|
encodedAuth, err := authconfig.Encode(registry.AuthConfig{
|
||||||
|
Username: input.Username,
|
||||||
|
Password: input.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePullOptions.RegistryAuth = encodedAuth
|
||||||
|
} else {
|
||||||
|
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
if authConfig.Username == "" && authConfig.Password == "" {
|
||||||
|
return imagePullOptions, nil
|
||||||
|
}
|
||||||
|
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||||
|
|
||||||
|
imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imagePullOptions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPinnedImage(image string) bool {
|
||||||
|
ref, err := reference.ParseAnyReference(image)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, pinned := ref.(reference.Canonical)
|
||||||
|
return pinned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanImage(ctx context.Context, imageName string) string {
|
||||||
|
ref, err := reference.ParseAnyReference(imageName)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Error(err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.String()
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -38,18 +44,21 @@ func TestCleanImage(t *testing.T) {
|
|||||||
func TestGetImagePullOptions(t *testing.T) {
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
orig := config.Dir()
|
||||||
|
t.Cleanup(func() { config.SetDir(orig) })
|
||||||
|
|
||||||
config.SetDir("/non-existent/docker")
|
config.SetDir("/non-existent/docker")
|
||||||
|
|
||||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
require.NoError(t, err, "Failed to create ImagePullOptions")
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Empty(t, options.RegistryAuth, "RegistryAuth should be empty if no username or password is set")
|
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
Image: "",
|
Image: "",
|
||||||
Username: "username",
|
Username: "username",
|
||||||
Password: "password",
|
Password: "password",
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to create ImagePullOptions")
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||||
|
|
||||||
config.SetDir("testdata/docker-pull-options")
|
config.SetDir("testdata/docker-pull-options")
|
||||||
@@ -57,6 +66,24 @@ func TestGetImagePullOptions(t *testing.T) {
|
|||||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
Image: "nektos/act",
|
Image: "nektos/act",
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to create ImagePullOptions")
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A digest-pinned image is immutable, so its local copy is always current.
|
||||||
|
func TestIsPinnedImage(t *testing.T) {
|
||||||
|
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||||
|
assert.False(t, isPinnedImage("alpine:latest"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||||
|
// whichever of the two shapes the daemon used.
|
||||||
|
func TestLogDockerResponseError(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
streamErr := func(line string) error {
|
||||||
|
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||||
|
}
|
||||||
|
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||||
|
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||||
|
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,845 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocker(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
client, err := GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
|
ContextDir: "testdata",
|
||||||
|
ImageTag: "envmergetest",
|
||||||
|
})
|
||||||
|
|
||||||
|
err = dockerBuild(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "envmergetest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
env := map[string]string{
|
||||||
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
||||||
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
|
"ANOTHER_VAR": "",
|
||||||
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||||
|
}
|
||||||
|
|
||||||
|
envExecutor := cr.extractFromImageEnv(&env)
|
||||||
|
err = envExecutor(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
||||||
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
|
"ANOTHER_VAR": "",
|
||||||
|
"SOME_RANDOM_VAR": "",
|
||||||
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||||
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||||
|
}, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockDockerClient struct {
|
||||||
|
mobyclient.APIClient
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
||||||
|
args := m.Called(ctx, execID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
||||||
|
args := m.Called(ctx, id, options)
|
||||||
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type endlessReader struct {
|
||||||
|
io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockConn struct {
|
||||||
|
net.Conn
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Write(b []byte) (n int, err error) {
|
||||||
|
args := m.Called(b)
|
||||||
|
return args.Int(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Close() (err error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerExecAbort(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
|
Conn: conn,
|
||||||
|
Reader: bufio.NewReader(endlessReader{}),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := make(chan error)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := <-channel
|
||||||
|
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerExecFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
|
Conn: conn,
|
||||||
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
||||||
|
ExitCode: 1,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
|
var exitErr ExitCodeError
|
||||||
|
require.ErrorAs(t, err, &exitErr)
|
||||||
|
assert.Equal(t, ExitCodeError(1), exitErr)
|
||||||
|
assert.Equal(t, "Process completed with exit code 1.", err.Error())
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||||
|
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||||
|
// length) followed by the payload.
|
||||||
|
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||||
|
b := make([]byte, 8+len(payload))
|
||||||
|
b[0] = byte(stream)
|
||||||
|
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||||
|
copy(b[8:], payload)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||||
|
// attach() streaming goroutine has drained and flushed the container's output,
|
||||||
|
// so a final line without a trailing newline is not lost.
|
||||||
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
logWriter := common.NewLineWriter(func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||||
|
Return(mobyclient.ContainerAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
|
Conn: &mockConn{},
|
||||||
|
Reader: bufio.NewReader(framed),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
|
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{
|
||||||
|
Result: (<-chan container.WaitResponse)(statusCh),
|
||||||
|
Error: (<-chan error)(errCh),
|
||||||
|
})
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
Stdout: logWriter,
|
||||||
|
Stderr: logWriter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cr.attach()(ctx))
|
||||||
|
require.NoError(t, cr.wait()(ctx))
|
||||||
|
|
||||||
|
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||||
|
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||||
|
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||||
|
// the goroutine closes after the final append.
|
||||||
|
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
|
statusCh <- container.WaitResponse{StatusCode: 2}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{
|
||||||
|
Result: (<-chan container.WaitResponse)(statusCh),
|
||||||
|
Error: (<-chan error)(errCh),
|
||||||
|
})
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.wait()(ctx)
|
||||||
|
var exitErr ExitCodeError
|
||||||
|
require.ErrorAs(t, err, &exitErr)
|
||||||
|
assert.Equal(t, ExitCodeError(2), exitErr)
|
||||||
|
assert.Equal(t, "Process completed with exit code 2.", err.Error())
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
||||||
|
// when they are a symlink, everything else does not exist.
|
||||||
|
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
||||||
|
for containerPath, target := range existing {
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
||||||
|
}
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
||||||
|
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
||||||
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var mkdirNames []string
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
if opts.DestinationPath != "/run" || opts.Content == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(opts.Content)
|
||||||
|
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
||||||
|
mkdirNames = append(mkdirNames, hdr.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||||
|
assert.Equal(t, []string{"act"}, mkdirNames)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
||||||
|
merr := errors.New("Failure")
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mkdirErr error
|
||||||
|
copyErr error
|
||||||
|
}{
|
||||||
|
{"mkdir", merr, nil},
|
||||||
|
{"copy content", nil, merr},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||||
|
// be logged as one.
|
||||||
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||||
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantWait bool
|
||||||
|
wantFailure bool
|
||||||
|
}{
|
||||||
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||||
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||||
|
{name: "removed cleanly", err: nil},
|
||||||
|
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
logger, hook := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||||
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
|
if tc.wantWait {
|
||||||
|
removed := make(chan container.WaitResponse, 1)
|
||||||
|
removed <- container.WaitResponse{}
|
||||||
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||||
|
}
|
||||||
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
|
require.NoError(t, cr.remove()(ctx))
|
||||||
|
// a failure keeps the id, so a later Remove() can retry it
|
||||||
|
if tc.wantFailure {
|
||||||
|
assert.Equal(t, "abc", cr.id)
|
||||||
|
assert.Len(t, hook.AllEntries(), 1)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, cr.id)
|
||||||
|
assert.Empty(t, hook.AllEntries())
|
||||||
|
}
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A container whose id was never learned, because find() could not reach the daemon or
|
||||||
|
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||||
|
// was never started here, so it is not worth a kill of its own.
|
||||||
|
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
|
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||||
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
|
|
||||||
|
require.NoError(t, cr.remove()(ctx))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||||
|
// daemon with a torn-down container.
|
||||||
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||||
|
boom := errors.New("daemon unreachable")
|
||||||
|
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
|
||||||
|
}
|
||||||
|
listOpts := mobyclient.ContainerListOptions{All: true}
|
||||||
|
inspectOpts := mobyclient.ContainerInspectOptions{}
|
||||||
|
|
||||||
|
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
|
||||||
|
cr, client := newCR("stale")
|
||||||
|
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||||
|
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
|
||||||
|
require.NoError(t, cr.find()(ctx))
|
||||||
|
assert.Empty(t, cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
|
||||||
|
cr, client := newCR("stale")
|
||||||
|
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||||
|
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||||
|
{ID: "other", Names: []string{"/somebody-else"}},
|
||||||
|
{ID: "fresh", Names: []string{"/job-1"}},
|
||||||
|
}}, nil)
|
||||||
|
require.NoError(t, cr.find()(ctx))
|
||||||
|
assert.Equal(t, "fresh", cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("live id kept", func(t *testing.T) {
|
||||||
|
cr, client := newCR("live")
|
||||||
|
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
|
||||||
|
require.NoError(t, cr.find()(ctx))
|
||||||
|
assert.Equal(t, "live", cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("transient inspect error trusts cache", func(t *testing.T) {
|
||||||
|
cr, client := newCR("live")
|
||||||
|
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
|
||||||
|
require.NoError(t, cr.find()(ctx))
|
||||||
|
assert.Equal(t, "live", cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list error propagates", func(t *testing.T) {
|
||||||
|
cr, client := newCR("")
|
||||||
|
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
|
||||||
|
require.ErrorIs(t, cr.find()(ctx), boom)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every daemon entry point fails fast with a clear, container-named
|
||||||
|
// error when no live cr.id is known.
|
||||||
|
func TestRejectsMissingContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||||
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
|
check := func(op string, err error) {
|
||||||
|
t.Helper()
|
||||||
|
require.Error(t, err, op)
|
||||||
|
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||||
|
}
|
||||||
|
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
|
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||||
|
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||||
|
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||||
|
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||||
|
check("GetContainerArchive", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
||||||
|
// and the Copy completes against the fresh id.
|
||||||
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
||||||
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
||||||
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||||
|
{ID: "fresh", Names: []string{"/job-1"}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run/act"
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
|
||||||
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
|
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
|
assert.Equal(t, "fresh", cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
||||||
|
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
||||||
|
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
||||||
|
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
||||||
|
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rc := NewContainer(&NewContainerInput{
|
||||||
|
Image: "alpine:latest",
|
||||||
|
Entrypoint: []string{"sleep", "30"},
|
||||||
|
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
|
||||||
|
AutoRemove: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, rc.Pull(false)(ctx))
|
||||||
|
require.NoError(t, rc.Create(nil, nil)(ctx))
|
||||||
|
require.NoError(t, rc.Start(false)(ctx))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = rc.Remove()(ctx)
|
||||||
|
_ = rc.Close()(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
||||||
|
// exact step that fails on a broken daemon.
|
||||||
|
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
|
func TestCheckVolumes(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
validVolumes []string
|
||||||
|
binds []string
|
||||||
|
expectedBinds []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "match all volumes",
|
||||||
|
validVolumes: []string{"**"},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "no volumes can be matched",
|
||||||
|
validVolumes: []string{},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "only allowed volumes can be matched",
|
||||||
|
validVolumes: []string{
|
||||||
|
"shared_volume",
|
||||||
|
"/home/test/data",
|
||||||
|
"/etc/conf.d/*.json",
|
||||||
|
},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
ValidVolumes: tc.validVolumes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||||
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
|
||||||
|
dangerous := func() *container.HostConfig {
|
||||||
|
return &container.HostConfig{
|
||||||
|
PidMode: "host",
|
||||||
|
IpcMode: "host",
|
||||||
|
UTSMode: "host",
|
||||||
|
CgroupnsMode: "host",
|
||||||
|
UsernsMode: "host",
|
||||||
|
CapAdd: []string{"ALL"},
|
||||||
|
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||||
|
VolumesFrom: []string{"other"},
|
||||||
|
Runtime: "runc",
|
||||||
|
Resources: container.Resources{
|
||||||
|
CgroupParent: "/custom",
|
||||||
|
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||||
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||||
|
},
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostConfig := dangerous()
|
||||||
|
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||||
|
|
||||||
|
assert.Empty(t, string(hostConfig.PidMode))
|
||||||
|
assert.Empty(t, string(hostConfig.IpcMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UTSMode))
|
||||||
|
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||||
|
assert.Empty(t, hostConfig.CapAdd)
|
||||||
|
assert.Empty(t, hostConfig.SecurityOpt)
|
||||||
|
assert.Empty(t, hostConfig.Devices)
|
||||||
|
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||||
|
assert.Empty(t, hostConfig.VolumesFrom)
|
||||||
|
assert.Empty(t, hostConfig.Runtime)
|
||||||
|
assert.Empty(t, hostConfig.CgroupParent)
|
||||||
|
assert.Empty(t, hostConfig.Sysctls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||||
|
// OS-independent options only: --device parsing requires a linux/windows
|
||||||
|
// server OS, which is not guaranteed for the test host.
|
||||||
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||||
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||||
|
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||||
|
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||||
|
|
||||||
|
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Options: dangerousOptions,
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
UsernsMode: "private",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Privileged: false,
|
||||||
|
UsernsMode: container.UsernsMode("private"),
|
||||||
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, hostConfig.Privileged)
|
||||||
|
assert.Empty(t, string(hostConfig.PidMode))
|
||||||
|
assert.Empty(t, string(hostConfig.IpcMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UTSMode))
|
||||||
|
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||||
|
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||||
|
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||||
|
assert.Empty(t, hostConfig.CapAdd)
|
||||||
|
assert.Empty(t, hostConfig.SecurityOpt)
|
||||||
|
assert.Empty(t, hostConfig.VolumesFrom)
|
||||||
|
assert.Empty(t, hostConfig.Runtime)
|
||||||
|
assert.Empty(t, hostConfig.CgroupParent)
|
||||||
|
assert.Empty(t, hostConfig.Sysctls)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("privileged preserves options", func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Privileged: true,
|
||||||
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||||
|
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||||
|
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
allowed := filepath.Join(base, "allowed")
|
||||||
|
denied := filepath.Join(base, "denied")
|
||||||
|
require.NoError(t, os.MkdirAll(allowed, 0o700))
|
||||||
|
require.NoError(t, os.MkdirAll(denied, 0o700))
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
ValidVolumes: []string{filepath.Join(allowed, "**")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
|
||||||
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{escapingPath + ":/mnt"},
|
||||||
|
})
|
||||||
|
assert.Empty(t, hostConf.Binds)
|
||||||
|
|
||||||
|
linkPath := filepath.Join(allowed, "link")
|
||||||
|
if err := os.Symlink(denied, linkPath); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{linkPath + ":/mnt"},
|
||||||
|
})
|
||||||
|
assert.Empty(t, hostConf.Binds)
|
||||||
|
|
||||||
|
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
|
||||||
|
})
|
||||||
|
assert.Empty(t, hostConf.Binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||||
|
assert.Empty(t, hostConf.Mounts)
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -90,7 +94,7 @@ func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
|||||||
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
||||||
// Cases: 1B, 2B
|
// Cases: 1B, 2B
|
||||||
// Should we early-exit here, since there is no host nor socket to talk to?
|
// Should we early-exit here, since there is no host nor socket to talk to?
|
||||||
return SocketAndHost{}, fmt.Errorf("docker host aka DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to DOCKER_HOST if set
|
// Default to DOCKER_HOST if set
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,7 +10,6 @@ import (
|
|||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -15,9 +18,19 @@ func init() {
|
|||||||
|
|
||||||
var originalCommonSocketLocations = CommonSocketLocations
|
var originalCommonSocketLocations = CommonSocketLocations
|
||||||
|
|
||||||
|
func isolateSocketEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
||||||
|
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
||||||
|
t.Setenv("DOCKER_HOST", host)
|
||||||
|
} else {
|
||||||
|
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
@@ -26,7 +39,7 @@ func TestGetSocketAndHostWithSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost(socketURI)
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,22 +52,22 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost("")
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
|
||||||
defaultSocket, defaultSocketFound := socketLocation()
|
defaultSocket, defaultSocketFound := socketLocation()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
require.NoError(t, err, "Expected no error from GetSocketAndHost")
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
||||||
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
||||||
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
||||||
@@ -62,7 +75,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
@@ -70,13 +83,13 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost("-")
|
ret, err := GetSocketAndHost("-")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
defaultSocket, found := socketLocation()
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
@@ -85,7 +98,7 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.True(t, found, "Expected a default socket to be found")
|
assert.True(t, found, "Expected a default socket to be found")
|
||||||
require.NoError(t, err, "Expected no error from GetSocketAndHost")
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +107,12 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||||
mySocket := mySocketFile.Name()
|
mySocket := mySocketFile.Name()
|
||||||
unixSocket := "unix://" + mySocket
|
unixSocket := "unix://" + mySocket
|
||||||
defer os.RemoveAll(mySocket)
|
defer os.RemoveAll(mySocket)
|
||||||
require.NoError(t, tmpErr)
|
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
|
||||||
CommonSocketLocations = []string{mySocket}
|
CommonSocketLocations = []string{mySocket}
|
||||||
@@ -110,12 +124,13 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|||||||
// Assert
|
// Assert
|
||||||
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
||||||
assert.True(t, found, "Expected default socket to be found")
|
assert.True(t, found, "Expected default socket to be found")
|
||||||
require.NoError(t, err, "Expected no error from GetSocketAndHost")
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
mySocket := "/my/socket/path.sock"
|
mySocket := "/my/socket/path.sock"
|
||||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||||
@@ -126,13 +141,14 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
assert.False(t, found, "Expected no default socket to be found")
|
||||||
assert.Empty(t, defaultSocket, "Expected no default socket to be found")
|
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
||||||
require.Error(t, err, "Expected an error in invalid state")
|
assert.Error(t, err, "Expected an error in invalid state")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
socketURI := "unix:///path/to/my.socket"
|
socketURI := "unix:///path/to/my.socket"
|
||||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
@@ -143,9 +159,9 @@ func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
// Default socket locations
|
// Default socket locations
|
||||||
assert.Empty(t, defaultSocket, "Expect default socket location to be empty")
|
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
assert.False(t, found, "Expected no default socket to be found")
|
||||||
// Sane default
|
// Sane default
|
||||||
require.NoError(t, err, "Expect no error from GetSocketAndHost")
|
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)
|
//go:build WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"github.com/docker/docker/api/types/system"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/moby/moby/api/types/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName string, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes image from local store, the function is used to run different
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
// container image architectures
|
// container image architectures
|
||||||
func RemoveImage(ctx context.Context, imageName string, force bool, pruneChildren bool) (bool, error) {
|
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +62,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -67,3 +73,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
@@ -5,9 +9,9 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"github.com/docker/docker/api/types/filters"
|
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
@@ -18,12 +22,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vol := range list.Volumes {
|
for _, vol := range list.Items {
|
||||||
if vol.Name == volumeName {
|
if vol.Name == volumeName {
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
return removeExecutor(volumeName, force)(ctx)
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,7 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
|||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func removeExecutor(volume string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
|
logger.Debugf("docker volume rm %s", volume)
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -49,6 +53,7 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
return cli.VolumeRemove(ctx, volume, force)
|
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||||
|
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||||
|
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||||
|
func requireDocker(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostEnvironment struct {
|
||||||
|
Path string
|
||||||
|
TmpDir string
|
||||||
|
ToolCache string
|
||||||
|
Workdir string
|
||||||
|
// CleanWorkdir means teardown owns Workdir and may delete it. Leave false
|
||||||
|
// when Workdir points at a caller-owned checkout (e.g. `act` local mode).
|
||||||
|
CleanWorkdir bool
|
||||||
|
ActPath string
|
||||||
|
CleanUp func()
|
||||||
|
StdOut io.Writer
|
||||||
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
|
|
||||||
|
// procGroup owns every process the job's steps start. Atomic: Remove may read
|
||||||
|
// it while a step is still starting.
|
||||||
|
procGroupOnce sync.Once
|
||||||
|
procGroup atomic.Pointer[process.Group]
|
||||||
|
}
|
||||||
|
|
||||||
|
// processGroup returns the job-scoped process group, creating it on first use.
|
||||||
|
// Returns nil if the job object could not be created; Group is nil-safe.
|
||||||
|
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
||||||
|
e.procGroupOnce.Do(func() {
|
||||||
|
group, err := process.NewGroup()
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.procGroup.Store(group)
|
||||||
|
})
|
||||||
|
return e.procGroup.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Close() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
for _, f := range files {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destPath, f.Name)), 0o777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(destPath, f.Name), []byte(f.Body), fs.FileMode(f.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
|
if err := os.RemoveAll(destPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(tarStream)
|
||||||
|
cp := &filecollector.CopyCollector{
|
||||||
|
DstDir: destPath,
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
ti, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ti.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return errors.New("CopyTarStream has been cancelled")
|
||||||
|
}
|
||||||
|
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||||
|
var ignorer gitignore.Matcher
|
||||||
|
if useGitIgnore {
|
||||||
|
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Error loading .gitignore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
|
}
|
||||||
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
|
Ignorer: ignorer,
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: &filecollector.CopyCollector{
|
||||||
|
DstDir: destPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
tw := tar.NewWriter(buf)
|
||||||
|
defer tw.Close()
|
||||||
|
srcPath = filepath.Clean(srcPath)
|
||||||
|
fi, err := os.Lstat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tc := &filecollector.TarCollector{
|
||||||
|
TarWriter: tw,
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
srcPrefix := srcPath
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: tc,
|
||||||
|
}
|
||||||
|
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var f io.ReadCloser
|
||||||
|
var linkname string
|
||||||
|
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
linkname, err = os.Readlink(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err = os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.NopCloser(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Start(_ bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ptyWriter struct {
|
||||||
|
Out io.Writer
|
||||||
|
AutoStop atomic.Bool
|
||||||
|
dirtyLine bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||||
|
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||||
|
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' {
|
||||||
|
_, _ = w.Out.Write([]byte("\n"))
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
w.dirtyLine = strings.LastIndex(string(buf), "\n") < len(buf)-1
|
||||||
|
return w.Out.Write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
type localEnv struct {
|
||||||
|
env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localEnv) Getenv(name string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for k, v := range l.env {
|
||||||
|
if strings.EqualFold(name, k) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.env[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||||
|
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||||
|
if err != nil {
|
||||||
|
err := "Cannot find: " + cmd + " in PATH"
|
||||||
|
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||||
|
return "", fmt.Errorf("%v: %w", err, _err)
|
||||||
|
}
|
||||||
|
return "", errors.New(err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||||
|
ppty, tty, err := openPty()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if term.IsTerminal(int(tty.Fd())) {
|
||||||
|
_, err := term.MakeRaw(int(tty.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
ppty.Close()
|
||||||
|
tty.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.Stdin = tty
|
||||||
|
cmd.Stdout = tty
|
||||||
|
cmd.Stderr = tty
|
||||||
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||||
|
return ppty, tty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKeepAlive(ppty io.Writer) {
|
||||||
|
c := 1
|
||||||
|
var err error
|
||||||
|
for c == 1 && err == nil {
|
||||||
|
c, err = ppty.Write([]byte{4})
|
||||||
|
<-time.After(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) {
|
||||||
|
defer func() {
|
||||||
|
finishLog()
|
||||||
|
}()
|
||||||
|
if _, err := io.Copy(writer, ppty); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvListFromMap(env map[string]string) []string {
|
||||||
|
envList := make([]string, 0)
|
||||||
|
for k, v := range env {
|
||||||
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
|
}
|
||||||
|
return envList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, _, workdir string) error {
|
||||||
|
envList := getEnvListFromMap(env)
|
||||||
|
var wd string
|
||||||
|
if workdir != "" {
|
||||||
|
if filepath.IsAbs(workdir) {
|
||||||
|
wd = workdir
|
||||||
|
} else {
|
||||||
|
wd = filepath.Join(e.Path, workdir)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wd = e.Path
|
||||||
|
}
|
||||||
|
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||||
|
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||||
|
defer common.FlushWriter(e.StdOut)
|
||||||
|
|
||||||
|
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, f)
|
||||||
|
cmd.Path = f
|
||||||
|
cmd.Args = command
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Stdout = e.StdOut
|
||||||
|
cmd.Env = envList
|
||||||
|
cmd.Stderr = e.StdOut
|
||||||
|
cmd.Dir = wd
|
||||||
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||||
|
|
||||||
|
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
|
||||||
|
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
|
||||||
|
treeKill := process.NewTreeKill(cmd)
|
||||||
|
|
||||||
|
var ppty *os.File
|
||||||
|
var tty *os.File
|
||||||
|
defer func() {
|
||||||
|
if ppty != nil {
|
||||||
|
ppty.Close()
|
||||||
|
}
|
||||||
|
if tty != nil {
|
||||||
|
tty.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if e.AllocatePTY {
|
||||||
|
var err error
|
||||||
|
ppty, tty, err = setupPty(cmd, cmdline)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var writer *ptyWriter
|
||||||
|
var logctx context.Context
|
||||||
|
if ppty != nil {
|
||||||
|
writer = &ptyWriter{Out: e.StdOut}
|
||||||
|
var finishLog context.CancelFunc
|
||||||
|
logctx, finishLog = context.WithCancel(context.Background())
|
||||||
|
go copyPtyOutput(writer, ppty, finishLog)
|
||||||
|
go writeKeepAlive(ppty)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Assign before the step's Killer so the step's job nests inside the group's;
|
||||||
|
// cancellation still scopes to this step's tree.
|
||||||
|
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
|
||||||
|
}
|
||||||
|
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||||
|
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||||
|
} else {
|
||||||
|
defer k.Close()
|
||||||
|
}
|
||||||
|
err = cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tty != nil {
|
||||||
|
writer.AutoStop.Store(true)
|
||||||
|
if _, err := tty.WriteString("\x04"); err != nil {
|
||||||
|
common.Logger(ctx).Debug("Failed to write EOT")
|
||||||
|
}
|
||||||
|
<-logctx.Done()
|
||||||
|
ppty.Close()
|
||||||
|
ppty = nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor {
|
||||||
|
return e.ExecWithCmdLine(command, "", env, user, workdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("this step has been cancelled: %w", err)
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
return parseEnvFile(e, srcPath, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAll is a var so tests can substitute a blocking stub.
|
||||||
|
var removeAll = os.RemoveAll
|
||||||
|
|
||||||
|
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
|
||||||
|
// cancellation the goroutine leaks: a delete inside a syscall cannot be
|
||||||
|
// interrupted (see runWithTimeout).
|
||||||
|
func removeAllWithContext(ctx context.Context, path string) error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- removeAll(path) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePathWithRetry(ctx context.Context, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attempts := 1
|
||||||
|
delay := time.Duration(0)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
attempts = 5
|
||||||
|
delay = 200 * time.Millisecond
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastErr = removeAllWithContext(ctx, path)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
|
||||||
|
// every process tree whose ExecutablePath or CommandLine references one of the
|
||||||
|
// given workspace dirs, releasing file handles for cleanup. Win32_Process
|
||||||
|
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
|
||||||
|
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
|
||||||
|
// (job1 vs job10) is spared and path metacharacters stay literal.
|
||||||
|
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
||||||
|
quoted := make([]string, len(dirs))
|
||||||
|
for i, d := range dirs {
|
||||||
|
// Single-quoted PowerShell literal; escape ' by doubling it.
|
||||||
|
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
return `$paths = @(` + strings.Join(quoted, ",") + `)
|
||||||
|
$selfPid = $PID
|
||||||
|
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
if ($_.ProcessId -eq $selfPid) { return $false }
|
||||||
|
foreach ($p in $paths) {
|
||||||
|
$prefix = $p + '\'
|
||||||
|
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||||
|
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
} | ForEach-Object {
|
||||||
|
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
||||||
|
// server cancel has already cancelled the parent ctx.
|
||||||
|
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer killCancel()
|
||||||
|
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
||||||
|
// across jobs, and Workdir may be a caller-owned checkout.
|
||||||
|
owned := []string{e.Path, e.TmpDir}
|
||||||
|
if e.CleanWorkdir {
|
||||||
|
owned = append(owned, e.Workdir)
|
||||||
|
}
|
||||||
|
dirs := make([]string, 0, len(owned))
|
||||||
|
for _, d := range owned {
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(d)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirs = append(dirs, abs)
|
||||||
|
}
|
||||||
|
if len(dirs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script := buildWindowsWorkspaceKillScript(dirs)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win32_Process exposes no working directory, so the scan above misses a
|
||||||
|
// process that merely runs in a workspace dir while pinning a handle on it.
|
||||||
|
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
|
||||||
|
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
|
||||||
|
} else if killed > 0 {
|
||||||
|
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||||
|
// wedge the runner slot. A var so tests can shrink it.
|
||||||
|
var hostCleanupTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
|
||||||
|
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
|
||||||
|
// mount) cannot be interrupted, and leaking scratch state beats losing the
|
||||||
|
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
|
||||||
|
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return nil
|
||||||
|
case <-timer.C:
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Remove() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// End lingering processes before removing the workspace; on Windows their
|
||||||
|
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
||||||
|
if err := e.procGroup.Load().Close(); err != nil {
|
||||||
|
logger.Debugf("closing the job's process group failed: %v", err)
|
||||||
|
}
|
||||||
|
e.terminateRunningProcesses(ctx)
|
||||||
|
|
||||||
|
// Removes per-job misc state only, never the toolcache root. Bounded because
|
||||||
|
// CleanUp is a caller-supplied, typically unbounded os.RemoveAll.
|
||||||
|
if e.CleanUp != nil {
|
||||||
|
logger.Debugf("running host environment cleanup callback")
|
||||||
|
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||||
|
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("host environment cleanup callback finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||||
|
// which absorb Windows file-handle release lag after the kill above.
|
||||||
|
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
|
||||||
|
defer rmCancel()
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
||||||
|
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
if e.CleanWorkdir {
|
||||||
|
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
||||||
|
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, err := range errs {
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ToContainerPath(path string) string {
|
||||||
|
if bp, err := filepath.Rel(e.Workdir, path); err != nil {
|
||||||
|
return filepath.Join(e.Path, bp)
|
||||||
|
} else if filepath.Clean(e.Workdir) == filepath.Clean(path) {
|
||||||
|
return e.Path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetActPath() string {
|
||||||
|
actPath := e.ActPath
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
actPath = strings.ReplaceAll(actPath, "\\", "/")
|
||||||
|
}
|
||||||
|
return actPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) GetPathVariableName() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "plan9":
|
||||||
|
return "path"
|
||||||
|
case "windows":
|
||||||
|
return "Path" // Actually we need a case insensitive map
|
||||||
|
}
|
||||||
|
return "PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) DefaultPathVariable() string {
|
||||||
|
v, _ := os.LookupEnv(e.GetPathVariableName())
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
||||||
|
return strings.Join(paths, string(filepath.ListSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference for Arch values for runner.arch
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||||
|
func goArchToActionArch(arch string) string {
|
||||||
|
archMapper := map[string]string{
|
||||||
|
"x86_64": "X64",
|
||||||
|
"386": "X86",
|
||||||
|
"aarch64": "ARM64",
|
||||||
|
}
|
||||||
|
if arch, ok := archMapper[arch]; ok {
|
||||||
|
return arch
|
||||||
|
}
|
||||||
|
return arch
|
||||||
|
}
|
||||||
|
|
||||||
|
func goOsToActionOs(os string) string {
|
||||||
|
osMapper := map[string]string{
|
||||||
|
"darwin": "macOS",
|
||||||
|
}
|
||||||
|
if os, ok := osMapper[os]; ok {
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"os": goOsToActionOs(runtime.GOOS),
|
||||||
|
"arch": goArchToActionArch(runtime.GOARCH),
|
||||||
|
"temp": e.TmpDir,
|
||||||
|
"tool_cache": e.ToolCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
|
||||||
|
org := e.StdOut
|
||||||
|
e.StdOut = stdout
|
||||||
|
return org, org
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||||
|
return runtime.GOOS == "windows"
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestCopyDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
Workdir: path.Join("testdata", "scratch"),
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(e.Path, 0o700)
|
||||||
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
|
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
Workdir: path.Join("testdata", "scratch"),
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(e.Path, 0o700)
|
||||||
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
|
expectedContent := []byte("sdde/7sh")
|
||||||
|
err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
archive, err := e.GetContainerArchive(ctx, e.Path)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer archive.Close()
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
h, err := reader.Next()
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "action.yml", h.Name)
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, expectedContent, content)
|
||||||
|
_, err = reader.Next()
|
||||||
|
assert.ErrorIs(t, err, io.EOF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: io.Discard,
|
||||||
|
Workdir: filepath.Join(dir, "path"),
|
||||||
|
}
|
||||||
|
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
||||||
|
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx)
|
||||||
|
var exitErr ExitCodeError
|
||||||
|
require.ErrorAs(t, err, &exitErr)
|
||||||
|
assert.Equal(t, ExitCodeError(3), exitErr)
|
||||||
|
assert.Equal(t, "Process completed with exit code 3.", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
allocPTY bool
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{name: "off", allocPTY: false, expect: "NOTTY"},
|
||||||
|
{name: "on", allocPTY: true, expect: "TTY"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: buf,
|
||||||
|
Workdir: filepath.Join(dir, "path"),
|
||||||
|
AllocatePTY: tc.allocPTY,
|
||||||
|
}
|
||||||
|
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
||||||
|
require.NoError(t, os.MkdirAll(p, 0o700))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.Exec(
|
||||||
|
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
||||||
|
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
||||||
|
)(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
||||||
|
assert.Equal(t, tc.expect, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
miscRoot := filepath.Join(base, "misc")
|
||||||
|
path := filepath.Join(miscRoot, "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
workdir := filepath.Join(base, "workspace", "owner", "repo")
|
||||||
|
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
Workdir: workdir,
|
||||||
|
CleanUp: func() {
|
||||||
|
_ = os.RemoveAll(miscRoot)
|
||||||
|
},
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
require.NoError(t, e.Remove()(ctx))
|
||||||
|
_, err := os.Stat(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
miscRoot := filepath.Join(base, "misc")
|
||||||
|
path := filepath.Join(miscRoot, "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
|
||||||
|
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
Workdir: workdir,
|
||||||
|
CleanWorkdir: true,
|
||||||
|
CleanUp: func() {
|
||||||
|
_ = os.RemoveAll(miscRoot)
|
||||||
|
},
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
require.NoError(t, e.Remove()(ctx))
|
||||||
|
_, err := os.Stat(workdir)
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
stubDone := make(chan struct{})
|
||||||
|
|
||||||
|
orig := removeAll
|
||||||
|
removeAll = func(string) error {
|
||||||
|
defer close(stubDone)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||||
|
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||||
|
// to return before restoring the var, so the restore can't race the read.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
<-stubDone
|
||||||
|
removeAll = orig
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := removeAllWithContext(ctx, t.TempDir())
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||||
|
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||||
|
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||||
|
// container". Remove must time out the callback and complete job teardown.
|
||||||
|
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||||
|
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||||
|
orig := hostCleanupTimeout
|
||||||
|
hostCleanupTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
CleanUp: func() {
|
||||||
|
<-release // simulate a delete syscall stuck indefinitely
|
||||||
|
},
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- e.Remove()(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Remove() hung on a stuck CleanUp callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
|
||||||
|
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
|
||||||
|
// #1023) wedging job completion after the CleanUp callback has already timed
|
||||||
|
// out or finished.
|
||||||
|
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||||
|
origTimeout := hostCleanupTimeout
|
||||||
|
hostCleanupTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
stubDone := make(chan struct{})
|
||||||
|
|
||||||
|
origRemoveAll := removeAll
|
||||||
|
removeAll = func(string) error {
|
||||||
|
defer close(stubDone)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The stuck delete goroutine outlives the timed-out Remove and still reads
|
||||||
|
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
<-stubDone
|
||||||
|
removeAll = origRemoveAll
|
||||||
|
})
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- e.Remove()(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Remove() hung on a stuck path removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||||
|
t.Run("single dir", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
||||||
|
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
|
||||||
|
// Self-PID guard is essential — without it the script could taskkill
|
||||||
|
// the PowerShell process running it.
|
||||||
|
assert.Contains(t, s, "$selfPid = $PID")
|
||||||
|
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
|
||||||
|
// Must match both ExecutablePath (binaries from the workspace) and
|
||||||
|
// CommandLine (system binaries invoked with workspace paths in args),
|
||||||
|
// both bounded by dir+separator so a name-prefix sibling is spared.
|
||||||
|
assert.Contains(t, s, `$prefix = $p + '\'`)
|
||||||
|
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
|
||||||
|
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
|
||||||
|
// Each matched PID must be tree-killed, not just stopped.
|
||||||
|
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{
|
||||||
|
`C:\work\path`,
|
||||||
|
`C:\work\workdir`,
|
||||||
|
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
||||||
|
})
|
||||||
|
assert.Contains(t, s, `'C:\work\path'`)
|
||||||
|
assert.Contains(t, s, `'C:\work\workdir'`)
|
||||||
|
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
|
||||||
|
// Commas between entries — no trailing comma, no leading comma.
|
||||||
|
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path with single quote is escaped", func(t *testing.T) {
|
||||||
|
// In PowerShell single-quoted strings the only special char is the
|
||||||
|
// quote itself, escaped by doubling. A workspace path that ever
|
||||||
|
// contained `'` would inject a command into the script otherwise.
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
|
||||||
|
assert.Contains(t, s, `'C:\work\it''s\path'`)
|
||||||
|
// And it must NOT appear unescaped — otherwise the quote would
|
||||||
|
// terminate the literal early.
|
||||||
|
assert.NotContains(t, s, `'C:\work\it's\path'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
|
||||||
|
// A path containing [ ] ? * must be embedded verbatim and matched with
|
||||||
|
// ordinal String methods, not -like, otherwise the metacharacters would
|
||||||
|
// be interpreted as wildcards and the leftover process could escape.
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
|
||||||
|
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
||||||
|
assert.NotContains(t, s, "-like")
|
||||||
|
assert.Contains(t, s, "StartsWith")
|
||||||
|
assert.Contains(t, s, "IndexOf")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript(nil)
|
||||||
|
// Empty array literal — script runs, matches nothing, is a no-op.
|
||||||
|
assert.Contains(t, s, "$paths = @()")
|
||||||
|
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
||||||
|
})
|
||||||
|
}
|
||||||
+9
-3
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,8 +14,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LinuxContainerEnvironmentExtensions struct {
|
type LinuxContainerEnvironmentExtensions struct{}
|
||||||
}
|
|
||||||
|
|
||||||
// Resolves the equivalent host path inside the container
|
// Resolves the equivalent host path inside the container
|
||||||
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
|
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
|
||||||
@@ -63,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
|||||||
return strings.Join(paths, ":")
|
return strings.Join(paths, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||||
|
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||||
|
|
||||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"os": "Linux",
|
"os": "Linux",
|
||||||
"arch": RunnerArch(ctx),
|
"arch": RunnerArch(ctx),
|
||||||
"temp": "/tmp",
|
"temp": "/tmp",
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
"tool_cache": DefaultToolCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+4
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +12,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/pkg/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||||
@@ -24,17 +31,18 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s := bufio.NewScanner(reader)
|
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
|
||||||
s.Buffer(nil, 1024*1024*1024) // increase buffer to 1GB to avoid scanner buffer overflow
|
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
|
||||||
firstLine := true
|
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||||
|
|
||||||
|
s := bufio.NewScanner(decoded)
|
||||||
|
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
||||||
|
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
if firstLine {
|
// GitHub's runner ignores blank lines
|
||||||
firstLine = false
|
if strings.TrimSpace(line) == "" {
|
||||||
// skip utf8 bom, powershell 5 legacy uses it for utf8
|
continue
|
||||||
if len(line) >= 3 && line[0] == 239 && line[1] == 187 && line[2] == 191 {
|
|
||||||
line = line[3:]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
singleLineEnv := strings.Index(line, "=")
|
singleLineEnv := strings.Index(line, "=")
|
||||||
multiLineEnv := strings.Index(line, "<<")
|
multiLineEnv := strings.Index(line, "<<")
|
||||||
@@ -55,6 +63,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
}
|
}
|
||||||
multiLineEnvContent += content
|
multiLineEnvContent += content
|
||||||
}
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return fmt.Errorf("reading env file: %w", err)
|
||||||
|
}
|
||||||
if !delimiterFound {
|
if !delimiterFound {
|
||||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,10 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return fmt.Errorf("reading env file: %w", err)
|
||||||
|
}
|
||||||
env = &localEnv
|
env = &localEnv
|
||||||
return s.Err()
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/encoding"
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
||||||
|
t.Helper()
|
||||||
|
e := &HostEnvironment{Path: t.TempDir()}
|
||||||
|
return e, filepath.Join(e.Path, "envfile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileSingleLine(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
assert.Equal(t, "qux", env["BAZ"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileMultiLine(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
content := "FOO<<EOF\nline1\nline2\nEOF\n"
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "line1\nline2", env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
big := strings.Repeat("x", 2*1024*1024)
|
||||||
|
content := "FOO<<EOF\n" + big + "\nEOF\n"
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, big, env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
|
||||||
|
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||||
|
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||||
|
assert.Contains(t, err.Error(), "reading env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression test: a blank line used to fail the job at "Complete Job", after
|
||||||
|
// every step had already been recorded as successful.
|
||||||
|
func TestParseEnvFileBlankLines(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
assert.Equal(t, "qux", env["BAZ"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// blank lines inside a heredoc value are content, not separators
|
||||||
|
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
|
||||||
|
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
|
||||||
|
func TestParseEnvFileUTF16(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
encoder *encoding.Encoder
|
||||||
|
}{
|
||||||
|
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
|
||||||
|
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
assert.Equal(t, "line1", env["MULTI"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "delimiter")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user