mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e42621b4a | ||
|
|
228effab64 | ||
|
|
a46d3f3eda | ||
|
|
2e72cf902a | ||
|
|
f4741df90d | ||
|
|
261ab374ce | ||
|
|
ca8e9c3c64 | ||
|
|
d8d4a4657d | ||
|
|
5fd2f82735 | ||
|
|
c6567296a0 | ||
|
|
e3607b576e |
No files matched your search
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
env:
|
env:
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
# daemon retains them between runs, and their digest pins skip the registry when it has them.
|
# daemon retains them between runs, and their digest pins skip the registry when it has them.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:26-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
|
|||||||
+8
-5
@@ -57,12 +57,15 @@ Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions`
|
|||||||
|
|
||||||
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
||||||
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
||||||
parallel.
|
parallel. Outside a container it runs `go test` in a Linux container on its own network, as CI does,
|
||||||
|
so both take the same paths. `E2E_ARGS` passes extra flags, e.g. `make test-e2e E2E_ARGS='-run TestCompatibility/dispatch'`.
|
||||||
|
|
||||||
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
|
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
|
||||||
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped, tolerated and timed-out jobs.
|
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped and tolerated jobs, `job_timeout`
|
||||||
`label_routing` and `capacity` cover scheduling, `reconnect` drops the connection to Gitea through a proxy, and
|
a job killed at the runner's deadline. `label_routing` and `capacity` cover scheduling, `reconnect` drops the
|
||||||
`host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
|
connection to Gitea through a proxy, and `host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
|
||||||
|
`dispatch`, `failure_paths` and `reusable_workflow` run in every mode: through the daemon and `exec`, each on
|
||||||
|
Docker and Kubernetes.
|
||||||
|
|
||||||
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
|
The suite shares one Gitea, regular runner and k3s cluster. Cache and ephemeral scenarios use isolated
|
||||||
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
||||||
+2
-2
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.1-dind AS dind
|
FROM docker:29.9.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.1-dind-rootless AS dind-rootless
|
FROM docker:29.9.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ GO ?= go
|
|||||||
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" )
|
||||||
|
|
||||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:8856b200466e9a5af9cc0d4de4ca96f73547833bcdddf9a567189ff772c4d372 # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:657378e5cfd898e36f820459c60ef8e69be8fcda9fd354e4381a8b654d091bbc # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
||||||
@@ -154,7 +154,7 @@ E2E_CONCURRENCY ?= 8
|
|||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh $(E2E_ARGS)
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ Use `uses: builtin:checkout` to check out a repository without downloading `acti
|
|||||||
|
|
||||||
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
|
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
|
||||||
|
|
||||||
|
As on GitHub, a job restores caches saved for its own ref, its pull request's base branch and the default branch, so a cache saved by a pull request is only restored by that pull request.
|
||||||
|
|
||||||
**Sharing a cache between runners**
|
**Sharing a cache between runners**
|
||||||
|
|
||||||
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||||
|
|||||||
@@ -6,7 +6,5 @@
|
|||||||
//
|
//
|
||||||
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||||
//
|
//
|
||||||
// TODO: Authorization
|
|
||||||
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
|
||||||
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||||
package artifactcache
|
package artifactcache
|
||||||
@@ -59,6 +59,10 @@ type credKey struct{}
|
|||||||
type JobCredential struct {
|
type JobCredential struct {
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
BaseRef string `json:"base_ref"`
|
||||||
|
DefaultRef string `json:"default_ref"`
|
||||||
|
|
||||||
// Results is the instance whose artifact service this server forwards for the job, and
|
// Results is the instance whose artifact service this server forwards for the job, and
|
||||||
// InsecureTLS how the runner reaches it; see results.go.
|
// InsecureTLS how the runner reaches it; see results.go.
|
||||||
Results string `json:"-"`
|
Results string `json:"-"`
|
||||||
@@ -68,6 +72,10 @@ type JobCredential struct {
|
|||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cred JobCredential) owns(cache *Cache) bool {
|
||||||
|
return cache.Repo == cred.Repo && cache.Ref == cred.Ref
|
||||||
|
}
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
// credEntry holds a registered job's credential along with an active
|
||||||
// registration count. RegisterJob is reference-counted so that if two tasks
|
// 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
|
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
||||||
@@ -653,7 +661,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
cache, err := h.lookupCache(db, cred, keys, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
@@ -669,26 +677,27 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
// lookupCache returns the entry to restore or (nil, nil), dropping matches whose blob was pruned.
|
||||||
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
func (h *Handler) lookupCache(db *cacheDB, cred JobCredential, keys []string, version string) (*Cache, error) {
|
||||||
// entry is dropped on the way out.
|
for {
|
||||||
func (h *Handler) lookupCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
|
cache, err := findCache(db, cred, keys, version)
|
||||||
cache, err := findCache(db, repo, keys, version)
|
if err != nil || cache == nil {
|
||||||
if err != nil || cache == nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
|
ok, err := h.storage.Exist(cache.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// Handing out a download URL counts as access, or eviction could drop the entry between
|
||||||
|
// this call and the GET that follows it.
|
||||||
|
h.touch(db, cache)
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
if !h.deleteCache(db, cache) {
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ok, err := h.storage.Exist(cache.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
h.deleteCache(db, cache)
|
|
||||||
return nil, nil //nolint:nilnil // absence is not an error here
|
|
||||||
}
|
|
||||||
// Handing out a download URL counts as access, or eviction could drop the entry between
|
|
||||||
// this call and the GET that follows it.
|
|
||||||
h.touch(db, cache)
|
|
||||||
return cache, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// POST /_apis/artifactcache/caches
|
||||||
@@ -700,7 +709,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &Cache{Repo: cred.Repo, Key: api.Key, Version: api.Version, Size: api.Size}
|
cache := &Cache{Repo: cred.Repo, Ref: cred.Ref, Key: api.Key, Version: api.Version, Size: api.Size}
|
||||||
if cache.Size == 0 {
|
if cache.Size == 0 {
|
||||||
cache.Size = -1
|
cache.Size = -1
|
||||||
}
|
}
|
||||||
@@ -749,7 +758,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
if !cred.owns(cache) {
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -808,7 +817,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
if !cred.owns(cache) {
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1071,25 +1080,35 @@ func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time
|
|||||||
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// findCache tries every key in one ref before the next ref, and returns (nil, nil) on a miss.
|
||||||
func findCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
|
func findCache(db *cacheDB, cred JobCredential, keys []string, version string) (*Cache, error) {
|
||||||
for _, prefix := range keys {
|
refs := []string{cred.Ref}
|
||||||
// if a key in the list matches exactly, don't return partial matches
|
for _, ref := range []string{cred.BaseRef, cred.DefaultRef} {
|
||||||
exact, err := findExactCache(db, repo, prefix, version, true)
|
if ref != "" && !slices.Contains(refs, ref) {
|
||||||
if err != nil {
|
refs = append(refs, ref)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if exact != nil {
|
}
|
||||||
return exact, nil
|
caches, err := db.find(func(cache *Cache) bool {
|
||||||
}
|
return cache.Repo == cred.Repo && cache.Version == version && cache.Complete && slices.Contains(refs, cache.Ref) &&
|
||||||
caches, err := db.find(func(cache *Cache) bool {
|
slices.ContainsFunc(keys, func(prefix string) bool { return strings.HasPrefix(cache.Key, prefix) })
|
||||||
return cache.Repo == repo && strings.HasPrefix(cache.Key, prefix) && cache.Version == version && cache.Complete
|
})
|
||||||
})
|
if err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
return nil, fmt.Errorf("find cache: %w", err)
|
}
|
||||||
}
|
for _, ref := range refs {
|
||||||
if cache := latest(caches, (*Cache).createdAtNano); cache != nil {
|
for _, prefix := range keys {
|
||||||
return cache, nil
|
var exact, partial []*Cache
|
||||||
|
for _, cache := range caches {
|
||||||
|
if cache.Ref == ref && cache.Key == prefix {
|
||||||
|
exact = append(exact, cache)
|
||||||
|
} else if cache.Ref == ref && strings.HasPrefix(cache.Key, prefix) {
|
||||||
|
partial = append(partial, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if a key in the list matches exactly, don't return partial matches
|
||||||
|
if cache := cmp.Or(latest(exact, (*Cache).createdAtNano), latest(partial, (*Cache).createdAtNano)); cache != nil {
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
@@ -1111,9 +1130,9 @@ func latest(caches []*Cache, stamp func(*Cache) int64) *Cache {
|
|||||||
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
// 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
|
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||||
// touches UsedAt on every part.
|
// touches UsedAt on every part.
|
||||||
func findExactCache(db *cacheDB, repo, key, version string, complete bool) (*Cache, error) {
|
func findExactCache(db *cacheDB, cred JobCredential, key, version string, complete bool) (*Cache, error) {
|
||||||
caches, err := db.find(func(cache *Cache) bool {
|
caches, err := db.find(func(cache *Cache) bool {
|
||||||
return cache.Key == key && cache.Repo == repo && cache.Version == version && cache.Complete == complete
|
return cred.owns(cache) && cache.Key == key && cache.Version == version && cache.Complete == complete
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find cache: %w", err)
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
@@ -1280,13 +1299,12 @@ func (h *Handler) evictExpired(db *cacheDB) {
|
|||||||
h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < expired })
|
h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < expired })
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictSuperseded removes entries a newer one with the same key and version replaced. The
|
// evictSuperseded removes entries a newer one with the same repo, ref, key and version replaced.
|
||||||
// grouping includes Repo so two repos sharing a (key, version) do not evict each other.
|
|
||||||
func (h *Handler) evictSuperseded(db *cacheDB) {
|
func (h *Handler) evictSuperseded(db *cacheDB) {
|
||||||
type entry struct{ repo, key, version string }
|
type entry struct{ repo, ref, key, version string }
|
||||||
groups := make(map[entry][]*Cache)
|
groups := make(map[entry][]*Cache)
|
||||||
for _, cache := range h.caches(db, func(cache *Cache) bool { return cache.Complete }) {
|
for _, cache := range h.caches(db, func(cache *Cache) bool { return cache.Complete }) {
|
||||||
group := entry{cache.Repo, cache.Key, cache.Version}
|
group := entry{cache.Repo, cache.Ref, cache.Key, cache.Version}
|
||||||
groups[group] = append(groups[group], cache)
|
groups[group] = append(groups[group], cache)
|
||||||
}
|
}
|
||||||
for _, caches := range groups {
|
for _, caches := range groups {
|
||||||
|
|||||||
@@ -759,11 +759,55 @@ func TestHandler_PrefixUsesNewestReservationWithinSecond(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
cache, err := findCache(db, testRepo, []string{"deps-"}, "v")
|
cache, err := findCache(db, JobCredential{Repo: testRepo}, []string{"deps-"}, "v")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, newer, cache)
|
require.Equal(t, newer, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_RestoreRefs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const main, release, pull = "refs/heads/main", "refs/heads/release", "refs/pull/1/head"
|
||||||
|
now := time.Now().Unix()
|
||||||
|
entry := func(ref, key string, age int64) *Cache {
|
||||||
|
return &Cache{Repo: testRepo, Ref: ref, Key: key, Version: "v", Complete: true, CreatedAt: now - 3600 + age, UsedAt: now}
|
||||||
|
}
|
||||||
|
exactOnMain := entry(main, "deps-exact", -1)
|
||||||
|
onRelease := entry(release, "deps-release", 0)
|
||||||
|
onMain := entry(main, "deps-main", 1)
|
||||||
|
onPull := entry(pull, "deps-pull", 2)
|
||||||
|
unscoped := entry("", "deps-unscoped", 3)
|
||||||
|
handler := newTestHandler(t, Policy{}, exactOnMain, onRelease, onMain, onPull, unscoped, entry("refs/pull/2/head", "deps-lost", 4))
|
||||||
|
for _, cache := range []*Cache{exactOnMain, onRelease, onMain, onPull, unscoped} {
|
||||||
|
writeBlob(t, handler, cache.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
keys []string
|
||||||
|
want *Cache
|
||||||
|
}{
|
||||||
|
{"the default branch never restores what a pull request wrote", JobCredential{Ref: main, DefaultRef: main}, []string{"deps-"}, onMain},
|
||||||
|
{"a pull request restores its own entry first", JobCredential{Ref: pull, BaseRef: release, DefaultRef: main}, []string{"deps-"}, onPull},
|
||||||
|
{"a pull request falls back past a lost blob to its base branch before the default branch", JobCredential{Ref: "refs/pull/2/head", BaseRef: release, DefaultRef: main}, []string{"deps-"}, onRelease},
|
||||||
|
{"a branch does not restore a sibling branch", JobCredential{Ref: "refs/heads/other", DefaultRef: main}, []string{"deps-"}, onMain},
|
||||||
|
{"a prefix in the own ref wins over an exact key in the default branch", JobCredential{Ref: release, DefaultRef: main}, []string{"deps-exact", "deps-"}, onRelease},
|
||||||
|
{"a job with a ref never restores entries saved without one", JobCredential{Ref: "refs/heads/other"}, []string{"deps-"}, nil},
|
||||||
|
{"a job without a ref only restores entries saved without one", JobCredential{}, []string{"deps-"}, unscoped},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.cred.Repo = testRepo
|
||||||
|
cache, err := handler.lookupCache(db, tc.cred, tc.keys, "v")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, cache)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler_evictOnCommit(t *testing.T) {
|
func TestHandler_evictOnCommit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
||||||
@@ -938,6 +982,7 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
handler.RegisterJob("token-a-pull", JobCredential{Repo: "owner/repoA", Ref: "refs/pull/1/head"})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "shared-key"
|
key := "shared-key"
|
||||||
@@ -972,13 +1017,20 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison")))
|
for _, client := range []*http.Client{clientB, {Transport: &bearerTransport{token: "token-a-pull"}}} {
|
||||||
require.NoError(t, err)
|
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison")))
|
||||||
req.Header.Set("Content-Range", "bytes 0-5/*")
|
require.NoError(t, err)
|
||||||
resp, err = clientB.Do(req)
|
req.Header.Set("Content-Range", "bytes 0-5/*")
|
||||||
require.NoError(t, err)
|
resp, err = client.Do(req)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
resp, err = client.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
func TestHandler_ArtifactSignature(t *testing.T) {
|
||||||
@@ -1083,49 +1135,20 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
assert.Equal(t, content, body)
|
assert.Equal(t, content, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
func TestHandler_GC_DedupPerRepoAndRef(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
|
||||||
handler.RegisterJob("tok-b", JobCredential{Repo: "owner/repoB"})
|
|
||||||
|
|
||||||
key := "shared-dedup-key"
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
entries := []*Cache{
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
{Repo: "owner/repoA", Key: "shared", Version: "v", Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1},
|
||||||
require.NoError(t, handler.insertCache(db, a))
|
{Repo: "owner/repoB", Key: "shared", Version: "v", Complete: true, CreatedAt: now, UsedAt: now, Size: 1},
|
||||||
require.NoError(t, handler.insertCache(db, b))
|
{Repo: "owner/repoA", Ref: "refs/pull/1/head", Key: "shared", Version: "v", Complete: true, CreatedAt: now, UsedAt: now, Size: 1},
|
||||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
}
|
||||||
_, err = handler.storage.Commit(a.ID, 1)
|
handler := newTestHandler(t, Policy{}, entries...)
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, handler.storage.Write(b.ID, 0, strings.NewReader("b")))
|
|
||||||
_, err = handler.storage.Commit(b.ID, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
handler.gcAt = time.Time{}
|
handler.gcAt = time.Time{}
|
||||||
handler.gcCache()
|
handler.gcCache()
|
||||||
|
|
||||||
db, err = handler.openDB()
|
assert.Len(t, keptKeys(t, handler, entries), len(entries))
|
||||||
require.NoError(t, err)
|
|
||||||
defer db.Close()
|
|
||||||
after, err := db.find(func(cache *Cache) bool { return cache.Key == key && cache.Version == version })
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
repos := make(map[string]bool)
|
|
||||||
for _, c := range after {
|
|
||||||
repos[c.Repo] = true
|
|
||||||
}
|
|
||||||
assert.True(t, repos["owner/repoA"], "repoA's cache must survive dedup against repoB")
|
|
||||||
assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
||||||
@@ -1190,10 +1213,10 @@ func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","ref":"refs/pull/1/head","base_ref":"refs/heads/release","default_ref":"refs/heads/main","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
|
||||||
cred, ok := handler.lookupCredential("via-internal-api")
|
cred, ok := handler.lookupCredential("via-internal-api")
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, JobCredential{Repo: testRepo, PublicURL: "http://runner.example"}, cred)
|
assert.Equal(t, JobCredential{Repo: testRepo, Ref: "refs/pull/1/head", BaseRef: "refs/heads/release", DefaultRef: "refs/heads/main", PublicURL: "http://runner.example"}, cred)
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
// 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
|
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||||
// existing and silently never saved.
|
// existing and silently never saved.
|
||||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
if existing, err := findExactCache(db, cred, req.Key, req.Version, true); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
@@ -84,7 +84,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
owner := hashedToken(bearerToken(r))
|
owner := hashedToken(bearerToken(r))
|
||||||
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
if pending, err := findExactCache(db, cred, req.Key, req.Version, false); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
@@ -103,6 +103,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
|
Ref: cred.Ref,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
@@ -137,13 +138,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred, req.Key, req.Version, false)
|
||||||
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||||
cache = nil // not the reservation this job made, so not this job's to commit
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
}
|
}
|
||||||
if err == nil && cache == nil {
|
if err == nil && cache == nil {
|
||||||
// A retry whose first response was lost finds the entry already committed.
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
cache, err = findExactCache(db, cred, req.Key, req.Version, true)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
@@ -186,7 +187,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
cache, err := h.lookupCache(db, cred, req.keys(), req.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -231,6 +231,24 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("each ref saves and restores its own entry for the same key", func(t *testing.T) {
|
||||||
|
clients := map[string]*http.Client{"x": testClient}
|
||||||
|
for _, ref := range []string{"y", "z"} {
|
||||||
|
handler.RegisterJob(ref, JobCredential{Repo: testRepo, Ref: "refs/pull/" + ref + "/head"})
|
||||||
|
clients[ref] = &http.Client{Transport: &bearerTransport{token: ref}}
|
||||||
|
created := v2Call(t, handler, clients[ref], "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
require.Equal(t, true, created["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte(ref)))
|
||||||
|
}
|
||||||
|
for _, ref := range []string{"y", "z"} {
|
||||||
|
require.Equal(t, true, v2Call(t, handler, clients[ref], "FinalizeCacheEntryUpload", map[string]any{"key": "deps-abc", "version": "v1", "size_bytes": 1})["ok"])
|
||||||
|
}
|
||||||
|
for content, client := range clients {
|
||||||
|
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, content, string(getURL(t, got["signed_download_url"].(string))))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Request struct {
|
|||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
Ref string `json:"ref,omitzero"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ const (
|
|||||||
jobLabel = "com.gitea.runner.job."
|
jobLabel = "com.gitea.runner.job."
|
||||||
maxCreateBody = 8 << 20
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
dockerProxyProbeTimeout = 5 * time.Second
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
|
dockerProxyProbeWorkerTimeout = time.Minute
|
||||||
|
dockerProxyProbeTarget = "/gitea-runner-probe"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -48,7 +50,7 @@ var (
|
|||||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var dockerProxyDirectories, dockerProxyProbes sync.Map
|
var dockerProxyProbes sync.Map
|
||||||
|
|
||||||
type dockerProxyDirectory struct {
|
type dockerProxyDirectory struct {
|
||||||
dir, daemonDir string
|
dir, daemonDir string
|
||||||
@@ -68,7 +70,7 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
}
|
}
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cli, err := GetDockerClient(probeCtx)
|
cli, err := client.New(client.FromEnv) // negotiates on first request, so a cached probe costs no round trip
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -83,65 +85,69 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
}
|
}
|
||||||
workdir, _ := os.Getwd()
|
workdir, _ := os.Getwd()
|
||||||
key := [3]string{cli.DaemonHost(), workdir, os.TempDir()}
|
key := [3]string{cli.DaemonHost(), workdir, os.TempDir()}
|
||||||
cached, _ := dockerProxyDirectories.Load(key)
|
probe, err := probeDockerProxyDirectory(probeCtx, cli, key, socket)
|
||||||
directory, seen := cached.(dockerProxyDirectory)
|
|
||||||
seen = seen && os.SameFile(directory.socket, socket) // daemon replacement invalidates cached paths
|
|
||||||
if !seen {
|
|
||||||
directory, seen, err = probeDockerProxyDirectory(probeCtx, cli, key, socket)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !seen {
|
if !probe.seen {
|
||||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
proxy, err := StartDockerProxy(daemonSocket, directory.dir, job)
|
proxy, err := StartDockerProxy(daemonSocket, probe.directory.dir, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dockerProxyDirectories.Delete(key)
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dockerProxyDirectories.Store(key, directory)
|
proxy.Socket = probe.directory.daemonDir + strings.TrimPrefix(proxy.Socket, probe.directory.dir)
|
||||||
proxy.Socket = directory.daemonDir + strings.TrimPrefix(proxy.Socket, directory.dir)
|
|
||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeDockerProxyDirectory(ctx context.Context, cli client.APIClient, key [3]string, socket os.FileInfo) (dockerProxyDirectory, bool, error) {
|
func probeDockerProxyDirectory(ctx context.Context, cli client.APIClient, key [3]string, socket os.FileInfo) (*dockerProxyProbe, error) {
|
||||||
for {
|
for {
|
||||||
probe := &dockerProxyProbe{directory: dockerProxyDirectory{socket: socket}, done: make(chan struct{})}
|
probe := &dockerProxyProbe{directory: dockerProxyDirectory{socket: socket}, done: make(chan struct{})}
|
||||||
actual, loaded := dockerProxyProbes.LoadOrStore(key, probe)
|
actual, loaded := dockerProxyProbes.LoadOrStore(key, probe)
|
||||||
if loaded {
|
if loaded {
|
||||||
probe, _ = actual.(*dockerProxyProbe)
|
probe, _ = actual.(*dockerProxyProbe)
|
||||||
if !os.SameFile(probe.directory.socket, socket) {
|
if !os.SameFile(probe.directory.socket, socket) { // daemon replacement invalidates cached paths
|
||||||
dockerProxyProbes.CompareAndDelete(key, probe)
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return dockerProxyDirectory{}, false, ctx.Err()
|
|
||||||
case <-probe.done:
|
|
||||||
}
|
|
||||||
if ctx.Err() == nil && (errors.Is(probe.err, context.Canceled) || errors.Is(probe.err, context.DeadlineExceeded)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
probe.directory.dir, probe.directory.daemonDir = runnerContainerWorkdir(ctx, cli)
|
go func() { // outlives callers' deadlines so a slow daemon still gets its result cached
|
||||||
probe.seen = probe.directory.daemonDir != ""
|
defer cli.Close()
|
||||||
if !probe.seen {
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeWorkerTimeout)
|
||||||
if probe.directory.dir, probe.err = filepath.Abs(key[2]); probe.err == nil {
|
defer cancel()
|
||||||
probe.directory.daemonDir = probe.directory.dir
|
probe.directory.dir, probe.directory.daemonDir = runnerContainerWorkdir(ctx, cli)
|
||||||
probe.seen, probe.err = daemonSeesDir(ctx, cli, probe.directory.dir, probe.directory.daemonDir)
|
probe.seen = probe.directory.daemonDir != ""
|
||||||
|
if !probe.seen {
|
||||||
|
if probe.directory.dir, probe.err = filepath.Abs(key[2]); probe.err == nil {
|
||||||
|
probe.directory.daemonDir = probe.directory.dir
|
||||||
|
probe.seen, probe.err = daemonSeesDir(ctx, cli, probe.directory.dir, probe.directory.daemonDir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if probe.seen {
|
||||||
dockerProxyProbes.CompareAndDelete(key, probe)
|
dockerProxyProbes.LoadOrStore(key, probe)
|
||||||
close(probe.done)
|
} else {
|
||||||
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
|
}
|
||||||
|
close(probe.done)
|
||||||
|
removeStaleProbeContainers(cli)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
return probe.directory, probe.seen, probe.err
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return &dockerProxyProbe{}, ctx.Err()
|
||||||
|
case <-probe.done:
|
||||||
|
}
|
||||||
|
if ctx.Err() == nil && errors.Is(probe.err, context.DeadlineExceeded) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return probe, probe.err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +206,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
created, err = cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
created, err = cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||||
Config: &container.Config{Image: image.ID, Cmd: []string{"true"}},
|
Config: &container.Config{Image: image.ID, Cmd: []string{"true"}},
|
||||||
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
||||||
{Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
|
{Type: mount.TypeBind, Source: daemonDir, Target: dockerProxyProbeTarget, ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
if !cerrdefs.IsNotFound(err) {
|
if !cerrdefs.IsNotFound(err) {
|
||||||
@@ -213,7 +219,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
|
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, dockerProxyProbeTarget)
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
||||||
@@ -222,6 +228,36 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
return seen, err
|
return seen, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeStaleProbeContainers removes probe containers left by abandoned creates once any probe in flight when listed has ended.
|
||||||
|
func removeStaleProbeContainers(cli client.APIClient) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
listed, err := cli.ContainerList(ctx, client.ContainerListOptions{
|
||||||
|
All: true,
|
||||||
|
Filters: make(client.Filters).Add("status", string(container.StateCreated)).Add("volume", dockerProxyProbeTarget),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("listing stale docker proxy probe containers failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listed.Items = slices.DeleteFunc(listed.Items, func(stale container.Summary) bool {
|
||||||
|
return stale.State != container.StateCreated || !slices.ContainsFunc(stale.Mounts, func(mountPoint container.MountPoint) bool {
|
||||||
|
return mountPoint.Type == mount.TypeBind && !mountPoint.RW && mountPoint.Destination == dockerProxyProbeTarget
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if len(listed.Items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(dockerProxyProbeWorkerTimeout + dockerProxyProbeTimeout)
|
||||||
|
for _, stale := range listed.Items {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeTimeout)
|
||||||
|
if err := (&containerReference{cli: cli, id: stale.ID}).remove()(ctx); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("removing stale docker proxy probe container failed: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
info, err := os.Stat(daemonSocket)
|
info, err := os.Stat(daemonSocket)
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`[{"Id":"probe-image"}]`))
|
_, _ = w.Write([]byte(`[{"Id":"probe-image"}]`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/containers/json"):
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
case strings.HasSuffix(r.URL.Path, "/json"):
|
case strings.HasSuffix(r.URL.Path, "/json"):
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
case strings.HasSuffix(r.URL.Path, "/archive"):
|
case strings.HasSuffix(r.URL.Path, "/archive"):
|
||||||
@@ -235,7 +237,8 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
t.Skip("automatic proxy discovery requires Linux")
|
t.Skip("automatic proxy discovery requires Linux")
|
||||||
}
|
}
|
||||||
t.Setenv("DOCKER_HOST", "unix://"+daemonSocket)
|
t.Setenv("DOCKER_HOST", "unix://"+daemonSocket)
|
||||||
t.Setenv("TMPDIR", shortTempDir(t))
|
tempDir := shortTempDir(t)
|
||||||
|
t.Setenv("TMPDIR", tempDir)
|
||||||
require.Nil(t, NewDockerProxy(t.Context(), "failed"))
|
require.Nil(t, NewDockerProxy(t.Context(), "failed"))
|
||||||
for _, job := range []string{"first", "second"} {
|
for _, job := range []string{"first", "second"} {
|
||||||
proxy := NewDockerProxy(t.Context(), job)
|
proxy := NewDockerProxy(t.Context(), job)
|
||||||
@@ -243,12 +246,10 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, proxy.Close(context.Background()))
|
require.NoError(t, proxy.Close(context.Background()))
|
||||||
}
|
}
|
||||||
assert.EqualValues(t, 2, probes.Load())
|
assert.EqualValues(t, 2, probes.Load())
|
||||||
dockerProxyDirectories.Range(func(key, value any) bool {
|
workdir, err := os.Getwd()
|
||||||
directory, _ := value.(dockerProxyDirectory)
|
require.NoError(t, err)
|
||||||
directory.socket, _ = os.Stat(t.TempDir())
|
cached, _ := dockerProxyProbes.Load([3]string{"unix://" + daemonSocket, workdir, tempDir})
|
||||||
dockerProxyDirectories.Store(key, directory)
|
cached.(*dockerProxyProbe).directory.socket, _ = os.Stat(t.TempDir())
|
||||||
return true
|
|
||||||
})
|
|
||||||
proxy := NewDockerProxy(t.Context(), "replaced daemon")
|
proxy := NewDockerProxy(t.Context(), "replaced daemon")
|
||||||
require.NotNil(t, proxy)
|
require.NotNil(t, proxy)
|
||||||
require.NoError(t, proxy.Close(context.Background()))
|
require.NoError(t, proxy.Close(context.Background()))
|
||||||
@@ -476,10 +477,23 @@ func BenchmarkDockerProxy(b *testing.B) {
|
|||||||
|
|
||||||
type probeClient struct {
|
type probeClient struct {
|
||||||
mobyclient.APIClient
|
mobyclient.APIClient
|
||||||
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
create func(context.Context, mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
stat func(string) error
|
stat func(string) error
|
||||||
mounts []container.MountPoint
|
mounts []container.MountPoint
|
||||||
|
containers []container.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerList(context.Context, mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||||
|
return mobyclient.ContainerListResult{Items: c.containers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerKill(context.Context, string, mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||||
|
return mobyclient.ContainerKillResult{}, cerrdefs.ErrConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
@@ -490,8 +504,8 @@ func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (m
|
|||||||
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "removed-image"}, {ID: "probe-image"}}}, nil
|
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "removed-image"}, {ID: "probe-image"}}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
func (c *probeClient) ContainerCreate(ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
return c.create(opts)
|
return c.create(ctx, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
@@ -518,7 +532,7 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
var calls, removals atomic.Int32
|
var calls, removals atomic.Int32
|
||||||
visible := false
|
visible := false
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(_ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
<-release
|
<-release
|
||||||
return mobyclient.ContainerCreateResult{ID: "probe"}, createErr
|
return mobyclient.ContainerCreateResult{ID: "probe"}, createErr
|
||||||
@@ -538,23 +552,18 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
results := make(chan result, 8)
|
results := make(chan result, 8)
|
||||||
for range cap(results) {
|
for range cap(results) {
|
||||||
go func() {
|
go func() {
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
results <- result{seen, err}
|
results <- result{probe.seen, err}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
assert.EqualValues(t, 1, calls.Load())
|
assert.EqualValues(t, 1, calls.Load())
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
|
||||||
assert.False(t, seen)
|
|
||||||
replacement, err := os.Stat(t.TempDir())
|
replacement, err := os.Stat(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
replaced := make(chan result, 1)
|
replaced := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, replacement)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, replacement)
|
||||||
replaced <- result{seen, err}
|
replaced <- result{probe.seen, err}
|
||||||
}()
|
}()
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
assert.EqualValues(t, 2, calls.Load())
|
assert.EqualValues(t, 2, calls.Load())
|
||||||
@@ -574,11 +583,11 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
assert.Zero(t, removals.Load())
|
assert.Zero(t, removals.Load())
|
||||||
}
|
}
|
||||||
createErr, visible = nil, true
|
createErr, visible = nil, true
|
||||||
directory, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, seen)
|
assert.True(t, probe.seen)
|
||||||
assert.Equal(t, dir, directory.dir)
|
assert.Equal(t, dir, probe.directory.dir)
|
||||||
assert.Equal(t, dir, directory.daemonDir)
|
assert.Equal(t, dir, probe.directory.daemonDir)
|
||||||
assert.EqualValues(t, 3, calls.Load())
|
assert.EqualValues(t, 3, calls.Load())
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -586,7 +595,7 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.Run("cancelled probe does not cancel another caller", func(t *testing.T) {
|
t.Run("cancelled caller leaves the probe to finish for the others", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
socket, err := os.Stat(t.TempDir())
|
socket, err := os.Stat(t.TempDir())
|
||||||
@@ -595,49 +604,105 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
calls := 0
|
var calls, removals atomic.Int32
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
calls++
|
calls.Add(1)
|
||||||
if calls == 1 {
|
<-release
|
||||||
<-release
|
return mobyclient.ContainerCreateResult{ID: "probe"}, ctx.Err()
|
||||||
|
},
|
||||||
|
stat: func(_path string) error { return nil },
|
||||||
|
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removals.Add(1)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
leader, follower, late := make(chan result, 1), make(chan result, 1), make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
||||||
|
leader <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
|
follower <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
cancel()
|
||||||
|
leaderResult := <-leader
|
||||||
|
require.ErrorIs(t, leaderResult.err, context.Canceled)
|
||||||
|
assert.False(t, leaderResult.seen)
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
|
late <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
close(release)
|
||||||
|
for _, results := range []chan result{follower, late} {
|
||||||
|
result := <-results
|
||||||
|
require.NoError(t, result.err)
|
||||||
|
assert.True(t, result.seen)
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, 1, calls.Load())
|
||||||
|
assert.EqualValues(t, 1, removals.Load())
|
||||||
|
_, cached := dockerProxyProbes.Load(key)
|
||||||
|
assert.True(t, cached)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Run("caller joining a probe that ran out of time retries", func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
socket, err := os.Stat(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
var calls atomic.Int32
|
||||||
|
cli := &probeClient{
|
||||||
|
create: func(ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
<-ctx.Done()
|
||||||
return mobyclient.ContainerCreateResult{}, ctx.Err()
|
return mobyclient.ContainerCreateResult{}, ctx.Err()
|
||||||
} else {
|
|
||||||
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
|
||||||
}
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||||
},
|
},
|
||||||
stat: func(_path string) error { return nil },
|
stat: func(_path string) error { return nil },
|
||||||
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
return mobyclient.ContainerRemoveResult{}, nil
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
leader, follower := make(chan result, 1), make(chan result, 1)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, [3]string{"daemon", dir, dir}, socket)
|
||||||
go func() {
|
require.NoError(t, err)
|
||||||
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
assert.True(t, probe.seen)
|
||||||
leader <- result{seen, err}
|
assert.EqualValues(t, 2, calls.Load())
|
||||||
}()
|
|
||||||
synctest.Wait()
|
|
||||||
go func() {
|
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
|
||||||
follower <- result{seen, err}
|
|
||||||
}()
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
cancel()
|
|
||||||
close(release)
|
|
||||||
leaderResult, followerResult := <-leader, <-follower
|
|
||||||
require.ErrorIs(t, leaderResult.err, context.Canceled)
|
|
||||||
assert.False(t, leaderResult.seen)
|
|
||||||
require.NoError(t, followerResult.err)
|
|
||||||
assert.True(t, followerResult.seen)
|
|
||||||
assert.Equal(t, 2, calls)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoveStaleProbeContainers(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
probeMount := container.MountPoint{Type: mount.TypeBind, Destination: "/gitea-runner-probe"}
|
||||||
|
writableMount := container.MountPoint{Type: mount.TypeBind, Destination: "/gitea-runner-probe", RW: true}
|
||||||
|
var removed []string
|
||||||
|
cli := &probeClient{
|
||||||
|
containers: []container.Summary{
|
||||||
|
{ID: "abandoned", State: container.StateCreated, Mounts: []container.MountPoint{probeMount}},
|
||||||
|
{ID: "foreign", State: container.StateCreated, Mounts: []container.MountPoint{writableMount}},
|
||||||
|
{ID: "started", State: container.StateExited, Mounts: []container.MountPoint{probeMount}},
|
||||||
|
},
|
||||||
|
remove: func(_ctx context.Context, id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removed = append(removed, id)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
removeStaleProbeContainers(cli)
|
||||||
|
assert.Equal(t, []string{"abandoned"}, removed)
|
||||||
|
assert.GreaterOrEqual(t, time.Since(start), dockerProxyProbeWorkerTimeout+dockerProxyProbeTimeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestDaemonSeesDir(t *testing.T) {
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
markers := make(map[string]bool)
|
markers := make(map[string]bool)
|
||||||
|
|
||||||
for _, testCase := range []struct {
|
for _, testCase := range []struct {
|
||||||
name string
|
name string
|
||||||
daemonDir string
|
daemonDir string
|
||||||
@@ -656,7 +721,7 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
removed := false
|
removed := false
|
||||||
var attemptedImages []string
|
var attemptedImages []string
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(_ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
||||||
attemptedImages = append(attemptedImages, opts.Config.Image)
|
attemptedImages = append(attemptedImages, opts.Config.Image)
|
||||||
if opts.Config.Image == "removed-image" {
|
if opts.Config.Image == "removed-image" {
|
||||||
@@ -692,6 +757,7 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"removed-image", "probe-image"}, attemptedImages)
|
assert.Equal(t, []string{"removed-image", "probe-image"}, attemptedImages)
|
||||||
assert.Equal(t, testCase.seen, seen)
|
assert.Equal(t, testCase.seen, seen)
|
||||||
assert.Equal(t, testCase.removed, removed)
|
assert.Equal(t, testCase.removed, removed)
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, entries)
|
assert.Empty(t, entries)
|
||||||
|
|||||||
@@ -980,7 +980,7 @@ func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
|||||||
RunnerOptions: "--entrypoint=runner-entrypoint",
|
RunnerOptions: "--entrypoint=runner-entrypoint",
|
||||||
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
||||||
},
|
},
|
||||||
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
cli: &probeClient{create: func(_ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
if len(entrypoint) > 0 {
|
if len(entrypoint) > 0 {
|
||||||
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -24,6 +26,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
@@ -37,6 +40,7 @@ type KubernetesOptions struct {
|
|||||||
MaxLifetime time.Duration
|
MaxLifetime time.Duration
|
||||||
ForcePull bool
|
ForcePull bool
|
||||||
RunnerUUID string
|
RunnerUUID string
|
||||||
|
Transfer *KubernetesTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
// KubernetesPod runs a job container and its service containers as one pod.
|
// KubernetesPod runs a job container and its service containers as one pod.
|
||||||
@@ -47,6 +51,10 @@ type KubernetesPod struct {
|
|||||||
job *kubernetesContainer
|
job *kubernetesContainer
|
||||||
services []*kubernetesContainer
|
services []*kubernetesContainer
|
||||||
arch, path string
|
arch, path string
|
||||||
|
ips []net.IP
|
||||||
|
transfer *transferServer
|
||||||
|
transferImage string
|
||||||
|
transferHelper string // empty when copies go through the API server
|
||||||
}
|
}
|
||||||
|
|
||||||
type kubernetesContainer struct {
|
type kubernetesContainer struct {
|
||||||
@@ -76,6 +84,7 @@ type podStatus struct {
|
|||||||
Spec struct{ Containers []struct{} }
|
Spec struct{ Containers []struct{} }
|
||||||
Status struct {
|
Status struct {
|
||||||
Phase, Reason, Message string
|
Phase, Reason, Message string
|
||||||
|
PodIPs []struct{ IP string }
|
||||||
Conditions []struct{ Type, Status, Message string }
|
Conditions []struct{ Type, Status, Message string }
|
||||||
InitContainerStatuses, ContainerStatuses []containerStatus
|
InitContainerStatuses, ContainerStatuses []containerStatus
|
||||||
}
|
}
|
||||||
@@ -131,14 +140,24 @@ func (p *KubernetesPod) create(ctx context.Context) error {
|
|||||||
labels[runnerUUIDLabel] = p.options.RunnerUUID
|
labels[runnerUUIDLabel] = p.options.RunnerUUID
|
||||||
}
|
}
|
||||||
metadata := map[string]any{"name": name, "labels": labels}
|
metadata := map[string]any{"name": name, "labels": labels}
|
||||||
|
var runnerImage string
|
||||||
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
|
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
|
||||||
var runner struct{ Metadata struct{ Name, UID string } }
|
var runner struct {
|
||||||
|
Metadata struct{ Name, UID string }
|
||||||
|
Spec struct{ Containers []struct{ Image string } }
|
||||||
|
}
|
||||||
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
|
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
|
||||||
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
|
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
|
||||||
|
if len(runner.Spec.Containers) == 1 {
|
||||||
|
runnerImage = runner.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
|
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := p.setupTransfer(runnerImage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
secretData := map[string]string{}
|
secretData := map[string]string{}
|
||||||
manifest, err := p.manifest(name, secretData, metadata)
|
manifest, err := p.manifest(name, secretData, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,6 +226,18 @@ func (p *KubernetesPod) manifest(secret string, secretData map[string]string, me
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
|
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
|
||||||
|
if p.transferHelper != "" {
|
||||||
|
transfer := map[string]any{
|
||||||
|
"name": "gitea-runner-transfer", "image": p.transferImage, "terminationMessagePolicy": "FallbackToLogsOnError",
|
||||||
|
"command": []string{"gitea-runner", KubernetesTransferCommand, "install", p.transferHelper},
|
||||||
|
"volumeMounts": []any{map[string]any{"name": "act", "mountPath": p.job.GetActPath()}},
|
||||||
|
"securityContext": map[string]any{"allowPrivilegeEscalation": false, "capabilities": map[string]any{"drop": []string{"ALL"}}},
|
||||||
|
}
|
||||||
|
if p.options.ForcePull {
|
||||||
|
transfer["imagePullPolicy"] = "Always"
|
||||||
|
}
|
||||||
|
spec["initContainers"] = []any{transfer}
|
||||||
|
}
|
||||||
if len(hostnames) > 0 {
|
if len(hostnames) > 0 {
|
||||||
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
|
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
|
||||||
}
|
}
|
||||||
@@ -343,7 +374,13 @@ func (p *KubernetesPod) waitStarted(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if pod.Status.Phase == "Failed" {
|
if pod.Status.Phase == "Failed" {
|
||||||
return fmt.Errorf("pod %s failed: %s", p.name, cmp.Or(pod.Status.Message, pod.Status.Reason))
|
reason := cmp.Or(pod.Status.Message, pod.Status.Reason)
|
||||||
|
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
||||||
|
if terminated := status.State.Terminated; reason == "" && terminated != nil && terminated.ExitCode != 0 {
|
||||||
|
reason = fmt.Sprintf("container %s exited with %d: %s", status.Name, terminated.ExitCode, strings.TrimSpace(terminated.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pod %s failed: %s", p.name, reason)
|
||||||
}
|
}
|
||||||
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
|
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
|
||||||
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
||||||
@@ -356,6 +393,9 @@ func (p *KubernetesPod) waitStarted(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if started {
|
if started {
|
||||||
|
for _, ip := range pod.Status.PodIPs {
|
||||||
|
p.ips = append(p.ips, net.ParseIP(ip.IP))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, condition := range pod.Status.Conditions {
|
for _, condition := range pod.Status.Conditions {
|
||||||
@@ -457,22 +497,35 @@ func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, w
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compressCopyMin is the size from which Copy compresses: below it gzip saves next to nothing on the wire and costs a process in the pod.
|
// compressCopyMin is the size from which Copy compresses and uses the transfer helper: below it that saves next to nothing on the wire and costs a process in the pod.
|
||||||
const compressCopyMin = 64 << 10
|
const compressCopyMin = 64 << 10
|
||||||
|
|
||||||
// extract streams the tar write produces into destPath over exec, which goes through the API server.
|
// extract streams the tar write produces into destPath, gzipped with compress, as copied actions shrink about fivefold and API server traffic often crosses zones.
|
||||||
// With compress it is gzipped on the way, as copied actions shrink about fivefold and API server traffic often crosses zones.
|
|
||||||
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, compress bool, write func(io.Writer) error) error {
|
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, compress bool, write func(io.Writer) error) error {
|
||||||
|
if compress && c.pod.transferHelper != "" {
|
||||||
|
request, done := c.pod.transfer.offer(c.pod.ips, write)
|
||||||
|
stdin, _ := json.Marshal(request)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{c.pod.transferHelper, KubernetesTransferCommand, "fetch", destPath}, bytes.NewReader(stdin), io.Discard, &stderr)
|
||||||
|
taken, writeErr := done()
|
||||||
|
if err == nil || ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken || !c.pod.options.Transfer.Fallback {
|
||||||
|
return fmt.Errorf("copy to %s over the pod network: %w: %s", destPath, errors.Join(writeErr, err), strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
c.pod.transferHelper = "" // a pod that cannot reach the runner once will not later
|
||||||
|
logging.Warnf("Copying into pod %s over the pod network failed, copying through the API server instead: %v: %s", c.pod.name, err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
compress = compress && c.gzip
|
compress = compress && c.gzip
|
||||||
reader, writer := io.Pipe()
|
reader, writer := io.Pipe()
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
go func() {
|
go func() {
|
||||||
if !compress {
|
if compress {
|
||||||
|
writer.CloseWithError(writeGzip(writer, write))
|
||||||
|
} else {
|
||||||
writer.CloseWithError(write(writer))
|
writer.CloseWithError(write(writer))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
compressed, _ := gzip.NewWriterLevel(writer, gzip.BestSpeed) // fast enough to not slow copies down, nearly as small as the default level
|
|
||||||
writer.CloseWithError(errors.Join(write(compressed), compressed.Close()))
|
|
||||||
}()
|
}()
|
||||||
script := `mkdir -p "$1" && tar -xf - -C "$1"`
|
script := `mkdir -p "$1" && tar -xf - -C "$1"`
|
||||||
if compress {
|
if compress {
|
||||||
@@ -485,6 +538,12 @@ func (c *kubernetesContainer) extract(ctx context.Context, destPath string, comp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeGzip gzips what write produces into w, at a level fast enough to not slow copies down and nearly as small as the default.
|
||||||
|
func writeGzip(w io.Writer, write func(io.Writer) error) error {
|
||||||
|
compressed, _ := gzip.NewWriterLevel(w, gzip.BestSpeed)
|
||||||
|
return errors.Join(write(compressed), compressed.Close())
|
||||||
|
}
|
||||||
|
|
||||||
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
|
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
size := 0
|
size := 0
|
||||||
|
|||||||
@@ -403,6 +403,13 @@ func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
|
|||||||
job := pod.JobContainer(&NewContainerInput{Image: "missing"})
|
job := pod.JobContainer(&NewContainerInput{Image: "missing"})
|
||||||
require.NoError(t, job.Create(nil, nil)(t.Context()))
|
require.NoError(t, job.Create(nil, nil)(t.Context()))
|
||||||
require.ErrorContains(t, job.Start(false)(t.Context()), "ErrImagePull: no such image")
|
require.ErrorContains(t, job.Start(false)(t.Context()), "ErrImagePull: no such image")
|
||||||
|
failed, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"phase":"Failed","initContainerStatuses":[
|
||||||
|
{"name":"gitea-runner-transfer","state":{"terminated":{"exitCode":1,"message":"exec format error\n"}}}]}}`)
|
||||||
|
pod, err = NewKubernetesPod(KubernetesOptions{Kubeconfig: failed})
|
||||||
|
require.NoError(t, err)
|
||||||
|
job = pod.JobContainer(&NewContainerInput{Image: "node"})
|
||||||
|
require.NoError(t, job.Create(nil, nil)(t.Context()))
|
||||||
|
require.ErrorContains(t, job.Start(false)(t.Context()), "container gitea-runner-transfer exited with 1: exec format error")
|
||||||
|
|
||||||
plugin := filepath.Join(t.TempDir(), "kubeconfig")
|
plugin := filepath.Join(t.TempDir(), "kubeconfig")
|
||||||
require.NoError(t, os.WriteFile(plugin, []byte(`
|
require.NoError(t, os.WriteFile(plugin, []byte(`
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KubernetesTransfer has job pods fetch large copies from the runner over the pod network instead of through the API server.
|
||||||
|
type KubernetesTransfer struct {
|
||||||
|
Host string // empty uses the outbound IP
|
||||||
|
Port uint16 // 0 picks a free port
|
||||||
|
Image string // has gitea-runner on its PATH, for an init container to copy into each job pod
|
||||||
|
Fallback bool // copy through the API server when a job pod cannot reach the runner, else fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// KubernetesTransferCommand is the hidden runner subcommand job pods run the transfer helper with.
|
||||||
|
const KubernetesTransferCommand = "kubernetes-transfer"
|
||||||
|
|
||||||
|
// transferRequest goes to the helper on the exec stream's stdin, keeping the token out of the command line and the audit log.
|
||||||
|
type transferRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Pin string `json:"pin"` // hex SHA-256 of the runner's certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
type transferServer struct {
|
||||||
|
base string
|
||||||
|
pin string
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]transferOffer
|
||||||
|
}
|
||||||
|
|
||||||
|
type transferOffer struct {
|
||||||
|
clients []net.IP
|
||||||
|
write func(io.Writer) error
|
||||||
|
result chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
transferServersMu sync.Mutex
|
||||||
|
transferServers = map[string]*transferServer{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTransferServer returns the runner's server for these settings, started on first use.
|
||||||
|
func startTransferServer(host string, port uint16) (*transferServer, error) {
|
||||||
|
transferServersMu.Lock()
|
||||||
|
defer transferServersMu.Unlock()
|
||||||
|
configured := net.JoinHostPort(host, strconv.Itoa(int(port)))
|
||||||
|
if server, ok := transferServers[configured]; ok {
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
ip := common.GetOutboundIP()
|
||||||
|
if ip == nil {
|
||||||
|
return nil, errors.New("no outbound IP for job pods to reach the runner at, set kubernetes.transfer.host")
|
||||||
|
}
|
||||||
|
host = ip.String()
|
||||||
|
}
|
||||||
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{SerialNumber: big.NewInt(1)} // trusted by its pin alone, so no CA, names or validity
|
||||||
|
certificate, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(int(port))))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen for job pod transfers: %w", err)
|
||||||
|
}
|
||||||
|
pin := sha256.Sum256(certificate)
|
||||||
|
server := &transferServer{base: "https://" + listener.Addr().String() + "/", pin: hex.EncodeToString(pin[:]), pending: map[string]transferOffer{}}
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Handler: server,
|
||||||
|
TLSConfig: &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{certificate}, PrivateKey: privateKey}}, MinVersion: tls.VersionTLS13},
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
go func() { _ = httpServer.ServeTLS(listener, "", "") }()
|
||||||
|
transferServers[configured] = server
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offer serves write once to one of clients until done, which reports whether a client took it and what write failed with.
|
||||||
|
func (s *transferServer) offer(clients []net.IP, write func(io.Writer) error) (request transferRequest, done func() (taken bool, err error)) {
|
||||||
|
token := rand.Text()
|
||||||
|
result := make(chan error, 1)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pending[token] = transferOffer{clients: clients, write: write, result: result}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return transferRequest{URL: s.base + token, Pin: s.pin}, func() (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
_, pending := s.pending[token]
|
||||||
|
delete(s.pending, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
return true, err
|
||||||
|
default:
|
||||||
|
return !pending, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *transferServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
token := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
s.mu.Lock()
|
||||||
|
offer, ok := s.pending[token]
|
||||||
|
ok = ok && r.Method == http.MethodGet && slices.ContainsFunc(offer.clients, net.ParseIP(host).Equal)
|
||||||
|
if ok {
|
||||||
|
delete(s.pending, token)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
err := writeGzip(w, offer.write)
|
||||||
|
offer.result <- err
|
||||||
|
if err != nil {
|
||||||
|
panic(http.ErrAbortHandler) // a cut-off body fails the fetch, a short tar might not
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchKubernetesTransfer reads a transferRequest from request and extracts the tar it points to into dest.
|
||||||
|
func FetchKubernetesTransfer(ctx context.Context, request io.Reader, dest string) error {
|
||||||
|
var transfer transferRequest
|
||||||
|
if err := json.NewDecoder(request).Decode(&transfer); err != nil {
|
||||||
|
return fmt.Errorf("read transfer request: %w", err)
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Minute,
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
InsecureSkipVerify: true, // VerifyConnection checks the pin instead
|
||||||
|
VerifyConnection: func(state tls.ConnectionState) error {
|
||||||
|
if sum := sha256.Sum256(state.PeerCertificates[0].Raw); hex.EncodeToString(sum[:]) != transfer.Pin {
|
||||||
|
return errors.New("runner certificate does not match the pin")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, transfer.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := client.Do(httpRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("fetch: %s", response.Status)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dest, 0o777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "tar", "-xf", "-", "-C", dest)
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = response.Body, os.Stderr, os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(io.Discard, response.Body) // tar stops at the first zero blocks, a cut-off body after them still fails
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallKubernetesTransferHelper copies the running binary to target.
|
||||||
|
func InstallKubernetesTransferHelper(target string) error {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source, err := os.Open(executable)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(target) // a file left in a reused act volume would keep its mode
|
||||||
|
file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(file, source)
|
||||||
|
return errors.Join(err, file.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTransfer turns on transfers for the pod, with the runner pod's image unless one is set.
|
||||||
|
func (p *KubernetesPod) setupTransfer(runnerImage string) error {
|
||||||
|
settings := p.options.Transfer
|
||||||
|
if settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
image := cmp.Or(settings.Image, runnerImage)
|
||||||
|
var err error
|
||||||
|
var server *transferServer
|
||||||
|
if image == "" {
|
||||||
|
err = errors.New("set kubernetes.transfer.image, the runner is no single-container pod in kubernetes.namespace to take it from")
|
||||||
|
} else {
|
||||||
|
server, err = startTransferServer(settings.Host, settings.Port)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !settings.Fallback {
|
||||||
|
return fmt.Errorf("kubernetes.transfer: %w", err)
|
||||||
|
}
|
||||||
|
logging.Warnf("Job pod copies go through the API server: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.transfer, p.transferImage = server, image
|
||||||
|
p.transferHelper = path.Join(p.job.GetActPath(), "bin", "gitea-runner")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const transferHelperEnv = "GITEA_RUNNER_TEST_TRANSFER_HELPER"
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv(transferHelperEnv) == "1" && len(os.Args) == 4 && os.Args[1] == KubernetesTransferCommand && os.Args[2] == "fetch" {
|
||||||
|
if err := FetchKubernetesTransfer(context.Background(), os.Stdin, os.Args[3]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(t *testing.T, request transferRequest) error {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return FetchKubernetesTransfer(t.Context(), bytes.NewReader(body), t.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferServer(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("extracts with tar")
|
||||||
|
}
|
||||||
|
server, err := startTransferServer("127.0.0.1", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
again, err := startTransferServer("127.0.0.1", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Same(t, server, again)
|
||||||
|
loopback := []net.IP{net.ParseIP("127.0.0.1")}
|
||||||
|
nothing := func(io.Writer) error { return nil }
|
||||||
|
|
||||||
|
t.Run("serves an offer gzipped once to an IP of its job pod", func(t *testing.T) {
|
||||||
|
request, done := server.offer([]net.IP{net.ParseIP("fd00::1"), loopback[0]}, nothing)
|
||||||
|
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DisableCompression: true}}
|
||||||
|
response, err := client.Get(request.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
assert.Equal(t, "gzip", response.Header.Get("Content-Encoding"))
|
||||||
|
require.ErrorContains(t, fetch(t, request), "404")
|
||||||
|
taken, _ := done()
|
||||||
|
assert.True(t, taken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("another pod can neither take nor block an offer", func(t *testing.T) {
|
||||||
|
request, done := server.offer([]net.IP{net.ParseIP("10.1.2.3")}, nothing)
|
||||||
|
require.ErrorContains(t, fetch(t, request), "404")
|
||||||
|
taken, _ := done()
|
||||||
|
assert.False(t, taken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed write fails the fetch even past the end blocks tar stops at, and done reports it", func(t *testing.T) {
|
||||||
|
request, done := server.offer(loopback, func(w io.Writer) error {
|
||||||
|
_, _ = w.Write(make([]byte, 1024))
|
||||||
|
_, _ = io.CopyN(w, rand.Reader, 1<<20)
|
||||||
|
return errors.New("source gone")
|
||||||
|
})
|
||||||
|
require.Error(t, fetch(t, request))
|
||||||
|
taken, err := done()
|
||||||
|
assert.True(t, taken)
|
||||||
|
require.EqualError(t, err, "source gone")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the helper refuses a runner whose certificate does not match its pin", func(t *testing.T) {
|
||||||
|
request, done := server.offer(loopback, nothing)
|
||||||
|
request.Pin = strings.Repeat("0", 64)
|
||||||
|
require.ErrorContains(t, fetch(t, request), "does not match the pin")
|
||||||
|
taken, _ := done()
|
||||||
|
assert.False(t, taken)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesPodTransferImage(t *testing.T) {
|
||||||
|
newPod := func(image string, fallback bool) *KubernetesPod {
|
||||||
|
pod := &KubernetesPod{options: KubernetesOptions{Transfer: &KubernetesTransfer{Host: "127.0.0.1", Image: image, Fallback: fallback}}}
|
||||||
|
pod.JobContainer(&NewContainerInput{})
|
||||||
|
return pod
|
||||||
|
}
|
||||||
|
|
||||||
|
pod := newPod("", false)
|
||||||
|
require.NoError(t, pod.setupTransfer("gitea/runner:latest"))
|
||||||
|
assert.Equal(t, "gitea/runner:latest", pod.transferImage)
|
||||||
|
|
||||||
|
pod = newPod("registry.example/runner:1", false)
|
||||||
|
require.NoError(t, pod.setupTransfer("gitea/runner:latest"))
|
||||||
|
assert.Equal(t, "registry.example/runner:1", pod.transferImage)
|
||||||
|
|
||||||
|
pod = newPod("", true)
|
||||||
|
require.NoError(t, pod.setupTransfer(""))
|
||||||
|
assert.Empty(t, pod.transferHelper)
|
||||||
|
|
||||||
|
pod = newPod("", false)
|
||||||
|
require.ErrorContains(t, pod.setupTransfer(""), "kubernetes.transfer.image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesPodCopiesLargeFilesOverPodNetwork(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
ctx := t.Context()
|
||||||
|
workdir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"phase":"Running","podIPs":[{"ip":"127.0.0.1"}],"containerStatuses":[
|
||||||
|
{"name":"job","ready":true,"state":{"running":{}}}]}}`, transferHelperEnv+"=1")
|
||||||
|
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, ForcePull: true, Transfer: &KubernetesTransfer{Host: "127.0.0.1", Image: "gitea/runner:latest", Fallback: true}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := pod.JobContainer(&NewContainerInput{Name: "job", Image: "node", WorkingDir: workdir, Stdout: io.Discard, Stderr: io.Discard})
|
||||||
|
require.NoError(t, job.Create(nil, nil)(ctx))
|
||||||
|
|
||||||
|
var manifest struct {
|
||||||
|
Spec struct {
|
||||||
|
InitContainers []struct {
|
||||||
|
Image, ImagePullPolicy string
|
||||||
|
Command []string
|
||||||
|
VolumeMounts []struct{ Name, MountPath string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(cluster.requests["POST /api/v1/namespaces/ci/pods"][0]), &manifest))
|
||||||
|
require.Len(t, manifest.Spec.InitContainers, 1)
|
||||||
|
initContainer := manifest.Spec.InitContainers[0]
|
||||||
|
assert.Equal(t, "gitea/runner:latest", initContainer.Image)
|
||||||
|
assert.Equal(t, "Always", initContainer.ImagePullPolicy)
|
||||||
|
assert.Equal(t, []string{"gitea-runner", KubernetesTransferCommand, "install", "/var/run/act/bin/gitea-runner"}, initContainer.Command)
|
||||||
|
assert.Equal(t, []struct{ Name, MountPath string }{{"act", "/var/run/act"}}, initContainer.VolumeMounts)
|
||||||
|
require.NoError(t, job.Start(false)(ctx))
|
||||||
|
|
||||||
|
helper := filepath.Join(t.TempDir(), "bin", "gitea-runner")
|
||||||
|
require.NoError(t, InstallKubernetesTransferHelper(helper))
|
||||||
|
pod.transferHelper = helper
|
||||||
|
lastExec := func() string { return cluster.requests["exec"][len(cluster.requests["exec"])-1] }
|
||||||
|
large := &FileEntry{Name: "large", Mode: 0o644, Body: strings.Repeat("a", compressCopyMin)}
|
||||||
|
copyLarge := func(dir string) error { return job.Copy(filepath.Join(workdir, dir)+"/", large)(ctx) }
|
||||||
|
require.NoError(t, copyLarge("transferred"))
|
||||||
|
content, err := os.ReadFile(filepath.Join(workdir, "transferred", "large"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, large.Body, string(content))
|
||||||
|
assert.Contains(t, lastExec(), "command="+KubernetesTransferCommand)
|
||||||
|
assert.NotContains(t, lastExec(), "127.0.0.1")
|
||||||
|
|
||||||
|
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "event.json", Mode: 0o644, Body: `{}`})(ctx))
|
||||||
|
assert.NotContains(t, lastExec(), "command="+KubernetesTransferCommand)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, "file"), nil, 0o644))
|
||||||
|
require.ErrorContains(t, copyLarge("file"), "over the pod network")
|
||||||
|
assert.Equal(t, helper, pod.transferHelper)
|
||||||
|
|
||||||
|
unreachable := filepath.Join(workdir, "unreachable")
|
||||||
|
require.NoError(t, os.WriteFile(unreachable, []byte("#!/bin/sh\necho 'connection refused' >&2\nexit 1\n"), 0o755))
|
||||||
|
pod.transferHelper = unreachable
|
||||||
|
pod.options.Transfer.Fallback = false
|
||||||
|
require.ErrorContains(t, copyLarge("direct"), "connection refused")
|
||||||
|
assert.NoFileExists(t, filepath.Join(workdir, "direct", "large"))
|
||||||
|
|
||||||
|
pod.options.Transfer.Fallback = true
|
||||||
|
require.NoError(t, copyLarge("auto"))
|
||||||
|
assert.FileExists(t, filepath.Join(workdir, "auto", "large"))
|
||||||
|
assert.Empty(t, pod.transferHelper)
|
||||||
|
}
|
||||||
@@ -180,9 +180,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
|
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
rc.withGithubEnv(ctx, getStepGithubContext(ctx, step), *step.getEnv())
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
if err := populateEnvsFromInput(ctx, step, action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
warnUnexpectedInputs(ctx, action, stepModel.With)
|
warnUnexpectedInputs(ctx, action, stepModel.With)
|
||||||
@@ -283,7 +283,7 @@ func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, ac
|
|||||||
}
|
}
|
||||||
image, ok := job.dockerActionImages[step.getStepModel()]
|
image, ok := job.dockerActionImages[step.getStepModel()]
|
||||||
if !ok {
|
if !ok {
|
||||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
|
image = dockerActionImageTag(getStepGithubContext(ctx, step).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
|
||||||
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
|
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
|
||||||
job.dockerActionImages[step.getStepModel()] = image
|
job.dockerActionImages[step.getStepModel()] = image
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre
|
|||||||
for key := range step.getStepModel().With {
|
for key := range step.getStepModel().With {
|
||||||
providedInputs[strings.ToLower(key)] = true
|
providedInputs[strings.ToLower(key)] = true
|
||||||
}
|
}
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewStepExpressionEvaluator(ctx, step)
|
||||||
for k, input := range action.Inputs {
|
for k, input := range action.Inputs {
|
||||||
if providedInputs[strings.ToLower(k)] {
|
if providedInputs[strings.ToLower(k)] {
|
||||||
continue
|
continue
|
||||||
@@ -456,6 +456,7 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
|
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
|
||||||
|
aliasGiteaEnv(*step.getEnv())
|
||||||
return ee, nil
|
return ee, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,8 +510,9 @@ func inputEnvKey(inputID string) string {
|
|||||||
return "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_")
|
return "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
|
func populateEnvsFromInput(ctx context.Context, step step, action *model.Action) error {
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
env := step.getEnv()
|
||||||
|
eval := step.getRunContext().NewStepExpressionEvaluator(ctx, step)
|
||||||
for inputID, input := range action.Inputs {
|
for inputID, input := range action.Inputs {
|
||||||
envKey := inputEnvKey(inputID)
|
envKey := inputEnvKey(inputID)
|
||||||
if _, ok := (*env)[envKey]; !ok {
|
if _, ok := (*env)[envKey]; !ok {
|
||||||
@@ -652,7 +654,7 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
if !x.IsComposite() {
|
if !x.IsComposite() {
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
if err := populateEnvsFromInput(ctx, step, action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
env[envKey] = value
|
env[envKey] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gh := step.getGithubContext(ctx)
|
|
||||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
|
||||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
|
||||||
|
|
||||||
return env, nil
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeActionParity(t *testing.T) {
|
func TestCompositeActionParity(t *testing.T) {
|
||||||
|
t.Run("input defaults use the step identity and docker env keeps the GITEA aliases", func(t *testing.T) {
|
||||||
|
for _, uses := range []string{"./action", "own/action@v2"} {
|
||||||
|
t.Run(uses, func(t *testing.T) {
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{PresetGitHubContext: &model.GithubContext{}},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "other/composite", "GITHUB_ACTION_REF": "v1"},
|
||||||
|
}
|
||||||
|
var currentStep step = &stepActionLocal{RunContext: rc, Step: &model.Step{Uses: uses}, env: map[string]string{}}
|
||||||
|
want := "|"
|
||||||
|
if uses == "own/action@v2" {
|
||||||
|
currentStep = &stepActionRemote{RunContext: rc, Step: &model.Step{Uses: uses}, env: map[string]string{}, remoteAction: mustNewRemoteAction(t, uses)}
|
||||||
|
want = "own/action|v2"
|
||||||
|
}
|
||||||
|
action := &model.Action{
|
||||||
|
Inputs: map[string]model.Input{"identity": {Default: "${{ github.action_repository }}|${{ github.action_ref }}"}},
|
||||||
|
Runs: model.ActionRuns{Env: map[string]string{"GITHUB_WORKFLOW_SHA": "runs-sha"}},
|
||||||
|
}
|
||||||
|
require.NoError(t, setupEnv(t.Context(), currentStep))
|
||||||
|
require.NoError(t, populateEnvsFromInput(t.Context(), currentStep, action))
|
||||||
|
assert.Equal(t, want, (*currentStep.getEnv())["INPUT_IDENTITY"])
|
||||||
|
_, err := evalDockerEnv(t.Context(), currentStep, action)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, (*currentStep.getEnv())["identity"])
|
||||||
|
assert.Equal(t, "runs-sha", (*currentStep.getEnv())["GITEA_WORKFLOW_SHA"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||||
hook := logtest.NewWithLevel(logging.LevelWarn)
|
hook := logtest.NewWithLevel(logging.LevelWarn)
|
||||||
ctx := common.WithLogger(t.Context(), logging.New(hook))
|
ctx := common.WithLogger(t.Context(), logging.New(hook))
|
||||||
@@ -54,7 +83,7 @@ func TestCompositeActionParity(t *testing.T) {
|
|||||||
assert.NotContains(t, composite.Env, "INPUT_EXTRA")
|
assert.NotContains(t, composite.Env, "INPUT_EXTRA")
|
||||||
|
|
||||||
nestedEnv := composite.GetEnv()
|
nestedEnv := composite.GetEnv()
|
||||||
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
|
require.NoError(t, populateEnvsFromInput(ctx, &stepActionLocal{RunContext: composite, env: nestedEnv}, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}))
|
||||||
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -90,18 +90,18 @@ var hashfiles string
|
|||||||
|
|
||||||
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
||||||
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext())
|
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext(), getStepGithubContext(ctx, step))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
||||||
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext())
|
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext(), getStepGithubContext(ctx, step))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
|
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext, github *model.GithubContext) *ExpressionEvaluator {
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: step.getGithubContext(ctx),
|
Github: github,
|
||||||
Env: *step.getEnv(),
|
Env: *step.getEnv(),
|
||||||
Job: jobContext,
|
Job: jobContext,
|
||||||
Steps: rc.StepResults,
|
Steps: rc.StepResults,
|
||||||
@@ -112,7 +112,7 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
|||||||
Needs: exprparser.NeedsContext(rc.Run),
|
Needs: exprparser.NeedsContext(rc.Run),
|
||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the inputs in actions/composite
|
// but required to interpolate/evaluate the inputs in actions/composite
|
||||||
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
Inputs: getEvaluatorInputs(rc, stepInputs, github),
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Exec
|
|||||||
if !ok || strings.Contains(action.Step.Uses, "${{") || action.action != nil {
|
if !ok || strings.Contains(action.Step.Uses, "${{") || action.action != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
github := action.getGithubContext(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
|
github := action.RunContext.getGithubContext(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
|
||||||
remote, err := newRemoteAction(action.Step.Uses, github, action.RunContext.Config.GitHubInstance)
|
remote, err := newRemoteAction(action.Step.Uses, github, action.RunContext.Config.GitHubInstance)
|
||||||
if err != nil || action.skipsLocalCheckout(remote, github) {
|
if err != nil || action.skipsLocalCheckout(remote, github) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1047,23 +1047,25 @@ func makeTestRC(t *testing.T, jobYAML string) *RunContext {
|
|||||||
|
|
||||||
func TestApplyJobTimeout(t *testing.T) {
|
func TestApplyJobTimeout(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
yaml string
|
yaml string
|
||||||
wantTimeout bool
|
want time.Duration
|
||||||
}{
|
}{
|
||||||
{"empty", "runs-on: ubuntu-latest", false},
|
{"empty", "runs-on: ubuntu-latest", 0},
|
||||||
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", 5 * time.Minute},
|
||||||
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false},
|
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", 0},
|
||||||
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
rc := makeTestRC(t, tc.yaml)
|
rc := makeTestRC(t, tc.yaml)
|
||||||
ctx := t.Context()
|
newCtx, cancel := applyJobTimeout(t.Context(), rc, rc.Run.Job())
|
||||||
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, hasDeadline := newCtx.Deadline()
|
deadline, hasDeadline := newCtx.Deadline()
|
||||||
assert.Equal(t, tc.wantTimeout, hasDeadline)
|
assert.Equal(t, tc.want != 0, hasDeadline)
|
||||||
|
if hasDeadline {
|
||||||
|
assert.WithinDuration(t, time.Now().Add(tc.want), deadline, time.Minute)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,8 +84,7 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
|
|||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
||||||
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
||||||
env["GITEA_ENV"] = env["GITHUB_ENV"]
|
aliasGiteaEnv(env)
|
||||||
env["GITEA_PATH"] = env["GITHUB_PATH"]
|
|
||||||
|
|
||||||
return rc.JobContainer.Copy(actPath,
|
return rc.JobContainer.Copy(actPath,
|
||||||
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ func TestRunJobHook(t *testing.T) {
|
|||||||
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
||||||
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
||||||
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||||
|
assert.Equal(t, jobContainer.env["GITHUB_PATH"], jobContainer.env["GITEA_PATH"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("each hook has its own command state", func(t *testing.T) {
|
t.Run("each hook has its own command state", func(t *testing.T) {
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
return common.NewErrorExecutor(err)
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rc.Config.NoSkipCheckout {
|
preset := rc.Config.PresetGitHubContext
|
||||||
|
if preset == nil {
|
||||||
// resolve the local workflow against the workspace root, not the process
|
// resolve the local workflow against the workspace root, not the process
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
// working directory, so it is found regardless of where the runner is invoked
|
||||||
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
|
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
uses := fmt.Sprintf("%s/%s@%s", preset.Repository, localWorkflow.Path, preset.Sha)
|
||||||
|
|
||||||
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
|
|||||||
|
|
||||||
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: workdir},
|
Config: &Config{Workdir: workdir, NoSkipCheckout: true},
|
||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
||||||
}
|
}
|
||||||
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
||||||
|
|||||||
@@ -1602,6 +1602,23 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aliasGiteaEnv mirrors the runner's GITHUB_* variables as GITEA_*, call it after the last write to them.
|
||||||
|
func aliasGiteaEnv(env map[string]string) {
|
||||||
|
for _, suffix := range []string{
|
||||||
|
"ACTION", "ACTION_PATH", "ACTION_REF", "ACTION_REPOSITORY", "ACTIONS", "ACTOR", "ACTOR_ID", "API_URL", "BASE_REF",
|
||||||
|
"ENV", "EVENT_NAME", "EVENT_PATH", "GRAPHQL_URL", "HEAD_REF", "JOB", "OUTPUT", "PATH", "REF", "REF_NAME",
|
||||||
|
"REF_PROTECTED", "REF_TYPE", "REPOSITORY", "REPOSITORY_ID", "REPOSITORY_OWNER", "REPOSITORY_OWNER_ID",
|
||||||
|
"RETENTION_DAYS", "RUN_ATTEMPT", "RUN_ID", "RUN_NUMBER", "SERVER_URL", "SHA", "STATE", "STEP_SUMMARY",
|
||||||
|
"TRIGGERING_ACTOR", "WORKFLOW", "WORKFLOW_REF", "WORKFLOW_SHA", "WORKSPACE",
|
||||||
|
} {
|
||||||
|
if value, ok := env["GITHUB_"+suffix]; ok {
|
||||||
|
env["GITEA_"+suffix] = value
|
||||||
|
} else {
|
||||||
|
delete(env, "GITEA_"+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
|
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
|
||||||
if _, ok := env["CI"]; !ok {
|
if _, ok := env["CI"]; !ok {
|
||||||
env["CI"] = "true"
|
env["CI"] = "true"
|
||||||
@@ -1654,6 +1671,7 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
|
|||||||
|
|
||||||
env["GITHUB_GRAPHQL_URL"] = ""
|
env["GITHUB_GRAPHQL_URL"] = ""
|
||||||
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
||||||
|
aliasGiteaEnv(env)
|
||||||
|
|
||||||
env["RUNNER_NAME"] = rc.Config.RunnerName
|
env["RUNNER_NAME"] = rc.Config.RunnerName
|
||||||
env["RUNNER_ENVIRONMENT"] = "self-hosted"
|
env["RUNNER_ENVIRONMENT"] = "self-hosted"
|
||||||
|
|||||||
@@ -877,25 +877,25 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
RepositoryID: "34",
|
RepositoryID: "34",
|
||||||
RepositoryOwnerID: "56",
|
RepositoryOwnerID: "56",
|
||||||
WorkflowRef: "owner/repo/.gitea/workflows/test.yml@refs/heads/main",
|
WorkflowRef: "owner/repo/.gitea/workflows/test.yml@refs/heads/main",
|
||||||
WorkflowSha: "workflow-sha",
|
|
||||||
RefProtected: true,
|
RefProtected: true,
|
||||||
}
|
}
|
||||||
ghc = rc.getGithubContext(t.Context())
|
ghc = rc.getGithubContext(t.Context())
|
||||||
assert.Equal(t, "preset-actor", ghc.Actor)
|
assert.Equal(t, "preset-actor", ghc.Actor)
|
||||||
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
||||||
assert.Equal(t, "job1", ghc.Job)
|
assert.Equal(t, "job1", ghc.Job)
|
||||||
env := map[string]string{}
|
env := map[string]string{"GITHUB_WORKFLOW_SHA": "workflow-sha"}
|
||||||
rc.withGithubEnv(t.Context(), ghc, env)
|
rc.withGithubEnv(t.Context(), ghc, env)
|
||||||
for name, value := range map[string]string{
|
for suffix, value := range map[string]string{
|
||||||
"GITHUB_ACTOR_ID": "12",
|
"ACTOR_ID": "12",
|
||||||
"GITHUB_TRIGGERING_ACTOR": "triggerer",
|
"TRIGGERING_ACTOR": "triggerer",
|
||||||
"GITHUB_REPOSITORY_ID": "34",
|
"REPOSITORY_ID": "34",
|
||||||
"GITHUB_REPOSITORY_OWNER_ID": "56",
|
"REPOSITORY_OWNER_ID": "56",
|
||||||
"GITHUB_WORKFLOW_REF": "owner/repo/.gitea/workflows/test.yml@refs/heads/main",
|
"WORKFLOW_REF": "owner/repo/.gitea/workflows/test.yml@refs/heads/main",
|
||||||
"GITHUB_WORKFLOW_SHA": "workflow-sha",
|
"WORKFLOW_SHA": "workflow-sha",
|
||||||
"GITHUB_REF_PROTECTED": "true",
|
"REF_PROTECTED": "true",
|
||||||
} {
|
} {
|
||||||
assert.Equal(t, value, env[name], name)
|
assert.Equal(t, value, env["GITHUB_"+suffix], suffix)
|
||||||
|
assert.Equal(t, value, env["GITEA_"+suffix], suffix)
|
||||||
}
|
}
|
||||||
ghc.RefProtected = false
|
ghc.RefProtected = false
|
||||||
rc.withGithubEnv(t.Context(), ghc, env)
|
rc.withGithubEnv(t.Context(), ghc, env)
|
||||||
@@ -922,12 +922,12 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
github := rc.getGithubContext(t.Context())
|
github := rc.getGithubContext(t.Context())
|
||||||
assert.Equal(t, testcase.serverURL, github.ServerURL)
|
assert.Equal(t, testcase.serverURL, github.ServerURL)
|
||||||
assert.Equal(t, testcase.apiURL, github.APIURL)
|
assert.Equal(t, testcase.apiURL, github.APIURL)
|
||||||
env := map[string]string{}
|
env := map[string]string{"GITEA_SERVER_URL": "stale", "GITEA_API_URL": "stale"}
|
||||||
rc.withGithubEnv(t.Context(), github, env)
|
rc.withGithubEnv(t.Context(), github, env)
|
||||||
_, exported := env["GITHUB_SERVER_URL"]
|
_, exported := env["GITHUB_SERVER_URL"]
|
||||||
assert.Equal(t, testcase.serverURL != "", exported)
|
assert.Equal(t, testcase.serverURL != "", exported)
|
||||||
assert.Equal(t, testcase.serverURL, env["GITHUB_SERVER_URL"])
|
assert.Equal(t, testcase.serverURL, env["GITEA_SERVER_URL"])
|
||||||
assert.Equal(t, testcase.apiURL, env["GITHUB_API_URL"])
|
assert.Equal(t, testcase.apiURL, env["GITEA_API_URL"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-12
@@ -27,12 +27,24 @@ type step interface {
|
|||||||
post() common.Executor
|
post() common.Executor
|
||||||
|
|
||||||
getRunContext() *RunContext
|
getRunContext() *RunContext
|
||||||
getGithubContext(ctx context.Context) *model.GithubContext
|
|
||||||
getStepModel() *model.Step
|
getStepModel() *model.Step
|
||||||
getEnv() *map[string]string
|
getEnv() *map[string]string
|
||||||
getIfExpression(context context.Context, stage stepStage) string
|
getIfExpression(context context.Context, stage stepStage) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stepActionIdentity(step step) (repository, ref string) {
|
||||||
|
if action, ok := step.(interface{ actionIdentity() (string, string) }); ok {
|
||||||
|
return action.actionIdentity()
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStepGithubContext(ctx context.Context, step step) *model.GithubContext {
|
||||||
|
github := step.getRunContext().getGithubContext(ctx)
|
||||||
|
github.ActionRepository, github.ActionRef = stepActionIdentity(step)
|
||||||
|
return github
|
||||||
|
}
|
||||||
|
|
||||||
type stepStage int
|
type stepStage int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -92,6 +104,8 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||||
}
|
}
|
||||||
if err == nil && runStep {
|
if err == nil && runStep {
|
||||||
|
env := *step.getEnv()
|
||||||
|
env["GITHUB_ACTION_REPOSITORY"], env["GITHUB_ACTION_REF"] = stepActionIdentity(step)
|
||||||
err = setupInputs(ctx, step)
|
err = setupInputs(ctx, step)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,15 +157,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
stepSummaryIndex := topRC.CurrentStepIndex
|
stepSummaryIndex := topRC.CurrentStepIndex
|
||||||
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
||||||
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
||||||
|
aliasGiteaEnv(*step.getEnv())
|
||||||
{
|
|
||||||
// For Gitea
|
|
||||||
(*step.getEnv())["GITEA_OUTPUT"] = (*step.getEnv())["GITHUB_OUTPUT"]
|
|
||||||
(*step.getEnv())["GITEA_STATE"] = (*step.getEnv())["GITHUB_STATE"]
|
|
||||||
(*step.getEnv())["GITEA_PATH"] = (*step.getEnv())["GITHUB_PATH"]
|
|
||||||
(*step.getEnv())["GITEA_ENV"] = (*step.getEnv())["GITHUB_ENV"]
|
|
||||||
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
||||||
// excluded here and initialized below at most once per step so writes from later
|
// excluded here and initialized below at most once per step so writes from later
|
||||||
@@ -253,7 +259,7 @@ func setupEnv(ctx context.Context, step step) error {
|
|||||||
env := step.getEnv()
|
env := step.getEnv()
|
||||||
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
||||||
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
|
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
rc.withGithubEnv(ctx, getStepGithubContext(ctx, step), *env)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||||
@@ -302,7 +308,7 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
|
|||||||
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
|
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
|
||||||
jobContext.Status = rc.ownStatus()
|
jobContext.Status = rc.ownStatus()
|
||||||
}
|
}
|
||||||
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext), expr, defaultStatusCheck)
|
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext, rc.getGithubContext(ctx)), expr, defaultStatusCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
|||||||
rc.ApplyExtraPath(ctx, &sab.env)
|
rc.ApplyExtraPath(ctx, &sab.env)
|
||||||
return sab.run(ctx, &action.Context{
|
return sab.run(ctx, &action.Context{
|
||||||
Container: rc.JobContainer,
|
Container: rc.JobContainer,
|
||||||
Github: rc.getGithubContext(ctx),
|
Github: getStepGithubContext(ctx, sab),
|
||||||
Inputs: sab.Step.With,
|
Inputs: sab.Step.With,
|
||||||
HostWorkdir: rc.Config.Workdir,
|
HostWorkdir: rc.Config.Workdir,
|
||||||
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
@@ -58,10 +58,6 @@ func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipeline
|
|||||||
|
|
||||||
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getGithubContext(ctx context.Context) *model.GithubContext {
|
|
||||||
return sab.RunContext.getGithubContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
|
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
|
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
|
||||||
|
|||||||
@@ -95,10 +95,6 @@ func (sal *stepActionLocal) getRunContext() *RunContext {
|
|||||||
return sal.RunContext
|
return sal.RunContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) getGithubContext(ctx context.Context) *model.GithubContext {
|
|
||||||
return sal.getRunContext().getGithubContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sal *stepActionLocal) getStepModel() *model.Step {
|
func (sal *stepActionLocal) getStepModel() *model.Step {
|
||||||
return sal.Step
|
return sal.Step
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepActionMocks struct {
|
type stepActionMocks struct {
|
||||||
@@ -53,7 +54,7 @@ func TestStepActionLocalTest(t *testing.T) {
|
|||||||
readAction: salm.readAction,
|
readAction: salm.readAction,
|
||||||
runAction: salm.runAction,
|
runAction: salm.runAction,
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{"previous": {Outputs: map[string]string{"value": "previous"}}},
|
||||||
ExprEval: &expressionEvaluator{},
|
ExprEval: &expressionEvaluator{},
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
Workdir: "/tmp",
|
Workdir: "/tmp",
|
||||||
@@ -68,25 +69,42 @@ func TestStepActionLocalTest(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
|
Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "other/action", "GITHUB_ACTION_REF": "v1"},
|
||||||
|
Matrix: map[string]any{"value": "matrix"},
|
||||||
|
actionInputs: map[string]any{"shared": "input"},
|
||||||
},
|
},
|
||||||
Step: &model.Step{
|
Step: &model.Step{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
Uses: "./path/to/action",
|
Uses: "./path/to/action",
|
||||||
|
If: yaml.Node{Value: "github.action_repository == 'other/action' && github.action_ref == 'v1' && " +
|
||||||
|
"env.STEP_VALUE == 'step' && env.GITHUB_ACTION_REPOSITORY == 'forged/repo' && env.GITHUB_ACTION_REF == 'forged-ref' && " +
|
||||||
|
"steps.previous.outputs.value == 'previous' && inputs.shared == 'input' && matrix.value == 'matrix'"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
require.NoError(t, sal.Step.Env.Encode(map[string]string{"STEP_VALUE": "step", "GITHUB_ACTION_REPOSITORY": "forged/repo", "GITHUB_ACTION_REF": "forged-ref"}))
|
||||||
|
require.NoError(t, sal.Step.RawWith.Encode(map[string]string{"identity": "${{ github.action_repository }}|${{ github.action_ref }}"}))
|
||||||
|
|
||||||
salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything).
|
salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything).
|
||||||
Return(&model.Action{}, nil)
|
Return(&model.Action{}, nil)
|
||||||
|
|
||||||
expectStepFileCommands(t, cm)
|
expectStepFileCommands(t, cm)
|
||||||
|
|
||||||
salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(noopExecutor)
|
salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(func(ctx context.Context) error {
|
||||||
|
sal.RunContext.setOutput(ctx, map[string]string{"name": "value"}, "ran")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
err := sal.pre()(ctx)
|
err := sal.pre()(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = sal.main()(ctx)
|
err = sal.main()(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, sal.env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Empty(t, sal.env["GITHUB_ACTION_REF"])
|
||||||
|
assert.Equal(t, "|", sal.Step.With["identity"])
|
||||||
|
assert.Equal(t, "ran", sal.RunContext.StepResults["1"].Outputs["value"])
|
||||||
|
assert.Equal(t, "other/action", sal.RunContext.Env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Equal(t, "v1", sal.RunContext.Env["GITHUB_ACTION_REF"])
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
salm.AssertExpectations(t)
|
salm.AssertExpectations(t)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
sar.Step.Uses = uses
|
sar.Step.Uses = uses
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
github := sar.RunContext.getGithubContext(ctx)
|
||||||
if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
|
if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
sar.prepareActionExecutor(),
|
sar.prepareActionExecutor(),
|
||||||
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
||||||
github := sar.getGithubContext(ctx)
|
github := getStepGithubContext(ctx, sar)
|
||||||
if sar.skipsLocalCheckout(sar.remoteAction, github) {
|
if sar.skipsLocalCheckout(sar.remoteAction, github) {
|
||||||
printRunActionHeader(ctx, sar.Step, nil, sar.env, sar.RunContext)
|
printRunActionHeader(ctx, sar.Step, nil, sar.env, sar.RunContext)
|
||||||
if sar.RunContext.Config.BindWorkdir {
|
if sar.RunContext.Config.BindWorkdir {
|
||||||
@@ -181,17 +181,11 @@ func (sar *stepActionRemote) getRunContext() *RunContext {
|
|||||||
return sar.RunContext
|
return sar.RunContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getGithubContext(ctx context.Context) *model.GithubContext {
|
func (sar *stepActionRemote) actionIdentity() (string, string) {
|
||||||
ghc := sar.getRunContext().getGithubContext(ctx)
|
if sar.remoteAction == nil {
|
||||||
|
return "", ""
|
||||||
// extend github context if we already have an initialized remoteAction
|
|
||||||
remoteAction := sar.remoteAction
|
|
||||||
if remoteAction != nil {
|
|
||||||
ghc.ActionRepository = fmt.Sprintf("%s/%s", remoteAction.Org, remoteAction.Repo)
|
|
||||||
ghc.ActionRef = remoteAction.Ref
|
|
||||||
}
|
}
|
||||||
|
return sar.remoteAction.Org + "/" + sar.remoteAction.Repo, sar.remoteAction.Ref
|
||||||
return ghc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getStepModel() *model.Step {
|
func (sar *stepActionRemote) getStepModel() *model.Step {
|
||||||
@@ -205,7 +199,7 @@ func (sar *stepActionRemote) getEnv() *map[string]string {
|
|||||||
func (sar *stepActionRemote) getIfExpression(ctx context.Context, stage stepStage) string {
|
func (sar *stepActionRemote) getIfExpression(ctx context.Context, stage stepStage) string {
|
||||||
switch stage {
|
switch stage {
|
||||||
case stepStagePre:
|
case stepStagePre:
|
||||||
github := sar.getGithubContext(ctx)
|
github := getStepGithubContext(ctx, sar)
|
||||||
if sar.skipsLocalCheckout(sar.remoteAction, github) {
|
if sar.skipsLocalCheckout(sar.remoteAction, github) {
|
||||||
// skip local checkout pre step
|
// skip local checkout pre step
|
||||||
return "false"
|
return "false"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
status model.StepStatus
|
status model.StepStatus
|
||||||
runError error
|
runError error
|
||||||
}{
|
}{
|
||||||
{name: "run-successful", status: model.StepStatusSuccess},
|
{name: "run-successful", condition: "github.action_repository == 'other/composite' && github.action_ref == 'v2'", status: model.StepStatusSuccess},
|
||||||
{name: "run-skipped", condition: "false", status: model.StepStatusSkipped},
|
{name: "run-skipped", condition: "false", status: model.StepStatusSkipped},
|
||||||
{name: "run-error", status: model.StepStatusFailure, runError: assert.AnError},
|
{name: "run-error", status: model.StepStatusFailure, runError: assert.AnError},
|
||||||
} {
|
} {
|
||||||
@@ -110,6 +110,7 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
},
|
},
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
|
Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "other/composite", "GITHUB_ACTION_REF": "v2"},
|
||||||
},
|
},
|
||||||
Step: &model.Step{ID: "step", Uses: "remote/action@v1", If: yaml.Node{Value: tt.condition}},
|
Step: &model.Step{ID: "step", Uses: "remote/action@v1", If: yaml.Node{Value: tt.condition}},
|
||||||
readAction: sarm.readAction,
|
readAction: sarm.readAction,
|
||||||
@@ -127,6 +128,8 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
require.NoError(t, sar.pre()(ctx))
|
require.NoError(t, sar.pre()(ctx))
|
||||||
assert.Equal(t, tt.runError, sar.main()(ctx))
|
assert.Equal(t, tt.runError, sar.main()(ctx))
|
||||||
assert.True(t, clonedAction)
|
assert.True(t, clonedAction)
|
||||||
|
assert.Equal(t, "remote/action", sar.env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Equal(t, "v1", sar.env["GITHUB_ACTION_REF"])
|
||||||
assert.Equal(t, &model.StepResult{Conclusion: tt.status, Outcome: tt.status, Outputs: map[string]string{}}, sar.RunContext.StepResults["step"])
|
assert.Equal(t, &model.StepResult{Conclusion: tt.status, Outcome: tt.status, Outputs: map[string]string{}}, sar.RunContext.StepResults["step"])
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
sarm.AssertExpectations(t)
|
||||||
@@ -182,7 +185,7 @@ func TestStepActionRemotePrepare(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
root, err := prepare(job, "org/repo@v1")
|
root, err := prepare(job, "org/repo@v1")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
nested, err := prepare(&RunContext{Config: job.Config, Run: job.Run, JobContainer: job.JobContainer, Parent: job}, "org/repo/save@v1")
|
nested, err := prepare(&RunContext{Config: job.Config, Run: job.Run, JobContainer: job.JobContainer, Parent: job, Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "org/repo", "GITHUB_ACTION_REF": "v1"}}, "$/save")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
other, err := prepare(job, "org/repo@v2")
|
other, err := prepare(job, "org/repo@v2")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -346,9 +349,6 @@ func Test_newRemoteActionSelfRepo(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{name: "unknown instance", action: "$/.gitea/actions/build", github: &model.GithubContext{Repository: "owner/workflow-repo", Sha: "abc123"}},
|
{name: "unknown instance", action: "$/.gitea/actions/build", github: &model.GithubContext{Repository: "owner/workflow-repo", Sha: "abc123"}},
|
||||||
{name: "empty path", action: "$/", github: workflow},
|
|
||||||
{name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow},
|
|
||||||
{name: "path traversal", action: "$/../escape", github: workflow},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ func (sd *stepDocker) getRunContext() *RunContext {
|
|||||||
return sd.RunContext
|
return sd.RunContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sd *stepDocker) getGithubContext(ctx context.Context) *model.GithubContext {
|
|
||||||
return sd.getRunContext().getGithubContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *stepDocker) getStepModel() *model.Step {
|
func (sd *stepDocker) getStepModel() *model.Step {
|
||||||
return sd.Step
|
return sd.Step
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStepDockerMain(t *testing.T) {
|
func TestStepDockerMain(t *testing.T) {
|
||||||
@@ -57,13 +58,16 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
|
Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "other/composite", "GITHUB_ACTION_REF": "v1"},
|
||||||
},
|
},
|
||||||
Step: &model.Step{
|
Step: &model.Step{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
Uses: "docker://node:14",
|
Uses: "docker://node:14",
|
||||||
WorkingDirectory: "workdir",
|
WorkingDirectory: "workdir",
|
||||||
|
If: yaml.Node{Value: "github.action_repository == 'other/composite' && github.action_ref == 'v1'"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
require.NoError(t, sd.Step.RawWith.Encode(map[string]string{"args": "identity:${{ github.action_repository }}:${{ github.action_ref }}"}))
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
cm.On("Pull", false).Return(noopExecutor)
|
cm.On("Pull", false).Return(noopExecutor)
|
||||||
@@ -80,6 +84,11 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
require.NoError(t, sd.main()(ctx))
|
require.NoError(t, sd.main()(ctx))
|
||||||
|
|
||||||
assert.Equal(t, "node:14", input.Image)
|
assert.Equal(t, "node:14", input.Image)
|
||||||
|
assert.Equal(t, []string{"identity::"}, input.Cmd)
|
||||||
|
assert.Contains(t, input.Env, "GITHUB_ACTION_REPOSITORY=")
|
||||||
|
assert.Contains(t, input.Env, "GITHUB_ACTION_REF=")
|
||||||
|
assert.Equal(t, "other/composite", sd.RunContext.Env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Equal(t, "v1", sd.RunContext.Env["GITHUB_ACTION_REF"])
|
||||||
|
|
||||||
assert.Empty(t, input.Username)
|
assert.Empty(t, input.Username)
|
||||||
assert.Empty(t, input.Password)
|
assert.Empty(t, input.Password)
|
||||||
|
|||||||
@@ -222,10 +222,6 @@ func (sr *stepRun) getRunContext() *RunContext {
|
|||||||
return sr.RunContext
|
return sr.RunContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sr *stepRun) getGithubContext(ctx context.Context) *model.GithubContext {
|
|
||||||
return sr.getRunContext().getGithubContext(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sr *stepRun) getStepModel() *model.Step {
|
func (sr *stepRun) getStepModel() *model.Step {
|
||||||
return sr.Step
|
return sr.Step
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shellContainerMock struct{ *containerMock }
|
type shellContainerMock struct{ *containerMock }
|
||||||
@@ -36,7 +37,6 @@ func TestStepRun(t *testing.T) {
|
|||||||
sr := &stepRun{
|
sr := &stepRun{
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
ExprEval: &expressionEvaluator{},
|
|
||||||
Config: &Config{Env: testCheckoutEnv()},
|
Config: &Config{Env: testCheckoutEnv()},
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "1",
|
JobID: "1",
|
||||||
@@ -48,19 +48,33 @@ func TestStepRun(t *testing.T) {
|
|||||||
},
|
},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
jobRunDefaults: model.RunDefaults{Shell: "bash"},
|
jobRunDefaults: model.RunDefaults{Shell: "bash"},
|
||||||
|
Env: map[string]string{"GITHUB_ACTION_REPOSITORY": "other/composite", "GITHUB_ACTION_REF": "v1"},
|
||||||
},
|
},
|
||||||
Step: &model.Step{
|
Step: &model.Step{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
Run: "cmd",
|
Run: "cmd${{ github.action_repository }}${{ github.action_ref }}",
|
||||||
WorkingDirectory: "workdir",
|
WorkingDirectory: "workdir",
|
||||||
|
If: yaml.Node{Value: "github.action_repository == 'other/composite' && github.action_ref == 'v1'"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
sr.RunContext.ExprEval = sr.RunContext.NewExpressionEvaluator(t.Context())
|
||||||
|
require.NoError(t, sr.Step.Env.Encode(map[string]string{
|
||||||
|
"GITHUB_ACTION_REPOSITORY": "forged/repo", "GITHUB_ACTION_REF": "forged-ref",
|
||||||
|
"GITEA_ACTION_REPOSITORY": "forged/repo", "GITEA_ACTION_REF": "forged-ref",
|
||||||
|
}))
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", []*container.FileEntry{fileEntry}).Return(noopExecutor)
|
cm.On("Copy", "/var/run/act", []*container.FileEntry{fileEntry}).Return(noopExecutor)
|
||||||
cm.On("Exec", []string{"bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "/var/run/act/workflow/1.sh"}, mock.AnythingOfType("map[string]string"), "", "workdir").Return(noopExecutor)
|
cm.On("Exec", []string{"bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "/var/run/act/workflow/1.sh"}, mock.AnythingOfType("map[string]string"), "", "workdir").Return(noopExecutor)
|
||||||
|
|
||||||
expectStepFileCommands(t, cm)
|
expectStepFileCommands(t, cm)
|
||||||
require.NoError(t, sr.main()(t.Context()))
|
require.NoError(t, sr.main()(t.Context()))
|
||||||
|
assert.Empty(t, sr.env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Empty(t, sr.env["GITHUB_ACTION_REF"])
|
||||||
|
assert.Empty(t, sr.env["GITEA_ACTION_REPOSITORY"])
|
||||||
|
assert.Empty(t, sr.env["GITEA_ACTION_REF"])
|
||||||
|
assert.Equal(t, sr.env["GITHUB_STEP_SUMMARY"], sr.env["GITEA_STEP_SUMMARY"])
|
||||||
|
assert.Equal(t, "other/composite", sr.RunContext.Env["GITHUB_ACTION_REPOSITORY"])
|
||||||
|
assert.Equal(t, "v1", sr.RunContext.Env["GITHUB_ACTION_REF"])
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -155,18 +155,14 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
require.NoError(t, setupEnv(t.Context(), testStep))
|
require.NoError(t, setupEnv(t.Context(), testStep))
|
||||||
require.NoError(t, setupInputs(t.Context(), testStep))
|
require.NoError(t, setupInputs(t.Context(), testStep))
|
||||||
|
|
||||||
delete(env, "GITHUB_REF")
|
for _, suffix := range []string{"REF", "REF_NAME", "REF_TYPE", "SHA", "WORKSPACE", "REPOSITORY", "REPOSITORY_OWNER", "ACTOR"} {
|
||||||
delete(env, "GITHUB_REF_NAME")
|
delete(env, "GITHUB_"+suffix)
|
||||||
delete(env, "GITHUB_REF_TYPE")
|
delete(env, "GITEA_"+suffix)
|
||||||
delete(env, "GITHUB_SHA")
|
}
|
||||||
delete(env, "GITHUB_WORKSPACE")
|
|
||||||
delete(env, "GITHUB_REPOSITORY")
|
|
||||||
delete(env, "GITHUB_REPOSITORY_OWNER")
|
|
||||||
delete(env, "GITHUB_ACTOR")
|
|
||||||
delete(env, "RUNNER_NAME")
|
delete(env, "RUNNER_NAME")
|
||||||
delete(env, "RUNNER_WORKSPACE")
|
delete(env, "RUNNER_WORKSPACE")
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
expected := map[string]string{
|
||||||
"ACT": "true",
|
"ACT": "true",
|
||||||
"ACT_SKIP_CHECKOUT": "true",
|
"ACT_SKIP_CHECKOUT": "true",
|
||||||
"CI": "true",
|
"CI": "true",
|
||||||
@@ -193,7 +189,13 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
"RUNNER_ENVIRONMENT": "self-hosted",
|
"RUNNER_ENVIRONMENT": "self-hosted",
|
||||||
"RUNNER_PERFLOG": "/dev/null",
|
"RUNNER_PERFLOG": "/dev/null",
|
||||||
"RUNNER_TRACKING_ID": "",
|
"RUNNER_TRACKING_ID": "",
|
||||||
}, env)
|
}
|
||||||
|
for name, value := range expected {
|
||||||
|
if suffix, ok := strings.CutPrefix(name, "GITHUB_"); ok {
|
||||||
|
expected["GITEA_"+suffix] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, expected, env)
|
||||||
|
|
||||||
for _, expression := range []string{"inputs.args", "env.ARGS"} {
|
for _, expression := range []string{"inputs.args", "env.ARGS"} {
|
||||||
t.Run("deferred inputs from "+expression, func(t *testing.T) {
|
t.Run("deferred inputs from "+expression, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Container image that runs your code
|
# Container image that runs your code
|
||||||
FROM node:24-bookworm-slim
|
FROM node:26-bookworm-slim
|
||||||
|
|
||||||
# Copies your code file from your action repository to the filesystem path `/` of the container
|
# Copies your code file from your action repository to the filesystem path `/` of the container
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Container image that runs your code
|
# Container image that runs your code
|
||||||
FROM node:24-bookworm-slim
|
FROM node:26-bookworm-slim
|
||||||
|
|
||||||
# Copies your code file from your action repository to the filesystem path `/` of the container
|
# Copies your code file from your action repository to the filesystem path `/` of the container
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ inputs:
|
|||||||
default: World
|
default: World
|
||||||
runs:
|
runs:
|
||||||
using: docker
|
using: docker
|
||||||
image: docker://node:24-bookworm-slim
|
image: docker://node:26-bookworm-slim
|
||||||
entrypoint: /bin/sh -c
|
entrypoint: /bin/sh -c
|
||||||
env:
|
env:
|
||||||
TEST: enabled
|
TEST: enabled
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ A hook path that does not exist inside the job environment fails the job with `N
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go.
|
A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*`/`GITEA_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go.
|
||||||
|
|
||||||
Its stdout is part of the job log, after a collapsible group naming the hook and its shell, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job. `::set-env::` and `::add-path::` apply to the steps that follow, but as in a step they need `ACTIONS_ALLOW_UNSECURE_COMMANDS: true`, and a refused one fails the hook.
|
Its stdout is part of the job log, after a collapsible group naming the hook and its shell, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job. `::set-env::` and `::add-path::` apply to the steps that follow, but as in a step they need `ACTIONS_ALLOW_UNSECURE_COMMANDS: true`, and a refused one fails the hook.
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,60 @@ A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of
|
|||||||
|
|
||||||
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
|
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
|
||||||
|
|
||||||
|
## Copying files over the pod network
|
||||||
|
|
||||||
|
The runner copies files into the job container, such as the actions a step uses, with `tar` over `pods/exec`. That data goes through the API server, which on a managed cluster often sits in other zones than the nodes, so each byte can cross zones twice. With `transfer.mode` set to `auto` or `direct`, the job container instead fetches directories, such as actions and workspaces, and other copies of 64 KiB or more gzipped from the runner pod. Smaller ones, like step scripts, stay on `pods/exec`, as do copies out of the job container and step output.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kubernetes:
|
||||||
|
transfer:
|
||||||
|
mode: direct # api (default), auto or direct
|
||||||
|
host: "" # address the runner listens on and job pods reach it at, defaults to its outbound IP, the pod IP in a cluster
|
||||||
|
port: 0 # port it listens on, defaults to a free one
|
||||||
|
image: "" # image with gitea-runner on its PATH, defaults to the image of a single-container runner pod in kubernetes.namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
Each job pod then gets an init container named `gitea-runner-transfer` from `image`, which copies its `gitea-runner` into the `act` volume for the job container to fetch with over HTTPS. The modes differ in what happens when a job pod cannot reach the runner, for example because of a NetworkPolicy, or when no `image` is set and the runner is not a single-container pod in `kubernetes.namespace`:
|
||||||
|
|
||||||
|
| Mode | Copies | When the pod network does not work |
|
||||||
|
|---|---|---|
|
||||||
|
| `api` | through the API server | not used |
|
||||||
|
| `auto` | over the pod network | the job pod gets its copies through the API server, after one failed attempt, and a warning is logged |
|
||||||
|
| `direct` | over the pod network | the job fails, so a broken setup shows instead of quietly costing API server traffic again |
|
||||||
|
|
||||||
|
In both `auto` and `direct`, a job fails when its init container does, for example when `image` cannot be pulled or has no `gitea-runner` on its PATH. A private `image` needs `imagePullSecrets` in `pod_template`, and a versioned tag keeps job pods on the runner's version. An `initContainers` entry named `gitea-runner-transfer` in `pod_template` merges into the init container, for example to set its `resources`.
|
||||||
|
|
||||||
|
The copies include secrets, such as `run` scripts with `${{ secrets.* }}` expanded, SSH keys for checkout and the runner's client key, so a runner only serves them to its own job pods:
|
||||||
|
|
||||||
|
- The runner listens with a certificate it generates at start. Its SHA-256 pin reaches the job pod over the `pods/exec` stream, which the API server protects with TLS, and the job pod trusts no other certificate, so nobody on the pod network can read or change a copy.
|
||||||
|
- Every copy has a one-time token, sent on that stream's stdin, so it shows neither in the command line nor in the API server audit log.
|
||||||
|
- The runner serves a copy only to a connection from an IP of the job pod it is for. Other pods get a 404 and leave the copy for its job pod. This needs the runner to see the job pods' own addresses, so it does not work for a runner outside the cluster or behind a proxy that rewrites source addresses.
|
||||||
|
|
||||||
|
To also keep other pods from connecting at all, allow the port only from the runner's own job pods, which carry its UUID in the `com.gitea.runner.uuid` label. The policy denies every other connection to the runner pod, so it also needs to allow `cache.port`, and both ports need fixed values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: gitea-runner-transfer
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: runner # the runner pod
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: <kubernetes.namespace>
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
com.gitea.runner.uuid: <runner UUID, from the .runner file>
|
||||||
|
ports:
|
||||||
|
- port: 9090 # kubernetes.transfer.port
|
||||||
|
- port: 9091 # cache.port
|
||||||
|
```
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
Services share the pod's network, so a step reaches one by its id or `localhost`, and two services cannot listen on the same port. An id that is not a valid hostname is sanitized, so `my_db` is reached as `my-db`. Pass secrets to services through `env`, as `command` is visible in the pod spec. Of `options`, only `--health-*` is supported. They become a startup probe with Docker's defaults, which stops a service that fails its retries, so the job fails as on Docker.
|
Services share the pod's network, so a step reaches one by its id or `localhost`, and two services cannot listen on the same port. An id that is not a valid hostname is sanitized, so `my_db` is reached as `my-db`. Pass secrets to services through `env`, as `command` is visible in the pod spec. Of `options`, only `--health-*` is supported. They become a startup probe with Docker's defaults, which stops a service that fails its retries, so the job fails as on Docker.
|
||||||
|
|||||||
+11
-10
@@ -28,7 +28,7 @@ func testRunCancellation(t *testing.T) {
|
|||||||
pushWorkflow(t, api, repo, "cancel.yml")
|
pushWorkflow(t, api, repo, "cancel.yml")
|
||||||
|
|
||||||
wfRun := waitForRun(t, api, repo)
|
wfRun := waitForRun(t, api, repo)
|
||||||
waitForRunningJobLog(t, api, repo, wfRun.ID, "e2e-live-log-marker")
|
waitForRunningJobLog(t, api, repo, wfRun.ID, "e2e-live-log-marker", 1)
|
||||||
if !cancelSupported {
|
if !cancelSupported {
|
||||||
requireSuccess(t, api, repo, wfRun.ID)
|
requireSuccess(t, api, repo, wfRun.ID)
|
||||||
return
|
return
|
||||||
@@ -49,31 +49,32 @@ func testRunCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) {
|
func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, prefix string, count int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// The budget covers scheduling and the container start, which a loaded runner makes slow.
|
// The budget covers scheduling and the container start, which a loaded runner makes slow.
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
|
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
status := "none"
|
|
||||||
for {
|
for {
|
||||||
jobs, err := api.Jobs(ctx, repo, runID)
|
jobs, err := api.Jobs(ctx, repo, runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list jobs: %v", err)
|
t.Fatalf("list jobs: %v", err)
|
||||||
}
|
}
|
||||||
if len(jobs) > 0 {
|
logged := 0
|
||||||
status = jobs[0].Status
|
for _, job := range jobs {
|
||||||
if status == "in_progress" {
|
if job.Status == "in_progress" {
|
||||||
logs, err := api.JobLogs(ctx, repo, jobs[0].ID)
|
if logs, err := api.JobLogs(ctx, repo, job.ID); err == nil && commandRow(logs, prefix) != "" {
|
||||||
if err == nil && commandRow(logs, substr) == substr {
|
logged++
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if logged >= count {
|
||||||
|
return
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
dumpRunLogs(t, api, repo, runID)
|
dumpRunLogs(t, api, repo, runID)
|
||||||
t.Fatalf("job of run %d is %q and never logged %q", runID, status, substr)
|
t.Fatalf("%d of %d running jobs of run %d logged %q", logged, count, runID, prefix)
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(pollInterval):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -67,10 +67,9 @@ func newCLIRunner(t *testing.T, label, extraRunnerConfig string) *cliRunner {
|
|||||||
log_report_interval: 500ms
|
log_report_interval: 500ms
|
||||||
%scache:
|
%scache:
|
||||||
dir: %s
|
dir: %s
|
||||||
`, filepath.Join(dir, ".runner"), extraRunnerConfig, filepath.Join(dir, "cache"))
|
container:
|
||||||
if fixture.network != "" {
|
network: %s
|
||||||
cfg += "container:\n network: " + fixture.network + "\n"
|
`, filepath.Join(dir, ".runner"), extraRunnerConfig, filepath.Join(dir, "cache"), fixture.network)
|
||||||
}
|
|
||||||
c := &cliRunner{api: api, repo: repo, bin: bin, cfgFile: filepath.Join(dir, "config.yaml"), dir: dir}
|
c := &cliRunner{api: api, repo: repo, bin: bin, cfgFile: filepath.Join(dir, "config.yaml"), dir: dir}
|
||||||
if err := os.WriteFile(c.cfgFile, []byte(cfg), 0o600); err != nil {
|
if err := os.WriteFile(c.cfgFile, []byte(cfg), 0o600); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
@@ -221,13 +220,26 @@ func testCLILabelsOverride(t *testing.T) {
|
|||||||
func testCLIShutdownDrains(t *testing.T) {
|
func testCLIShutdownDrains(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := newCLIRunner(t, "e2e-shutdown", " shutdown_timeout: 1m\n")
|
c := newCLIRunner(t, "e2e-shutdown", " shutdown_timeout: 1m\n")
|
||||||
|
gate, release := newGate(t)
|
||||||
|
if err := c.api.CreateVariable(t.Context(), c.repo, "E2E_GATE", gate); err != nil {
|
||||||
|
t.Fatalf("create gate variable: %v", err)
|
||||||
|
}
|
||||||
d := c.startDaemon(t)
|
d := c.startDaemon(t)
|
||||||
|
|
||||||
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
||||||
wfRun := waitForRun(t, c.api, c.repo)
|
wfRun := waitForRun(t, c.api, c.repo)
|
||||||
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started")
|
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started", 1)
|
||||||
|
|
||||||
d.terminate(t)
|
d.terminate(t)
|
||||||
|
deadline := time.After(time.Minute)
|
||||||
|
for !strings.Contains(d.log.String(), "shutdown initiated") {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("daemon never started shutting down\n%s", d.log)
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release()
|
||||||
if err := d.wait(t, runTimeout); err != nil {
|
if err := d.wait(t, runTimeout); err != nil {
|
||||||
t.Errorf("daemon did not exit cleanly: %v\n%s", err, d.log)
|
t.Errorf("daemon did not exit cleanly: %v\n%s", err, d.log)
|
||||||
}
|
}
|
||||||
@@ -248,7 +260,7 @@ func testCLIShutdownCancels(t *testing.T) {
|
|||||||
|
|
||||||
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
||||||
wfRun := waitForRun(t, c.api, c.repo)
|
wfRun := waitForRun(t, c.api, c.repo)
|
||||||
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started")
|
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started", 1)
|
||||||
|
|
||||||
d.terminate(t)
|
d.terminate(t)
|
||||||
if err := d.wait(t, runTimeout); err != nil {
|
if err := d.wait(t, runTimeout); err != nil {
|
||||||
@@ -297,10 +309,7 @@ func testExecArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
artifacts := t.TempDir()
|
artifacts := t.TempDir()
|
||||||
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts}
|
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network}
|
||||||
if fixture.network != "" {
|
|
||||||
args = append(args, "--network", fixture.network)
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, bin, args...)
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
|||||||
@@ -28,19 +28,20 @@ func TestCompatibility(t *testing.T) {
|
|||||||
t.Run("payloads", testPayloads)
|
t.Run("payloads", testPayloads)
|
||||||
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
||||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||||
t.Run("dispatch", testWorkflowDispatch)
|
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
||||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
t.Run("builtin_checkout", testBuiltinCheckout)
|
||||||
t.Run("exec_artifacts", testExecArtifacts)
|
t.Run("exec_artifacts", testExecArtifacts)
|
||||||
t.Run("moving_action_ref", testMovingActionRef)
|
t.Run("moving_action_ref", testMovingActionRef)
|
||||||
t.Run("ephemeral", testEphemeralRunner)
|
t.Run("ephemeral", testEphemeralRunner)
|
||||||
t.Run("failure_paths", testFailurePaths)
|
t.Run("failure_paths", inAllModes(testFailurePaths))
|
||||||
|
t.Run("job_timeout", testJobTimeout)
|
||||||
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
||||||
t.Run("cli_once", testCLIOnce)
|
t.Run("cli_once", testCLIOnce)
|
||||||
t.Run("cli_labels_override", testCLILabelsOverride)
|
t.Run("cli_labels_override", testCLILabelsOverride)
|
||||||
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
||||||
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
||||||
t.Run("host_mode", testHostMode)
|
t.Run("host_mode", testHostMode)
|
||||||
t.Run("reusable_workflow", testReusableWorkflow)
|
t.Run("reusable_workflow", inAllModes(testReusableWorkflow))
|
||||||
t.Run("label_routing", testLabelRouting)
|
t.Run("label_routing", testLabelRouting)
|
||||||
t.Run("capacity", testCapacity)
|
t.Run("capacity", testCapacity)
|
||||||
t.Run("reconnect", testReconnect)
|
t.Run("reconnect", testReconnect)
|
||||||
|
|||||||
+4
-20
@@ -11,27 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testWorkflowDispatch(t *testing.T) {
|
func testWorkflowDispatch(t *testing.T, m mode) {
|
||||||
t.Parallel()
|
result := runWorkflow(t, m, workflowSpec{files: []string{"dispatch.yml"}, inputs: map[string]string{"subject": "dispatch-input-value"}})
|
||||||
ctx := t.Context()
|
if result.conclusion != "success" {
|
||||||
|
t.Errorf("run concluded %q, want success", result.conclusion)
|
||||||
api, repo := newScenario(t)
|
|
||||||
pushWorkflow(t, api, repo, "dispatch.yml")
|
|
||||||
|
|
||||||
branch, err := api.DefaultBranch(ctx, repo)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get default branch: %v", err)
|
|
||||||
}
|
}
|
||||||
inputs := map[string]string{"subject": "dispatch-input-value"}
|
|
||||||
if err := waitForDispatch(ctx, api, repo, "dispatch.yml", branch, inputs); err != nil {
|
|
||||||
t.Fatalf("dispatch workflow: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatched := waitForRun(t, api, repo)
|
|
||||||
if dispatched.Event != "workflow_dispatch" {
|
|
||||||
t.Fatalf("run event is %q, want workflow_dispatch", dispatched.Event)
|
|
||||||
}
|
|
||||||
requireSuccess(t, api, repo, dispatched.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea indexes workflows asynchronously after the Contents API commit.
|
// Gitea indexes workflows asynchronously after the Contents API commit.
|
||||||
|
|||||||
+29
-43
@@ -5,55 +5,41 @@
|
|||||||
|
|
||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func testFailurePaths(t *testing.T) {
|
func testFailurePaths(t *testing.T, m mode) {
|
||||||
|
result := runWorkflow(t, m, workflowSpec{files: []string{"failure.yml"}})
|
||||||
|
if result.conclusion != "failure" {
|
||||||
|
t.Errorf("run concluded %q, want failure", result.conclusion)
|
||||||
|
}
|
||||||
|
for job, want := range map[string]string{"fail": "failure", "tolerated": "success", "dependent": "skipped"} {
|
||||||
|
if got := result.job(job); got != want {
|
||||||
|
t.Errorf("job %q concluded %q, want %q", job, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, marker := range []string{"e2e-before-fail", "e2e-failure-cleanup", "e2e-tolerated-next"} {
|
||||||
|
if !result.hasLine(marker) {
|
||||||
|
t.Errorf("logs are missing %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.hasLine("e2e-skipped-step") {
|
||||||
|
t.Error("a success() step ran after a failed step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJobTimeout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := t.Context()
|
api, repo, _ := startIsolatedScenario(t, "timeout.yml", "e2e-timeout", runnerOptions{timeout: 15 * time.Second})
|
||||||
|
|
||||||
api, repo := newScenario(t)
|
|
||||||
pushWorkflow(t, api, repo, "failure.yml")
|
|
||||||
|
|
||||||
wfRun := waitForRun(t, api, repo)
|
wfRun := waitForRun(t, api, repo)
|
||||||
completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, runTimeout)
|
completed, err := api.WaitForRunConclusion(t.Context(), repo, wfRun.ID, runTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dumpRunLogs(t, api, repo, wfRun.ID)
|
dumpRunLogs(t, api, repo, wfRun.ID)
|
||||||
t.Fatalf("wait for run: %v", err)
|
t.Fatalf("wait for run: %v", err)
|
||||||
}
|
}
|
||||||
if completed.Conclusion != "failure" {
|
if logs := runLogs(t, api, repo, wfRun.ID); completed.Conclusion != "failure" || commandRow(logs, "e2e-timeout-started") == "" {
|
||||||
t.Errorf("run concluded %q, want failure", completed.Conclusion)
|
t.Errorf("run concluded %q, want failure after the job started, logs:\n%s", completed.Conclusion, logs)
|
||||||
}
|
|
||||||
|
|
||||||
jobs, err := api.Jobs(ctx, repo, wfRun.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list jobs: %v", err)
|
|
||||||
}
|
|
||||||
want := map[string]string{
|
|
||||||
"fail": "failure",
|
|
||||||
"tolerated": "success",
|
|
||||||
"dependent": "skipped",
|
|
||||||
"timeout": "failure",
|
|
||||||
}
|
|
||||||
for _, job := range jobs {
|
|
||||||
if w, ok := want[job.Name]; ok && job.Conclusion != w {
|
|
||||||
t.Errorf("job %q concluded %q, want %q", job.Name, job.Conclusion, w)
|
|
||||||
}
|
|
||||||
delete(want, job.Name)
|
|
||||||
}
|
|
||||||
for name := range want {
|
|
||||||
t.Errorf("job %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
logs := runLogs(t, api, repo, wfRun.ID)
|
|
||||||
for _, marker := range []string{"e2e-before-fail", "e2e-failure-cleanup", "e2e-tolerated-next", "e2e-timeout-started"} {
|
|
||||||
if commandRow(logs, marker) != marker {
|
|
||||||
t.Errorf("logs are missing %q", marker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if commandRow(logs, "e2e-skipped-step") != "" {
|
|
||||||
t.Error("a success() step ran after a failed step")
|
|
||||||
}
|
|
||||||
if t.Failed() {
|
|
||||||
t.Logf("full stored logs:\n%s", logs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-5
@@ -53,12 +53,16 @@ func (a *GiteaAPI) CreateRepo(ctx context.Context, name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message string) error {
|
func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message string) error {
|
||||||
body := map[string]any{
|
return a.CreateFiles(ctx, repo, map[string]string{path: content}, message)
|
||||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
}
|
||||||
"message": message,
|
|
||||||
|
func (a *GiteaAPI) CreateFiles(ctx context.Context, repo string, files map[string]string, message string) error {
|
||||||
|
var operations []map[string]string
|
||||||
|
for path, content := range files {
|
||||||
|
operations = append(operations, map[string]string{"operation": "create", "path": path, "content": base64.StdEncoding.EncodeToString([]byte(content))})
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", giteaAdminUser, repo, path)
|
body := map[string]any{"files": operations, "message": message}
|
||||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
||||||
|
|||||||
+17
-95
@@ -15,9 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,7 +38,8 @@ type GiteaFixture struct {
|
|||||||
image string
|
image string
|
||||||
version string
|
version string
|
||||||
baseURL string
|
baseURL string
|
||||||
network string // set in container mode; jobs must join it
|
network string // the user-defined network every container of the suite joins
|
||||||
|
selfIP string // the test process's address on network, which job containers can reach
|
||||||
adminToken string
|
adminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,44 +54,10 @@ func dockerClient(ctx context.Context) (mobyclient.APIClient, error) {
|
|||||||
return cli, nil
|
return cli, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer docker bridge gateway, then LAN IP; never 127.0.0.1 (ACTIONS_RUNTIME_URL must reach the host from job containers).
|
|
||||||
func hostAddress(ctx context.Context, cli mobyclient.APIClient) netip.Addr {
|
|
||||||
if gateway, ok := bridgeGateway(ctx, cli); ok && bindable(gateway) {
|
|
||||||
return gateway
|
|
||||||
}
|
|
||||||
|
|
||||||
loopback := netip.AddrFrom4([4]byte{127, 0, 0, 1})
|
|
||||||
|
|
||||||
conn, err := net.Dial("udp", "192.0.2.1:80") // TEST-NET-1: routing table only
|
|
||||||
if err != nil {
|
|
||||||
return loopback
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
addr, ok := conn.LocalAddr().(*net.UDPAddr)
|
|
||||||
if !ok {
|
|
||||||
return loopback
|
|
||||||
}
|
|
||||||
return addr.AddrPort().Addr().Unmap()
|
|
||||||
}
|
|
||||||
|
|
||||||
func bridgeGateway(ctx context.Context, cli mobyclient.APIClient) (netip.Addr, bool) {
|
|
||||||
inspected, err := cli.NetworkInspect(ctx, "bridge", mobyclient.NetworkInspectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return netip.Addr{}, false
|
|
||||||
}
|
|
||||||
for _, cfg := range inspected.Network.IPAM.Config {
|
|
||||||
if cfg.Gateway.Is4() {
|
|
||||||
return cfg.Gateway, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return netip.Addr{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// When tests run inside a container (sibling docker daemon), join that network and address by name.
|
// When tests run inside a container (sibling docker daemon), join that network and address by name.
|
||||||
func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, bool) {
|
func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, string, bool) {
|
||||||
if _, err := os.Stat("/.dockerenv"); err != nil {
|
if _, err := os.Stat("/.dockerenv"); err != nil {
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := os.ReadFile("/proc/sys/kernel/hostname")
|
id, err := os.ReadFile("/proc/sys/kernel/hostname")
|
||||||
@@ -100,42 +65,19 @@ func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, bool) {
|
|||||||
id, err = os.ReadFile("/etc/hostname")
|
id, err = os.ReadFile("/etc/hostname")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
inspected, err := cli.ContainerInspect(ctx, strings.TrimSpace(string(id)), mobyclient.ContainerInspectOptions{})
|
inspected, err := cli.ContainerInspect(ctx, strings.TrimSpace(string(id)), mobyclient.ContainerInspectOptions{})
|
||||||
if err != nil || inspected.Container.NetworkSettings == nil {
|
if err != nil || inspected.Container.NetworkSettings == nil {
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
for name := range inspected.Container.NetworkSettings.Networks {
|
for name, endpoint := range inspected.Container.NetworkSettings.Networks {
|
||||||
if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS
|
if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS
|
||||||
return name, true
|
return name, endpoint.IPAddress.String(), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
|
||||||
|
|
||||||
func bindable(addr netip.Addr) bool {
|
|
||||||
l, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_ = l.Close()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func freeHostPort(host string) (int, error) {
|
|
||||||
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer l.Close()
|
|
||||||
|
|
||||||
addr, ok := l.Addr().(*net.TCPAddr)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("unexpected listener address type %T", l.Addr())
|
|
||||||
}
|
|
||||||
return addr.Port, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
|
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
|
||||||
@@ -151,31 +93,12 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
return nil, errors.New("E2E_GITEA_IMAGE is not set")
|
return nil, errors.New("E2E_GITEA_IMAGE is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano())
|
sharedNet, selfIP, ok := selfNetwork(ctx, cli)
|
||||||
containerPort := network.MustParsePort("3000/tcp")
|
if !ok {
|
||||||
|
return nil, errors.New("e2e tests must run in a container on a user-defined network, `make test-e2e` provides one")
|
||||||
var (
|
|
||||||
baseURL string
|
|
||||||
hostConfig = &apicontainer.HostConfig{}
|
|
||||||
netConfig *network.NetworkingConfig
|
|
||||||
sharedNet, _ = selfNetwork(ctx, cli)
|
|
||||||
)
|
|
||||||
if sharedNet != "" {
|
|
||||||
baseURL = "http://" + net.JoinHostPort(name, "3000")
|
|
||||||
netConfig = &network.NetworkingConfig{
|
|
||||||
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
host := hostAddress(ctx, cli)
|
|
||||||
port, err := freeHostPort(host.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("find a free host port: %w", err)
|
|
||||||
}
|
|
||||||
baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(port))
|
|
||||||
hostConfig.PortBindings = network.PortMap{
|
|
||||||
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano())
|
||||||
|
baseURL := "http://" + net.JoinHostPort(name, "3000")
|
||||||
|
|
||||||
if _, err := cli.ImageInspect(ctx, image); err != nil {
|
if _, err := cli.ImageInspect(ctx, image); err != nil {
|
||||||
return nil, fmt.Errorf("inspect gitea image %s: %w", image, err)
|
return nil, fmt.Errorf("inspect gitea image %s: %w", image, err)
|
||||||
@@ -191,17 +114,16 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
"GITEA__server__LFS_START_SERVER=true",
|
"GITEA__server__LFS_START_SERVER=true",
|
||||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
||||||
},
|
},
|
||||||
ExposedPorts: network.PortSet{containerPort: struct{}{}},
|
|
||||||
},
|
},
|
||||||
HostConfig: hostConfig,
|
HostConfig: &apicontainer.HostConfig{Tmpfs: map[string]string{"/data": "exec"}},
|
||||||
NetworkingConfig: netConfig,
|
NetworkingConfig: &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}},
|
||||||
Name: name,
|
Name: name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create gitea container: %w", err)
|
return nil, fmt.Errorf("create gitea container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet}
|
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet, selfIP: selfIP}
|
||||||
closeClient = false
|
closeClient = false
|
||||||
|
|
||||||
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
|
|||||||
+120
-78
@@ -9,15 +9,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,125 +33,168 @@ import (
|
|||||||
func testKubernetes(t *testing.T) {
|
func testKubernetes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
k3sID, kubernetes, images := startK3s(t)
|
cluster := requireCluster(t)
|
||||||
|
|
||||||
api, repo := newScenario(t)
|
api, repo := newScenario(t)
|
||||||
if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", images[1]); err != nil {
|
if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", cluster.serviceImage); err != nil {
|
||||||
t.Fatalf("create service image variable: %v", err)
|
t.Fatalf("create service image variable: %v", err)
|
||||||
}
|
}
|
||||||
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: images[0], kubernetes: kubernetes})
|
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: cluster.jobImage, kubernetes: cluster.kubernetes})
|
||||||
pushWorkflow(t, api, repo, "kubernetes.yml")
|
pushWorkflow(t, api, repo, "kubernetes.yml")
|
||||||
|
|
||||||
wfRun := waitForRun(t, api, repo)
|
wfRun := waitForRun(t, api, repo)
|
||||||
requireSuccess(t, api, repo, wfRun.ID)
|
requireSuccess(t, api, repo, wfRun.ID)
|
||||||
|
}
|
||||||
|
|
||||||
if out, err := containerExec(ctx, fixture.cli, k3sID, "", nil,
|
// checkClusterLeaks runs after every test, so a pod or secret any of them left behind fails the suite.
|
||||||
|
func checkClusterLeaks() error {
|
||||||
|
if !clusterStarted.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cluster, err := k3s()
|
||||||
|
if err != nil {
|
||||||
|
return nil // the tests that needed the cluster already failed
|
||||||
|
}
|
||||||
|
if out, err := containerExec(context.Background(), dockerCLI, cluster.id, "", nil,
|
||||||
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Errorf("job pods or secrets left behind: %v: %s", err, out)
|
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type kubeCluster struct {
|
||||||
|
id string
|
||||||
|
kubernetes *config.Kubernetes
|
||||||
|
jobImage, serviceImage string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
||||||
|
clusterStarted atomic.Bool
|
||||||
|
clusterCleanup []func()
|
||||||
|
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
||||||
|
cluster, err := k3s()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gitea, err := fixture.cli.ContainerInspect(context.Background(), fixture.id, mobyclient.ContainerInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect gitea container: %w", err)
|
||||||
|
}
|
||||||
|
giteaURL, err := url.Parse(fixture.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse gitea url: %w", err)
|
||||||
|
}
|
||||||
|
cluster.kubernetes.PodTemplate = map[string]any{"spec": map[string]any{"hostAliases": []any{map[string]any{
|
||||||
|
"ip": gitea.Container.NetworkSettings.Networks[fixture.network].IPAddress.String(),
|
||||||
|
"hostnames": []any{giteaURL.Hostname()},
|
||||||
|
}}}}
|
||||||
|
return cluster, nil
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireCluster(t *testing.T) *kubeCluster {
|
||||||
|
t.Helper()
|
||||||
|
cluster, err := sharedCluster()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopCluster() {
|
||||||
|
if !clusterStarted.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = k3s()
|
||||||
|
for _, cleanup := range slices.Backward(clusterCleanup) {
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startK3s(t *testing.T) (string, *config.Kubernetes, []string) {
|
func startK3s() (*kubeCluster, error) {
|
||||||
t.Helper()
|
clusterStarted.Store(true)
|
||||||
ctx := t.Context()
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
cli := fixture.cli
|
defer cancel()
|
||||||
|
cli := dockerCLI
|
||||||
|
sharedNet, _, ok := selfNetwork(ctx, cli)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("e2e tests must run in a container on a user-defined network")
|
||||||
|
}
|
||||||
|
|
||||||
name := fmt.Sprintf("gitea-runner-e2e-k3s-%d", time.Now().UnixNano())
|
name := fmt.Sprintf("gitea-runner-e2e-k3s-%d", time.Now().UnixNano())
|
||||||
apiPort := network.MustParsePort("6443/tcp")
|
|
||||||
server := "https://" + name + ":6443"
|
server := "https://" + name + ":6443"
|
||||||
hostConfig := &apicontainer.HostConfig{Privileged: true}
|
|
||||||
var netConfig *network.NetworkingConfig
|
|
||||||
if fixture.network != "" {
|
|
||||||
netConfig = &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{fixture.network: {}}}
|
|
||||||
} else {
|
|
||||||
loopback := netip.MustParseAddr("127.0.0.1")
|
|
||||||
port, err := freeHostPort(loopback.String())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("find a free host port: %v", err)
|
|
||||||
}
|
|
||||||
server = "https://" + net.JoinHostPort(loopback.String(), strconv.Itoa(port))
|
|
||||||
hostConfig.PortBindings = network.PortMap{apiPort: []network.PortBinding{{HostIP: loopback, HostPort: strconv.Itoa(port)}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
created, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||||
Config: &apicontainer.Config{
|
Config: &apicontainer.Config{
|
||||||
Image: os.Getenv("E2E_K3S_IMAGE"),
|
Image: os.Getenv("E2E_K3S_IMAGE"),
|
||||||
Cmd: []string{"server", "--disable=traefik,metrics-server,servicelb,local-storage,coredns", "--tls-san=" + name, "--kubelet-arg=fail-cgroupv1=false"},
|
Cmd: []string{"server", "--disable=traefik,metrics-server,servicelb,local-storage,coredns", "--tls-san=" + name, "--kubelet-arg=fail-cgroupv1=false"},
|
||||||
ExposedPorts: network.PortSet{apiPort: struct{}{}},
|
|
||||||
},
|
},
|
||||||
HostConfig: hostConfig,
|
HostConfig: &apicontainer.HostConfig{Privileged: true},
|
||||||
NetworkingConfig: netConfig,
|
NetworkingConfig: &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}},
|
||||||
Name: name,
|
Name: name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create k3s container: %v", err)
|
return nil, fmt.Errorf("create k3s container: %w", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
clusterCleanup = append(clusterCleanup, func() {
|
||||||
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
})
|
})
|
||||||
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
t.Fatalf("start k3s container: %v", err)
|
return nil, fmt.Errorf("start k3s container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
containerdCtx, stopWaiting := context.WithTimeout(ctx, 2*time.Minute)
|
if err := waitForContainerLog(ctx, created.ID, "containerd is now running"); err != nil {
|
||||||
defer stopWaiting()
|
return nil, fmt.Errorf("wait for k3s containerd: %w", err)
|
||||||
if err := waitForContainerLog(containerdCtx, created.ID, "containerd is now running"); err != nil {
|
}
|
||||||
t.Fatalf("wait for k3s containerd: %v", err)
|
sandboxPulled := make(chan struct{})
|
||||||
|
go func() { // first pods would otherwise pull the sandbox image themselves
|
||||||
|
defer close(sandboxPulled)
|
||||||
|
pullCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = containerExec(pullCtx, cli, created.ID, "", nil, "sh", "-c",
|
||||||
|
`ctr --namespace=k8s.io images pull "docker.io/$(sed -n 's/^ *sandbox = "\(.*\)"$/\1/p' /var/lib/rancher/k3s/agent/etc/containerd/config.toml)"`)
|
||||||
|
}()
|
||||||
|
images, err := preloadImages(ctx, created.ID, os.Getenv("E2E_JOB_IMAGE"), os.Getenv("SERVICE_IMAGE"))
|
||||||
|
<-sandboxPulled
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
images := preloadImages(t, created.ID, os.Getenv("E2E_JOB_IMAGE"), os.Getenv("SERVICE_IMAGE"))
|
|
||||||
|
|
||||||
waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
for {
|
for {
|
||||||
_, err := containerExec(waitCtx, cli, created.ID, "", nil, "kubectl", "get", "serviceaccount", "default")
|
_, err := containerExec(ctx, cli, created.ID, "", nil, "kubectl", "get", "serviceaccount", "default")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
if inspectErr != nil || !inspected.Container.State.Running || waitCtx.Err() != nil {
|
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
||||||
var logs bytes.Buffer
|
var logs bytes.Buffer
|
||||||
if reader, logsErr := cli.ContainerLogs(ctx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
if reader, logsErr := cli.ContainerLogs(context.Background(), created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||||
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
}
|
}
|
||||||
t.Fatalf("k3s did not start: %v, state %+v %v, last log lines:\n%s", err, inspected.Container.State, inspectErr, logs.String())
|
return nil, fmt.Errorf("k3s did not start: %w, state %+v %v, last log lines:\n%s", err, inspected.Container.State, inspectErr, logs.String())
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-waitCtx.Done():
|
case <-ctx.Done():
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(pollInterval):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kubeconfig, err := containerExec(ctx, cli, created.ID, "", nil, "cat", "/etc/rancher/k3s/k3s.yaml")
|
kubeconfig, err := containerExec(ctx, cli, created.ID, "", nil, "cat", "/etc/rancher/k3s/k3s.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read kubeconfig: %v", err)
|
return nil, fmt.Errorf("read kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
path := filepath.Join(t.TempDir(), "kubeconfig")
|
path := filepath.Join(binDir, "kubeconfig")
|
||||||
if err := os.WriteFile(path, []byte(strings.ReplaceAll(kubeconfig, "https://127.0.0.1:6443", server)), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(strings.ReplaceAll(kubeconfig, "https://127.0.0.1:6443", server)), 0o600); err != nil {
|
||||||
t.Fatalf("write kubeconfig: %v", err)
|
return nil, fmt.Errorf("write kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
kubernetes := &config.Kubernetes{Kubeconfig: path, Namespace: "default"}
|
return &kubeCluster{id: created.ID, kubernetes: &config.Kubernetes{Kubeconfig: path, Namespace: "default"}, jobImage: images[0], serviceImage: images[1]}, nil
|
||||||
|
|
||||||
if fixture.network != "" {
|
|
||||||
gitea, err := cli.ContainerInspect(ctx, fixture.id, mobyclient.ContainerInspectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("inspect gitea container: %v", err)
|
|
||||||
}
|
|
||||||
giteaURL, err := url.Parse(fixture.baseURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse gitea url: %v", err)
|
|
||||||
}
|
|
||||||
kubernetes.PodTemplate = map[string]any{"spec": map[string]any{"hostAliases": []any{map[string]any{
|
|
||||||
"ip": gitea.Container.NetworkSettings.Networks[fixture.network].IPAddress.String(),
|
|
||||||
"hostnames": []any{giteaURL.Hostname()},
|
|
||||||
}}}}
|
|
||||||
}
|
|
||||||
return created.ID, kubernetes, images
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForContainerLog(ctx context.Context, id, message string) error {
|
func waitForContainerLog(ctx context.Context, id, message string) error {
|
||||||
logs, err := fixture.cli.ContainerLogs(ctx, id, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
|
logs, err := dockerCLI.ContainerLogs(ctx, id, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -175,25 +219,23 @@ func waitForContainerLog(ctx context.Context, id, message string) error {
|
|||||||
return fmt.Errorf("container exited before logging %q", message)
|
return fmt.Errorf("container exited before logging %q", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func preloadImages(t *testing.T, k3sID string, images ...string) []string {
|
func preloadImages(ctx context.Context, k3sID string, images ...string) ([]string, error) {
|
||||||
t.Helper()
|
|
||||||
ctx := t.Context()
|
|
||||||
var aliases []string
|
var aliases []string
|
||||||
for index, image := range images {
|
for index, image := range images {
|
||||||
alias := fmt.Sprintf("gitea-runner-e2e/preload-%d:%d", index, time.Now().UnixNano())
|
alias := fmt.Sprintf("gitea-runner-e2e/preload-%d:%d", index, time.Now().UnixNano())
|
||||||
if _, err := fixture.cli.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||||
t.Fatalf("tag %s: %v", image, err)
|
return nil, fmt.Errorf("tag %s: %w", image, err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _, _ = fixture.cli.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
clusterCleanup = append(clusterCleanup, func() { _, _ = dockerCLI.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
||||||
aliases = append(aliases, alias)
|
aliases = append(aliases, alias)
|
||||||
}
|
}
|
||||||
archive, err := fixture.cli.ImageSave(ctx, aliases)
|
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("save %v: %v", images, err)
|
return nil, fmt.Errorf("save %v: %w", images, err)
|
||||||
}
|
}
|
||||||
defer archive.Close()
|
defer archive.Close()
|
||||||
if out, err := containerExec(ctx, fixture.cli, k3sID, "", archive, "ctr", "--namespace=k8s.io", "images", "import", "-"); err != nil {
|
if out, err := containerExec(ctx, dockerCLI, k3sID, "", archive, "ctr", "--namespace=k8s.io", "images", "import", "-"); err != nil {
|
||||||
t.Fatalf("import %v into k3s: %v: %s", images, err, out)
|
return nil, fmt.Errorf("import %v into k3s: %w: %s", images, err, out)
|
||||||
}
|
}
|
||||||
return aliases
|
return aliases, nil
|
||||||
}
|
}
|
||||||
+43
-7
@@ -9,18 +9,24 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/internal/app/poll"
|
"gitea.com/gitea/runner/internal/app/poll"
|
||||||
|
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
const runTimeout = 3 * time.Minute
|
const runTimeout = 3 * time.Minute
|
||||||
@@ -33,6 +39,8 @@ var skipReason string
|
|||||||
|
|
||||||
var binDir string
|
var binDir string
|
||||||
|
|
||||||
|
var dockerCLI mobyclient.APIClient
|
||||||
|
|
||||||
var runnerBinary = sync.OnceValues(func() (string, error) {
|
var runnerBinary = sync.OnceValues(func() (string, error) {
|
||||||
bin := filepath.Join(binDir, "gitea-runner")
|
bin := filepath.Join(binDir, "gitea-runner")
|
||||||
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
||||||
@@ -54,6 +62,17 @@ func runSuite(m *testing.M) int {
|
|||||||
skipReason = fmt.Sprintf("docker unavailable: %v", err)
|
skipReason = fmt.Sprintf("docker unavailable: %v", err)
|
||||||
return m.Run()
|
return m.Run()
|
||||||
}
|
}
|
||||||
|
dockerCLI = cli
|
||||||
|
|
||||||
|
if binDir, err = os.MkdirTemp("", "gitea-runner-e2e-"); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create binary dir: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(binDir)
|
||||||
|
defer stopCluster()
|
||||||
|
if flag.Parse(); flag.Lookup("test.run").Value.String() == "" { // a filtered run starts the cluster only if it needs it
|
||||||
|
go func() { _, _ = k3s() }()
|
||||||
|
}
|
||||||
|
|
||||||
f, err := StartGitea(ctx, cli)
|
f, err := StartGitea(ctx, cli)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,15 +82,14 @@ func runSuite(m *testing.M) int {
|
|||||||
fixture = f
|
fixture = f
|
||||||
defer func() { _ = fixture.Close(context.Background()) }()
|
defer func() { _ = fixture.Close(context.Background()) }()
|
||||||
|
|
||||||
if binDir, err = os.MkdirTemp("", "gitea-runner-e2e-"); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "create binary dir: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(binDir)
|
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
||||||
|
|
||||||
return m.Run()
|
code := m.Run()
|
||||||
|
if err := checkClusterLeaks(); err != nil && code == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
func newScenario(t *testing.T) (*GiteaAPI, string) {
|
func newScenario(t *testing.T) (*GiteaAPI, string) {
|
||||||
@@ -162,6 +180,24 @@ func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newGate serves a URL that answers 503 until release, for jobs to poll instead of sleeping.
|
||||||
|
func newGate(t *testing.T) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
var released atomic.Bool
|
||||||
|
listener, err := net.Listen("tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen for the gate: %v", err)
|
||||||
|
}
|
||||||
|
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !released.Load() {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}), ReadHeaderTimeout: time.Second}
|
||||||
|
go func() { _ = server.Serve(listener) }()
|
||||||
|
t.Cleanup(func() { _ = server.Close() })
|
||||||
|
return fmt.Sprintf("http://%s/", net.JoinHostPort(fixture.selfIP, strconv.Itoa(listener.Addr().(*net.TCPAddr).Port))), func() { released.Store(true) }
|
||||||
|
}
|
||||||
|
|
||||||
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return waitForRunAfter(t, api, repo, 0)
|
return waitForRunAfter(t, api, repo, 0)
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mode struct {
|
||||||
|
exec, kubernetes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var allModes = []mode{{}, {kubernetes: true}, {exec: true}, {exec: true, kubernetes: true}}
|
||||||
|
|
||||||
|
func (m mode) String() string {
|
||||||
|
driver, backend := "daemon", "docker"
|
||||||
|
if m.exec {
|
||||||
|
driver = "exec"
|
||||||
|
}
|
||||||
|
if m.kubernetes {
|
||||||
|
backend = "kubernetes"
|
||||||
|
}
|
||||||
|
return driver + "_" + backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func inAllModes(scenario func(*testing.T, mode)) func(*testing.T) {
|
||||||
|
return func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, m := range allModes {
|
||||||
|
t.Run(m.String(), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
scenario(t, m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowSpec struct {
|
||||||
|
files []string
|
||||||
|
inputs map[string]string // dispatches files[0] instead of relying on push
|
||||||
|
execArgs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowResult struct {
|
||||||
|
conclusion string
|
||||||
|
jobs map[string]string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *workflowResult) job(id string) string {
|
||||||
|
return cmp.Or(r.jobs[id], "skipped") // exec reports nothing for a job it skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *workflowResult) hasLine(line string) bool {
|
||||||
|
return slices.Contains(r.lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||||
|
t.Helper()
|
||||||
|
run := daemonWorkflow
|
||||||
|
if m.exec {
|
||||||
|
run = execWorkflow
|
||||||
|
}
|
||||||
|
result := run(t, m, spec)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if t.Failed() {
|
||||||
|
t.Logf("%s concluded %q, jobs %v, logs:\n%s", m, result.conclusion, result.jobs, strings.Join(result.lines, "\n"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
api, repo := newScenario(t)
|
||||||
|
if m.kubernetes {
|
||||||
|
cluster := requireCluster(t)
|
||||||
|
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: cluster.jobImage, kubernetes: cluster.kubernetes, capacity: 16})
|
||||||
|
}
|
||||||
|
files := map[string]string{}
|
||||||
|
for _, file := range spec.files {
|
||||||
|
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read workflow fixture %s: %v", file, err)
|
||||||
|
}
|
||||||
|
if m.kubernetes {
|
||||||
|
content = bytes.ReplaceAll(content, []byte("runs-on: ubuntu-latest"), []byte("runs-on: e2e-kubernetes"))
|
||||||
|
}
|
||||||
|
files[".gitea/workflows/"+file] = string(content)
|
||||||
|
}
|
||||||
|
if err := api.CreateFiles(ctx, repo, files, "add workflows"); err != nil {
|
||||||
|
t.Fatalf("push workflows: %v", err)
|
||||||
|
}
|
||||||
|
if spec.inputs != nil {
|
||||||
|
branch, err := api.DefaultBranch(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get default branch: %v", err)
|
||||||
|
}
|
||||||
|
if err := waitForDispatch(ctx, api, repo, spec.files[0], branch, spec.inputs); err != nil {
|
||||||
|
t.Fatalf("dispatch workflow: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wfRun := waitForRun(t, api, repo)
|
||||||
|
completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, runTimeout)
|
||||||
|
if err != nil {
|
||||||
|
dumpRunLogs(t, api, repo, wfRun.ID)
|
||||||
|
t.Fatalf("wait for run: %v", err)
|
||||||
|
}
|
||||||
|
jobs, err := api.Jobs(ctx, repo, wfRun.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list jobs: %v", err)
|
||||||
|
}
|
||||||
|
result := &workflowResult{conclusion: completed.Conclusion, jobs: map[string]string{}}
|
||||||
|
for _, job := range jobs {
|
||||||
|
result.jobs[job.Name] = job.Conclusion
|
||||||
|
}
|
||||||
|
for line := range strings.SplitSeq(runLogs(t, api, repo, wfRun.ID), "\n") {
|
||||||
|
if _, payload, found := strings.Cut(line, "Z "); found {
|
||||||
|
result.lines = append(result.lines, strings.TrimRight(payload, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
bin, err := runnerBinary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
workdir := t.TempDir()
|
||||||
|
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
||||||
|
if err := os.MkdirAll(workflows, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, file := range spec.files {
|
||||||
|
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read workflow fixture %s: %v", file, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(workflows, file), content, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"init", "-q"},
|
||||||
|
{"remote", "add", "origin", "https://example.com/e2e/exec.git"},
|
||||||
|
{"-c", "user.name=e2e", "-c", "[email protected]", "commit", "-q", "--allow-empty", "-m", "init"},
|
||||||
|
} {
|
||||||
|
if out, err := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"exec", "--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
|
||||||
|
env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
||||||
|
if m.kubernetes {
|
||||||
|
cluster := requireCluster(t)
|
||||||
|
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
||||||
|
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig)
|
||||||
|
} else {
|
||||||
|
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
|
||||||
|
}
|
||||||
|
if spec.inputs != nil {
|
||||||
|
args = append(args, "-E", "workflow_dispatch")
|
||||||
|
for name, value := range spec.inputs {
|
||||||
|
args = append(args, "--input", name+"="+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...)
|
||||||
|
cmd.Env = env
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
runErr := cmd.Run()
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if runErr != nil && !errors.As(runErr, &exitErr) {
|
||||||
|
t.Fatalf("exec: %v\n%s", runErr, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
|
||||||
|
if runErr != nil {
|
||||||
|
result.conclusion = "failure"
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(&stdout)
|
||||||
|
scanner.Buffer(nil, 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var entry struct {
|
||||||
|
JobID string `json:"jobID"`
|
||||||
|
JobResult string `json:"jobResult"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
RawOutput bool `json:"raw_output"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.JobResult != "" {
|
||||||
|
result.jobs[entry.JobID] = entry.JobResult
|
||||||
|
}
|
||||||
|
if entry.RawOutput {
|
||||||
|
for line := range strings.SplitSeq(strings.TrimRight(entry.Msg, "\n"), "\n") {
|
||||||
|
result.lines = append(result.lines, strings.TrimRight(line, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.conclusion != "success" && len(result.jobs) == 0 {
|
||||||
|
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
+15
-4
@@ -10,6 +10,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -101,6 +103,10 @@ func (p *flakyProxy) restore() { p.down.Store(false) }
|
|||||||
func testReconnect(t *testing.T) {
|
func testReconnect(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
api, repo := newScenario(t)
|
api, repo := newScenario(t)
|
||||||
|
gate, release := newGate(t)
|
||||||
|
if err := api.CreateVariable(t.Context(), repo, "E2E_GATE", gate); err != nil {
|
||||||
|
t.Fatalf("create gate variable: %v", err)
|
||||||
|
}
|
||||||
proxy := newFlakyProxy(t, fixture.baseURL)
|
proxy := newFlakyProxy(t, fixture.baseURL)
|
||||||
startRunner(t, repo, "e2e-reconnect", runnerOptions{host: true, address: proxy.URL()})
|
startRunner(t, repo, "e2e-reconnect", runnerOptions{host: true, address: proxy.URL()})
|
||||||
|
|
||||||
@@ -112,14 +118,19 @@ func testReconnect(t *testing.T) {
|
|||||||
|
|
||||||
pushWorkflow(t, api, repo, "reconnect.yml")
|
pushWorkflow(t, api, repo, "reconnect.yml")
|
||||||
slow := waitForRunAfter(t, api, repo, idle.ID)
|
slow := waitForRunAfter(t, api, repo, idle.ID)
|
||||||
waitForRunningJobLog(t, api, repo, slow.ID, "e2e-before-outage")
|
waitForRunningJobLog(t, api, repo, slow.ID, "e2e-before-outage", 1)
|
||||||
// the job logs a line per second, so a long outage spans several that can only arrive after it
|
// the job logs a line every 200ms until released, so the outage spans several that can only arrive after it
|
||||||
proxy.cut(t, 8)
|
proxy.cut(t, 8)
|
||||||
proxy.restore()
|
proxy.restore()
|
||||||
|
release()
|
||||||
requireSuccess(t, api, repo, slow.ID)
|
requireSuccess(t, api, repo, slow.ID)
|
||||||
logs := runLogs(t, api, repo, slow.ID)
|
logs := runLogs(t, api, repo, slow.ID)
|
||||||
for i := 1; i <= 15; i++ {
|
ticks, err := strconv.Atoi(strings.TrimPrefix(commandRow(logs, "ticks="), "ticks="))
|
||||||
if tick := fmt.Sprintf("tick-%02d", i); commandRow(logs, tick) != tick {
|
if err != nil {
|
||||||
|
t.Fatalf("the job did not report its tick count: %v\n%s", err, logs)
|
||||||
|
}
|
||||||
|
for i := 1; i <= ticks; i++ {
|
||||||
|
if tick := fmt.Sprintf("tick-%d", i); commandRow(logs, tick) != tick {
|
||||||
t.Errorf("log line %s written during or after the outage was lost:\n%s", tick, logs)
|
t.Errorf("log line %s written during or after the outage was lost:\n%s", tick, logs)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-10
@@ -8,15 +8,12 @@ package e2e
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// testReusableWorkflow runs a local workflow_call workflow and consumes its output.
|
// testReusableWorkflow runs a local workflow_call workflow and consumes its output.
|
||||||
func testReusableWorkflow(t *testing.T) {
|
func testReusableWorkflow(t *testing.T, m mode) {
|
||||||
t.Parallel()
|
result := runWorkflow(t, m, workflowSpec{
|
||||||
api, repo := newScenario(t)
|
files: []string{"reusable_callee.yml", "reusable_caller.yml"},
|
||||||
pushWorkflow(t, api, repo, "reusable_callee.yml")
|
execArgs: []string{"--no-skip-checkout"},
|
||||||
pushWorkflow(t, api, repo, "reusable_caller.yml")
|
})
|
||||||
|
if result.conclusion != "success" || !result.hasLine("e2e-reusable-ran") {
|
||||||
wfRun := waitForRun(t, api, repo)
|
t.Errorf("run concluded %q, want success with the called workflow's marker", result.conclusion)
|
||||||
requireSuccess(t, api, repo, wfRun.ID)
|
|
||||||
if logs := runLogs(t, api, repo, wfRun.ID); commandRow(logs, "e2e-reusable-ran") == "" {
|
|
||||||
t.Errorf("the called workflow did not run:\n%s", logs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ type runnerOptions struct {
|
|||||||
host bool // register a host-mode label instead of a docker one
|
host bool // register a host-mode label instead of a docker one
|
||||||
name string // suffix that keeps runner names unique when a test starts several
|
name string // suffix that keeps runner names unique when a test starts several
|
||||||
address string // Gitea address the runner talks to, default the fixture
|
address string // Gitea address the runner talks to, default the fixture
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||||
@@ -60,9 +61,8 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
cfg.Runner.StateReportInterval = 500 * time.Millisecond // so cancel reaches the job quickly
|
cfg.Runner.StateReportInterval = 500 * time.Millisecond // so cancel reaches the job quickly
|
||||||
cfg.Runner.LogReportInterval = 500 * time.Millisecond
|
cfg.Runner.LogReportInterval = 500 * time.Millisecond
|
||||||
cfg.Runner.Capacity = max(options.capacity, 1)
|
cfg.Runner.Capacity = max(options.capacity, 1)
|
||||||
if fixture.network != "" {
|
cfg.Runner.Timeout = cmp.Or(options.timeout, cfg.Runner.Timeout)
|
||||||
cfg.Container.Network = fixture.network
|
cfg.Container.Network = fixture.network
|
||||||
}
|
|
||||||
|
|
||||||
scheme := labels.SchemeDocker
|
scheme := labels.SchemeDocker
|
||||||
if options.kubernetes != nil {
|
if options.kubernetes != nil {
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ func testCapacity(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
api, repo := newScenario(t)
|
api, repo := newScenario(t)
|
||||||
|
gate, release := newGate(t)
|
||||||
|
if err := api.CreateVariable(t.Context(), repo, "E2E_GATE", gate); err != nil {
|
||||||
|
t.Fatalf("create gate variable: %v", err)
|
||||||
|
}
|
||||||
startRunner(t, repo, "e2e-capacity", runnerOptions{capacity: tc.capacity})
|
startRunner(t, repo, "e2e-capacity", runnerOptions{capacity: tc.capacity})
|
||||||
pushWorkflow(t, api, repo, "capacity.yml")
|
pushWorkflow(t, api, repo, "capacity.yml")
|
||||||
|
|
||||||
wfRun := waitForRun(t, api, repo)
|
wfRun := waitForRun(t, api, repo)
|
||||||
|
waitForRunningJobLog(t, api, repo, wfRun.ID, "span-start=", tc.capacity)
|
||||||
|
release()
|
||||||
requireSuccess(t, api, repo, wfRun.ID)
|
requireSuccess(t, api, repo, wfRun.ID)
|
||||||
|
|
||||||
jobs, err := api.Jobs(t.Context(), repo, wfRun.ID)
|
jobs, err := api.Jobs(t.Context(), repo, wfRun.ID)
|
||||||
|
|||||||
Vendored
+2
-2
@@ -6,12 +6,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
echo "span-start=$(date +%s%N)"
|
echo "span-start=$(date +%s%N)"
|
||||||
sleep 8
|
until curl -fs "${{ vars.E2E_GATE }}" > /dev/null; do sleep 0.1; done
|
||||||
echo "span-end=$(date +%s%N)"
|
echo "span-end=$(date +%s%N)"
|
||||||
two:
|
two:
|
||||||
runs-on: e2e-capacity
|
runs-on: e2e-capacity
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
echo "span-start=$(date +%s%N)"
|
echo "span-start=$(date +%s%N)"
|
||||||
sleep 8
|
until curl -fs "${{ vars.E2E_GATE }}" > /dev/null; do sleep 0.1; done
|
||||||
echo "span-end=$(date +%s%N)"
|
echo "span-end=$(date +%s%N)"
|
||||||
+1
-1
@@ -6,5 +6,5 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
echo e2e-shutdown-started
|
echo e2e-shutdown-started
|
||||||
sleep 8
|
until curl -fs "${{ vars.E2E_GATE }}" > /dev/null; do sleep 0.1; done
|
||||||
echo e2e-shutdown-finished
|
echo e2e-shutdown-finished
|
||||||
Vendored
-7
@@ -21,10 +21,3 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo dependent
|
- run: echo dependent
|
||||||
timeout:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 1
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
echo e2e-timeout-started
|
|
||||||
sleep 300
|
|
||||||
Vendored
+5
-2
@@ -9,7 +9,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
echo e2e-before-outage
|
echo e2e-before-outage
|
||||||
for i in $(seq -w 1 15); do
|
i=0
|
||||||
|
until curl -fs "${{ vars.E2E_GATE }}" > /dev/null; do
|
||||||
|
i=$((i + 1))
|
||||||
echo "tick-$i"
|
echo "tick-$i"
|
||||||
sleep 1
|
sleep 0.2
|
||||||
done
|
done
|
||||||
|
echo "ticks=$i"
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
name: timeout
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
timeout:
|
||||||
|
runs-on: e2e-timeout
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo e2e-timeout-started
|
||||||
|
sleep 300
|
||||||
@@ -5,9 +5,9 @@ go 1.27
|
|||||||
toolchain go1.27.3
|
toolchain go1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect/v2 v2.0.1
|
connectrpc.com/connect/v2 v2.0.2
|
||||||
gitea.dev/actionslib v1.5.0
|
gitea.dev/actionslib v1.5.1
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.3
|
github.com/bmatcuk/doublestar/v4 v4.10.4
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.47.0
|
go.opentelemetry.io/otel/trace v1.47.0
|
||||||
go.opentelemetry.io/proto/otlp v1.11.1
|
go.opentelemetry.io/proto/otlp v1.11.1
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
|
||||||
golang.org/x/net v0.61.0
|
golang.org/x/net v0.62.0
|
||||||
golang.org/x/sync v0.24.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.49.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/term v0.47.0
|
golang.org/x/term v0.47.0
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
connectrpc.com/connect/v2 v2.0.1 h1:WTAycxe87UqxSM5oxS30d1dalZ+C5xz8Ec8O6zVYkHQ=
|
connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw=
|
||||||
connectrpc.com/connect/v2 v2.0.1/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo=
|
connectrpc.com/connect/v2 v2.0.2/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo=
|
||||||
gitea.dev/actionslib v1.5.0 h1:dC3v2HTWbbvyNBSdg2W3kd4WNC/p+FnZaKpl1MdzM00=
|
gitea.dev/actionslib v1.5.1 h1:QVOaENNWx4EFRlZvrWmMTKaKLbaqxdlhWLtsI4kf2v8=
|
||||||
gitea.dev/actionslib v1.5.0/go.mod h1:C9wmOFgDBCXyRvg4A3KTX8Vszs8v9+elH4QLGVDolqU=
|
gitea.dev/actionslib v1.5.1/go.mod h1:C9wmOFgDBCXyRvg4A3KTX8Vszs8v9+elH4QLGVDolqU=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI=
|
github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
@@ -111,8 +111,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func Execute(ctx context.Context) {
|
|||||||
// ./gitea-runner bug-report
|
// ./gitea-runner bug-report
|
||||||
rootCmd.AddCommand(loadBugReportCmd())
|
rootCmd.AddCommand(loadBugReportCmd())
|
||||||
|
|
||||||
|
// ./gitea-runner kubernetes-transfer, run inside Kubernetes job pods
|
||||||
|
rootCmd.AddCommand(loadKubernetesTransferCmd())
|
||||||
|
|
||||||
// ./gitea-runner config
|
// ./gitea-runner config
|
||||||
rootCmd.AddCommand(loadConfigCmd(&configFile))
|
rootCmd.AddCommand(loadConfigCmd(&configFile))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadKubernetesTransferCmd is what job pods run when kubernetes.transfer is on: the init container installs the binary, the job container fetches with it.
|
||||||
|
func loadKubernetesTransferCmd() *cobra.Command {
|
||||||
|
transferCmd := &cobra.Command{
|
||||||
|
Use: container.KubernetesTransferCommand,
|
||||||
|
Short: "Copy files from the runner into a Kubernetes job pod",
|
||||||
|
Hidden: true,
|
||||||
|
}
|
||||||
|
transferCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "install <path>",
|
||||||
|
Short: "Copy this binary to path",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_cmd *cobra.Command, args []string) error {
|
||||||
|
return container.InstallKubernetesTransferHelper(args[0])
|
||||||
|
},
|
||||||
|
}, &cobra.Command{
|
||||||
|
Use: "fetch <dir>",
|
||||||
|
Short: "Extract the tar stream the transfer request on stdin points to into dir",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return container.FetchKubernetesTransfer(cmd.Context(), cmd.InOrStdin(), args[0])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return transferCmd
|
||||||
|
}
|
||||||
+39
-14
@@ -415,6 +415,11 @@ func (r *Runner) kubernetesOptions(runsOn []string) container.KubernetesOptions
|
|||||||
PodTemplates: []map[string]any{r.cfg.Kubernetes.PodTemplate},
|
PodTemplates: []map[string]any{r.cfg.Kubernetes.PodTemplate},
|
||||||
RunnerUUID: r.uuid,
|
RunnerUUID: r.uuid,
|
||||||
}
|
}
|
||||||
|
if transfer := r.cfg.Kubernetes.Transfer; transfer.Mode == config.KubernetesTransferModeAuto || transfer.Mode == config.KubernetesTransferModeDirect {
|
||||||
|
options.Transfer = &container.KubernetesTransfer{
|
||||||
|
Host: transfer.Host, Port: transfer.Port, Image: transfer.Image, Fallback: transfer.Mode == config.KubernetesTransferModeAuto,
|
||||||
|
}
|
||||||
|
}
|
||||||
names := r.labels.Names()
|
names := r.labels.Names()
|
||||||
for index, name := range runsOn {
|
for index, name := range runsOn {
|
||||||
if template, ok := r.cfg.Kubernetes.PodTemplates[name]; ok && slices.Contains(names, name) && !slices.Contains(runsOn[:index], name) {
|
if template, ok := r.cfg.Kubernetes.PodTemplates[name]; ok && slices.Contains(names, name) && !slices.Contains(runsOn[:index], name) {
|
||||||
@@ -533,7 +538,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// is removed when the task finishes, so a leaked token stops working as
|
// is removed when the task finishes, so a leaked token stops working as
|
||||||
// soon as the job ends rather than remaining valid for the runner's
|
// soon as the job ends rather than remaining valid for the runner's
|
||||||
// lifetime.
|
// lifetime.
|
||||||
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, cacheURL, reporter)
|
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, cacheCredential(preset, cacheURL), reporter)
|
||||||
defer revokeCache()
|
defer revokeCache()
|
||||||
|
|
||||||
eventJSON, err := json.Marshal(preset.Event)
|
eventJSON, err := json.Marshal(preset.Event)
|
||||||
@@ -690,34 +695,54 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
|
|||||||
//
|
//
|
||||||
// Safe with an empty token (older Gitea did not issue one).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
||||||
func (r *Runner) registerCacheForTask(token, repo, publicURL string, reporter *report.Reporter) (func(), string) {
|
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
||||||
if token == "" || r.cacheHandler == nil {
|
if token == "" || r.cacheHandler == nil {
|
||||||
return func() {}, ""
|
return func() {}, ""
|
||||||
}
|
}
|
||||||
cred := artifactcache.JobCredential{
|
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
|
||||||
Repo: repo,
|
cred.InsecureTLS = r.cfg.Runner.Insecure
|
||||||
PublicURL: publicURL,
|
|
||||||
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
|
|
||||||
InsecureTLS: r.cfg.Runner.Insecure,
|
|
||||||
}
|
|
||||||
revoke := r.cacheHandler.RegisterJob(token, cred)
|
revoke := r.cacheHandler.RegisterJob(token, cred)
|
||||||
if r.cfg.Cache.ExternalServer != "" {
|
if r.cfg.Cache.ExternalServer != "" {
|
||||||
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, repo, publicURL, reporter)
|
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, cred, reporter)
|
||||||
revoke = func() { revokeLocal(); revokeExternal() }
|
revoke = func() { revokeLocal(); revokeExternal() }
|
||||||
}
|
}
|
||||||
return revoke, r.cacheHandler.ResultsURL(cred)
|
return revoke, r.cacheHandler.ResultsURL(cred)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheCredential scopes the job's cache entries by ref, as GitHub does.
|
||||||
|
func cacheCredential(preset *model.GithubContext, publicURL string) artifactcache.JobCredential {
|
||||||
|
ref := preset.Ref
|
||||||
|
if strings.HasPrefix(preset.EventName, "pull_request") && preset.EventName != "pull_request_target" {
|
||||||
|
number, _ := preset.Event["number"].(float64)
|
||||||
|
ref = fmt.Sprintf("refs/pull/%d/head", int64(number)) // review events take github.ref from the reviewer's commit_id
|
||||||
|
}
|
||||||
|
return artifactcache.JobCredential{
|
||||||
|
Repo: preset.Repository,
|
||||||
|
Ref: ref,
|
||||||
|
BaseRef: branchRef(preset.BaseRef),
|
||||||
|
DefaultRef: branchRef(model.AsString(model.NestedMapLookup(preset.Event, "repository", "default_branch"))),
|
||||||
|
PublicURL: publicURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchRef(branch string) string {
|
||||||
|
if branch == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "refs/heads/" + branch
|
||||||
|
}
|
||||||
|
|
||||||
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
||||||
// Failures are logged but not fatal: if registration fails, the cache will
|
// Failures are logged but not fatal: if registration fails, the cache will
|
||||||
// 401 the job's requests — better than failing the whole task for a cache
|
// 401 the job's requests — better than failing the whole task for a cache
|
||||||
// outage. The warning is mirrored to the job log so users can see why their
|
// outage. The warning is mirrored to the job log so users can see why their
|
||||||
// cache calls 401, instead of having to read the runner daemon's stderr.
|
// cache calls 401, instead of having to read the runner daemon's stderr.
|
||||||
func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporter *report.Reporter) func() {
|
func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCredential, reporter *report.Reporter) func() {
|
||||||
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
||||||
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
|
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, struct {
|
||||||
"token": token, "repo": repo, "public_url": publicURL,
|
Token string `json:"token"`
|
||||||
}); err != nil {
|
artifactcache.JobCredential
|
||||||
|
}{token, cred}); err != nil {
|
||||||
logging.Warnf("cache external_server register failed (%s): %v", base, err)
|
logging.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
||||||
@@ -736,7 +761,7 @@ func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func postInternalCache(url, secret string, body map[string]any) error {
|
func postInternalCache(url, secret string, body any) error {
|
||||||
buf, err := json.Marshal(body)
|
buf, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -24,7 +25,7 @@ import (
|
|||||||
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||||
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
|
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
|
||||||
r := &Runner{cacheHandler: handler}
|
r := &Runner{cacheHandler: handler}
|
||||||
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
|
unregister, _ := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo"}, nil)
|
||||||
require.NotNil(t, unregister, token)
|
require.NotNil(t, unregister, token)
|
||||||
unregister()
|
unregister()
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
|||||||
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
|
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
|
||||||
const publicURL = "http://a1b2c3d4e5f6:8088"
|
const publicURL = "http://a1b2c3d4e5f6:8088"
|
||||||
token := "full-flow-token"
|
token := "full-flow-token"
|
||||||
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
|
unregister, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo", PublicURL: publicURL}, nil)
|
||||||
assert.Equal(t, publicURL, resultsURL)
|
assert.Equal(t, publicURL, resultsURL)
|
||||||
|
|
||||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||||
@@ -112,6 +113,24 @@ func decodeJSON(resp *http.Response, v any) error {
|
|||||||
return json.UnmarshalRead(resp.Body, v)
|
return json.UnmarshalRead(resp.Body, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheCredential(t *testing.T) {
|
||||||
|
repository := map[string]any{"default_branch": "main"}
|
||||||
|
review := &model.GithubContext{
|
||||||
|
Repository: "owner/repo", EventName: "pull_request_review", Ref: "refs/heads/main", BaseRef: "release",
|
||||||
|
Event: map[string]any{"repository": repository, "number": float64(3)},
|
||||||
|
}
|
||||||
|
assert.Equal(t, artifactcache.JobCredential{
|
||||||
|
Repo: "owner/repo", Ref: "refs/pull/3/head", BaseRef: "refs/heads/release", DefaultRef: "refs/heads/main", PublicURL: "http://cache",
|
||||||
|
}, cacheCredential(review, "http://cache"))
|
||||||
|
|
||||||
|
review.EventName, review.Ref = "pull_request_target", "refs/heads/release"
|
||||||
|
assert.Equal(t, "refs/heads/release", cacheCredential(review, "").Ref)
|
||||||
|
|
||||||
|
assert.Equal(t, artifactcache.JobCredential{
|
||||||
|
Repo: "owner/repo", Ref: "refs/heads/feature", DefaultRef: "refs/heads/main",
|
||||||
|
}, cacheCredential(&model.GithubContext{Repository: "owner/repo", Ref: "refs/heads/feature", Event: map[string]any{"repository": repository}}, ""))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||||
const secret = "shared-secret-for-tests"
|
const secret = "shared-secret-for-tests"
|
||||||
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
|
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
|
||||||
@@ -151,7 +170,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be unknown to the remote server before registration")
|
"token must be unknown to the remote server before registration")
|
||||||
|
|
||||||
unregister, resultsURL := r.registerCacheForTask(token, repo, publicURL, nil)
|
unregister, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/main", PublicURL: publicURL}, nil)
|
||||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be accepted after registerCacheForTask")
|
"token must be accepted after registerCacheForTask")
|
||||||
|
|
||||||
@@ -194,6 +213,12 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.NoError(t, decodeJSON(resp, &hit))
|
require.NoError(t, decodeJSON(resp, &hit))
|
||||||
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
||||||
|
|
||||||
|
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other"}, nil)
|
||||||
|
defer unregisterOther()
|
||||||
|
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode, "the remote server must scope the entry by the ref the runner sent")
|
||||||
|
|
||||||
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
|
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer dl.Body.Close()
|
defer dl.Body.Close()
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/logging"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
@@ -275,6 +277,17 @@ func TestRunnerKubernetesOptionsLayerTheLabelPodTemplatesInRunsOnOrder(t *testin
|
|||||||
require.Equal(t, []map[string]any{{"base": true}, {"large": true}, {"gpu": true}}, r.kubernetesOptions([]string{"linux", "large", "other", "gpu", "large"}).PodTemplates)
|
require.Equal(t, []map[string]any{{"base": true}, {"large": true}, {"gpu": true}}, r.kubernetesOptions([]string{"linux", "large", "other", "gpu", "large"}).PodTemplates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerKubernetesOptionsTransfer(t *testing.T) {
|
||||||
|
kubernetesRunner := &Runner{cfg: &config.Config{}}
|
||||||
|
kubernetesRunner.cfg.Kubernetes.Transfer = config.KubernetesTransfer{Mode: config.KubernetesTransferModeAPI, Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest"}
|
||||||
|
require.Nil(t, kubernetesRunner.kubernetesOptions(nil).Transfer)
|
||||||
|
|
||||||
|
kubernetesRunner.cfg.Kubernetes.Transfer.Mode = config.KubernetesTransferModeAuto
|
||||||
|
require.Equal(t, &container.KubernetesTransfer{Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest", Fallback: true}, kubernetesRunner.kubernetesOptions(nil).Transfer)
|
||||||
|
kubernetesRunner.cfg.Kubernetes.Transfer.Mode = config.KubernetesTransferModeDirect
|
||||||
|
require.Equal(t, &container.KubernetesTransfer{Host: "10.0.0.5", Port: 4000, Image: "gitea/runner:latest"}, kubernetesRunner.kubernetesOptions(nil).Transfer)
|
||||||
|
}
|
||||||
|
|
||||||
// Proxy variables are assembled per task, because a job's service containers have to be
|
// Proxy variables are assembled per task, because a job's service containers have to be
|
||||||
// reached directly and they are only known once the workflow is parsed.
|
// reached directly and they are only known once the workflow is parsed.
|
||||||
func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
||||||
@@ -325,7 +338,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
|
|
||||||
// The registration is what makes it true: the cache server takes the results service over,
|
// The registration is what makes it true: the cache server takes the results service over,
|
||||||
// having been told which instance to forward the artifact half to.
|
// having been told which instance to forward the artifact half to.
|
||||||
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", r.cacheHandler.ExternalURL(), nil)
|
revoke, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo", PublicURL: r.cacheHandler.ExternalURL()}, nil)
|
||||||
defer revoke()
|
defer revoke()
|
||||||
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
|
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,18 @@ kubernetes:
|
|||||||
#pod_template: {}
|
#pod_template: {}
|
||||||
# Pod templates merged over pod_template, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
# Pod templates merged over pod_template, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
||||||
#pod_templates: {}
|
#pod_templates: {}
|
||||||
|
# Have job pods fetch directories, such as actions, and copies of 64 KiB or more gzipped from the runner over the pod network,
|
||||||
|
# instead of the runner streaming them through the API server. An init container copies gitea-runner from image into each job pod.
|
||||||
|
# api: through the API server (default). auto: over the pod network, through the API server for a job pod that
|
||||||
|
# cannot reach the runner. direct: over the pod network only, so a broken setup fails jobs instead of quietly
|
||||||
|
# costing API server traffic again.
|
||||||
|
#transfer:
|
||||||
|
# mode: api
|
||||||
|
# # Address and port the runner listens on and job pods reach it at, the outbound IP and a free port when empty.
|
||||||
|
# host: ""
|
||||||
|
# port: 0
|
||||||
|
# # Image with gitea-runner on its PATH, the runner pod's image when it has a single container and runs in namespace.
|
||||||
|
# image: ""
|
||||||
|
|
||||||
# Optional local task-admission checks. Disabled by default. When enabled, low
|
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||||
|
|||||||
@@ -210,6 +210,25 @@ type Kubernetes struct {
|
|||||||
Namespace string `yaml:"namespace"` // Namespace of job pods, empty uses the service account's, else the kubeconfig context's, else default.
|
Namespace string `yaml:"namespace"` // Namespace of job pods, empty uses the service account's, else the kubeconfig context's, else default.
|
||||||
PodTemplate map[string]any `yaml:"pod_template"` // PodTemplate is a pod manifest the runner merges its own onto.
|
PodTemplate map[string]any `yaml:"pod_template"` // PodTemplate is a pod manifest the runner merges its own onto.
|
||||||
PodTemplates map[string]map[string]any `yaml:"pod_templates"` // PodTemplates are merged over PodTemplate, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
PodTemplates map[string]map[string]any `yaml:"pod_templates"` // PodTemplates are merged over PodTemplate, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
||||||
|
Transfer KubernetesTransfer `yaml:"transfer"` // Transfer has job pods fetch copied files from the runner over the pod network instead of through the API server.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kubernetes transfer modes: how files the runner copies reach job pods.
|
||||||
|
const (
|
||||||
|
KubernetesTransferModeAPI = "api" // through the API server
|
||||||
|
KubernetesTransferModeAuto = "auto" // over the pod network, through the API server for a job pod that cannot reach the runner
|
||||||
|
KubernetesTransferModeDirect = "direct" // over the pod network only, a copy that cannot reach the runner fails
|
||||||
|
)
|
||||||
|
|
||||||
|
// KubernetesTransferModes are the valid kubernetes.transfer.mode values.
|
||||||
|
var KubernetesTransferModes = []string{KubernetesTransferModeAPI, KubernetesTransferModeAuto, KubernetesTransferModeDirect}
|
||||||
|
|
||||||
|
// KubernetesTransfer configures copying files into job pods over the pod network.
|
||||||
|
type KubernetesTransfer struct {
|
||||||
|
Mode string `yaml:"mode"` // Mode is one of KubernetesTransferModes, api when empty.
|
||||||
|
Host string `yaml:"host"` // Host is the address the runner listens on and job pods reach it at, empty uses the outbound IP.
|
||||||
|
Port uint16 `yaml:"port"` // Port the runner listens on for job pods, 0 picks a free one.
|
||||||
|
Image string `yaml:"image"` // Image has gitea-runner on its PATH, empty uses the image of a single-container runner pod.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||||
@@ -346,6 +365,12 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if !slices.Contains(ToolCacheModes, cfg.Runner.ToolCacheMode) {
|
if !slices.Contains(ToolCacheModes, cfg.Runner.ToolCacheMode) {
|
||||||
return nil, fmt.Errorf("invalid runner.tool_cache_mode %q: must be one of %q", cfg.Runner.ToolCacheMode, ToolCacheModes)
|
return nil, fmt.Errorf("invalid runner.tool_cache_mode %q: must be one of %q", cfg.Runner.ToolCacheMode, ToolCacheModes)
|
||||||
}
|
}
|
||||||
|
if cfg.Kubernetes.Transfer.Mode == "" {
|
||||||
|
cfg.Kubernetes.Transfer.Mode = KubernetesTransferModeAPI
|
||||||
|
}
|
||||||
|
if !slices.Contains(KubernetesTransferModes, cfg.Kubernetes.Transfer.Mode) {
|
||||||
|
return nil, fmt.Errorf("invalid kubernetes.transfer.mode %q: must be one of %q", cfg.Kubernetes.Transfer.Mode, KubernetesTransferModes)
|
||||||
|
}
|
||||||
if cfg.Runner.LogReportLevel != "" {
|
if cfg.Runner.LogReportLevel != "" {
|
||||||
if _, err := logging.ParseLevel(cfg.Runner.LogReportLevel); err != nil {
|
if _, err := logging.ParseLevel(cfg.Runner.LogReportLevel); err != nil {
|
||||||
return nil, fmt.Errorf("invalid runner.log_report_level: %w", err)
|
return nil, fmt.Errorf("invalid runner.log_report_level: %w", err)
|
||||||
|
|||||||
@@ -140,6 +140,19 @@ func TestLoadDefault_ToolCacheMode(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "tool_cache_mode")
|
assert.Contains(t, err.Error(), "tool_cache_mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_KubernetesTransferMode(t *testing.T) {
|
||||||
|
cfg, err := LoadDefault(write(t, "kubernetes:\n transfer:\n host: 10.0.0.5\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, KubernetesTransferModeAPI, cfg.Kubernetes.Transfer.Mode, "copies stay on the API server unless asked")
|
||||||
|
|
||||||
|
cfg, err = LoadDefault(write(t, "kubernetes:\n transfer:\n mode: direct\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, KubernetesTransferModeDirect, cfg.Kubernetes.Transfer.Mode)
|
||||||
|
|
||||||
|
_, err = LoadDefault(write(t, "kubernetes:\n transfer:\n mode: fast\n"))
|
||||||
|
require.ErrorContains(t, err, "kubernetes.transfer.mode")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDefault_RejectsInvalidLogReportLevel(t *testing.T) {
|
func TestLoadDefault_RejectsInvalidLogReportLevel(t *testing.T) {
|
||||||
_, err := LoadDefault(write(t, "runner:\n log_report_level: loud\n"))
|
_, err := LoadDefault(write(t, "runner:\n log_report_level: loud\n"))
|
||||||
require.ErrorContains(t, err, "runner.log_report_level")
|
require.ErrorContains(t, err, "runner.log_report_level")
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func TestTreeKillCancelBeforeCaptureKillsTree(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = stdin.Close() })
|
t.Cleanup(func() { _ = stdin.Close() })
|
||||||
t.Cleanup(func() { _ = input.Close() })
|
t.Cleanup(func() { _ = input.Close() })
|
||||||
cmd := exec.CommandContext(t.Context(), "/bin/sh", "-c", "cat <&0 & echo $!; read value")
|
cmd := exec.CommandContext(t.Context(), "/bin/sh", "-c", "exec 3<&0; cat <&3 & echo $!; read value")
|
||||||
cmd.Stdin = stdin
|
cmd.Stdin = stdin
|
||||||
cmd.SysProcAttr = SysProcAttr("", false)
|
cmd.SysProcAttr = SysProcAttr("", false)
|
||||||
output, err := cmd.StdoutPipe()
|
output, err := cmd.StdoutPipe()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
exec s6-notifyoncheck -n 0 dockerd-entrypoint.sh
|
exec s6-notifyoncheck -d -n 0 dockerd-entrypoint.sh
|
||||||
+13
-1
@@ -23,4 +23,16 @@ for pull in "${pulls[@]}"; do
|
|||||||
done
|
done
|
||||||
((result == 0)) || exit "$result"
|
((result == 0)) || exit "$result"
|
||||||
|
|
||||||
exec "${GO:-go}" test -tags e2e -count=1 -parallel "$E2E_CONCURRENCY" -timeout 20m -v ./e2e/...
|
args=(test -tags e2e -count=1 -parallel "$E2E_CONCURRENCY" -timeout 20m -v "$@" ./e2e/...)
|
||||||
|
if [[ -f /.dockerenv ]]; then
|
||||||
|
exec "${GO:-go}" "${args[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run like CI: in a Linux container on a user-defined network, which every container of the suite joins.
|
||||||
|
network="gitea-runner-e2e-$$"
|
||||||
|
docker network create "$network" > /dev/null
|
||||||
|
trap 'docker network rm "$network" > /dev/null || true' EXIT
|
||||||
|
docker run --rm --network "$network" -w "$PWD" -v "$PWD:$PWD" -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v gitea-runner-e2e-gomod:/go/pkg/mod -v gitea-runner-e2e-gocache:/root/.cache/go-build \
|
||||||
|
-e E2E_CONCURRENCY -e E2E_JOB_IMAGE -e E2E_GITEA_IMAGE -e E2E_K3S_IMAGE -e SERVICE_IMAGE \
|
||||||
|
"golang:$(sed -n 's/^toolchain go//p' go.mod)" go "${args[@]}"
|
||||||
Reference in new issue
Block a user